Files
feuerwerk/src/core/properties/PropertySchema.ts
T
adminandClaude Opus 5.5 a526e1b40f
game-maker / gate (push) Successful in 3m16s
game-maker / suite (push) Failing after 14m37s
Use a shader graph as a node in other shaders (#303)
A calculation needed in several shaders (a greyscale, a noise, an
outline) had to be built again in every one of them. A shader
function keeps it as an asset of its own, called like a behavior is
since #16.

- New asset type `shaderfunction` ("Shader function"), edited in the
  Shader tab. The panel beside the graph shows no picture, the
  readout counts its inputs and outputs.
- Two new nodes: `Function input` and `Function output`. Their
  declared ports, numbers or colours only, are the ports of every
  call. One of each per function, like Start and End.
- The call is node `ShaderCall`, named after the function. The
  palette lists every function, it can be dragged in from the Assets
  panel, and a double-click opens it.
- The compiler sets the function in where it is called
  (`inlineFunctions`). It renames the copied nodes `<call>/<node>`
  and turns an unconnected port into a constant. Passes, Time, the
  Edge cut and the corner walk then see a plain graph.
- A problem inside a function is reported at the call, in the
  function's words. The function's own tab checks it on its own
  (`checkShaderFunction`).
- `ShaderLibrary` keys a shader on its graph and on every function it
  calls, however deep, so a change reaches every caller.
- The port commands mirror a new, renamed or removed port into every
  call, as for a behavior's Start and End. `NodeDef.portKinds` holds
  the two ends to Number and Color.
- As agreed in the issue: no `Property`, `Color property`, Output or
  Vertex output in a function, and `Time` is the caller's clock.
- Own decision: a `Pass` may stand in a function. It becomes a pass
  of the calling shader, so a wide blur can be a function.
- A function calling itself, also through another, says so at the
  node and is not set in. The palette does not offer such a call.
- The Shader tab showed only the compiler's problems. A call nothing
  reaches was never compiled and looked healthy, so a call now also
  shows its own problem there, as in a behavior.

Tests:
- Gate: 228 files, 3510 tests, CHECK EXIT 0 (+13: 11 in
  tests/core/shader-functions.test.ts, 2 for the new icon)
- e2e 28: 100 checks, all passed (+3 in 28l-viii). Called and in
  place both #606060/#a2a2a2, halved in the function #303030/#515151.
- Counter-check: a fingerprint without the called functions fails 1
  unit test, and in the browser the change check (#606060 stays).
- The first e2e runs found two faults, both fixed: a double-click
  only selected the function, and an unreached call of itself
  showed no problem.
- e2e total 1728 is computed (1725 + 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:09:53 +02:00

1152 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Asset } from '../model/Asset';
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
import {
type FieldValue,
isList,
isStruct,
type ListValue,
type PropertyBag,
type PropertyValue,
NO_OBJECT,
objectPlace,
type StructValue,
textOf,
} from '../model/Properties';
import {
coerceAs,
coerceField,
emptyOf,
type FieldKind,
type FlatKind,
type ValueOf,
type VariableKind,
} from './kinds';
import { findAssetOf, type Project } from '../model/Project';
import { ALL_LAYERS, COLLIDES_WITH_KEY, COLLISION_LAYER_KEY } from '../model/collisionLayers';
import { INHERIT_LAYER, OVERLAY_KEY, RENDER_LAYER_KEY, SEES_KEY } from '../model/renderLayers';
import {
TILE_BOUNCINESS_KEY,
TILE_COLLISION_SCALE_KEY,
TILE_COLLISION_SCALE_MAX,
TILE_COLLISION_SCALE_MIN,
TILE_FPS_KEY,
TILE_FRICTION_KEY,
TILE_KIND_KEY,
TILE_ONE_WAY_ANGLE_KEY,
TILE_ONE_WAY_KEY,
TILE_PLAYBACK_KEY,
TILE_SORT_OFFSET_KEY,
TILE_SORT_OFFSET_LIMIT,
TILE_SOLID_KEY,
TILE_TRIGGER_KEY,
} from '../model/tile';
import { TILEMAP_COLUMNS_KEY, TILEMAP_MAX_CELLS, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap';
import {
AXIS_TYPES,
JOINT_ANCHOR_X_KEY,
JOINT_ANCHOR_Y_KEY,
JOINT_COLLIDE_KEY,
JOINT_CONNECTED_KEY,
JOINT_DAMPING_KEY,
JOINT_LENGTH_KEY,
JOINT_LIMIT_DISTANCE_KEY,
JOINT_LIMIT_KEY,
JOINT_MAX_ANGLE_KEY,
JOINT_MAX_DISTANCE_KEY,
JOINT_MAX_FORCE_KEY,
JOINT_MIN_ANGLE_KEY,
JOINT_MIN_DISTANCE_KEY,
JOINT_MOTOR_KEY,
JOINT_MOTOR_LABELS,
JOINT_MOTOR_SPEED_KEY,
JOINT_MOTOR_TARGET_KEY,
JOINT_MOTORS,
type JointMotor,
JOINT_STIFFNESS_KEY,
JOINT_TYPE_KEY,
JOINT_TYPE_LABELS,
JOINT_TYPES,
type JointType,
TWO_ANCHOR_TYPES,
} from '../model/joint';
import { PALETTE_KEY } from '../model/pixelart';
import { SLICE_SCALE_KEY } from '../model/texture';
import {
VECTOR_CANVAS_DEFAULT,
VECTOR_CANVAS_MAX,
VECTOR_HEIGHT_KEY,
VECTOR_WIDTH_KEY,
} from '../model/vectorart';
import type { SceneObject } from '../model/SceneObject';
import { listItemOf, listOf } from './listItems';
import { FONT_KEY } from './fonts';
import {
BURST_COUNT_KEY,
EMITTING_KEY,
FIELD_HEIGHT_KEY,
FIELD_SHADER_KEY,
FIELD_SIZE_DEFAULT,
FIELD_WIDTH_KEY,
fieldShaderOf,
LIFETIME_KEY,
MAX_PARTICLES_KEY,
MODE_KEY,
MODE_OPTIONS,
MOVE_WITH_SOURCE_KEY,
PARTICLE_COLOR_KEY,
PARTICLE_SIZE_KEY,
PARTICLE_TEXTURE_KEY,
particleParamDefs,
PREWARM_KEY,
RATE_KEY,
TIME_SCALE_KEY,
} from './particles';
import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders';
import { SHADER_RESOLUTION_KEY, SHADER_RESOLUTION_OPTIONS } from './cameraShader';
import { LOOP_KEY as SOUND_LOOP_KEY, VOLUME_KEY as SOUND_VOLUME_KEY } from './sounds';
import { Theme } from '../../ui';
/**
* What a kind's definition carries beyond key, label and default. `decimals` belongs only on
* properties that are integral by meaning (counts, sizes in whole pixels), not on geometry: dragging
* produces fractions, and the field rounds the value away rather than hiding it. Without
* `assetTypes` an asset picker offers every placeable asset; `uploadedOnly` keeps drawn pictures out
* of a texture picker (#186). `renderLayers` makes a layer row pick from the project's render layers
* instead of its collision layers (#302), with "Inherit" as the first choice of a single layer.
*/
type Extras<K extends FlatKind> = K extends 'number'
? { min?: number; max?: number; step?: number; decimals?: number }
: K extends 'enum'
? { options: { value: string; label: string }[] }
: K extends 'asset'
? { assetTypes?: readonly AssetType[] }
: K extends 'texture'
? { uploadedOnly?: boolean }
: K extends 'layer' | 'layers'
? { renderLayers?: boolean }
: unknown;
/**
* The definition of a flat kind: its default is a value of that kind, as the table says. Not an
* object's: that default is no value of the kind but its tagged place (`ObjectDef`).
* `shownWith` names a switch on the same bag; the inspector shows the row only while it is on
* (see `isRowShown`), everything else – graph, defaults, files – always has it. `shownFor` does the
* same by a choice: the row is shown while the enum under `key` holds one of `values` (#154); a
* list of such choices must all hold. `shownWhenSet` names a text or asset row the row belongs to:
* shown while that one is not empty (#170).
*/
export type FlatDef<K extends FlatKind = FlatKind> = {
[P in Exclude<K, 'object'>]: {
key: string;
label: string;
kind: P;
default: ValueOf<P>;
/** What a new asset starts with when it differs from `default`, the value a missing key reads as. */
initial?: ValueOf<P>;
shownWith?: string;
shownFor?: ShownFor | readonly ShownFor[];
shownWhenSet?: string;
} & Extras<P>;
}[Exclude<K, 'object'>];
/** The value a new asset starts with, which is also what Reset goes back to. */
export function initialOf(def: PropertyDef): PropertyValue {
return 'initial' in def && def.initial !== undefined ? def.initial : def.default;
}
interface ShownFor {
key: string;
values: readonly string[];
}
/** Whether the inspector shows the row, given the values of the bag it is on. */
export function isRowShown(def: PropertyDef, values: PropertyBag): boolean {
if ('noRow' in def && def.noRow) return false;
const choices: readonly ShownFor[] =
'shownFor' in def && def.shownFor ? ('key' in def.shownFor ? [def.shownFor] : def.shownFor) : [];
if (choices.some(({ key, values: wanted }) => !wanted.includes(textOf(values[key] ?? '')))) return false;
if ('shownWhenSet' in def && def.shownWhenSet && textOf(values[def.shownWhenSet] ?? '') === '')
return false;
return !('shownWith' in def) || def.shownWith === undefined || values[def.shownWith] === true;
}
/** How far a contact normal may lean from a one-way side and still land on it, in degrees (#155, #159). */
export const ONE_WAY_ANGLE_DEFAULT = 45;
export const ONE_WAY_ANGLE_MAX = 90;
const ONE_WAY_ANGLE_ROW: FlatDef<'number'> = {
key: 'oneWayAngle',
label: 'One-way angle',
kind: 'number',
default: ONE_WAY_ANGLE_DEFAULT,
min: 0,
max: ONE_WAY_ANGLE_MAX,
step: 5,
shownWith: 'oneWay',
};
/** The surface a collider or a solid tile (#193) offers, read by the physics engine and by `Move` (#146, #150). */
const FRICTION_ROW: FlatDef<'number'> = {
key: 'friction',
label: 'Friction',
kind: 'number',
default: 0.5,
min: 0,
step: 0.05,
};
const BOUNCINESS_ROW: FlatDef<'number'> = {
key: 'bounciness',
label: 'Bounciness',
kind: 'number',
default: 0,
min: 0,
max: 1,
step: 0.05,
};
/**
* Which colliders may meet (#152). Both rows sit on colliders and on tilemaps, so a map can be
* ground that only some things walk on. The defaults are layer 0 against every layer, which is
* what a project without any named layer does – nothing collides differently than before.
*/
const COLLISION_LAYER_ROWS: readonly FlatDef[] = [
{ key: COLLISION_LAYER_KEY, label: 'Layer', kind: 'layer', default: 0 },
{ key: COLLIDES_WITH_KEY, label: 'Collides with', kind: 'layers', default: ALL_LAYERS },
];
const jointRows = (...values: JointType[]) => ({ shownFor: { key: JOINT_TYPE_KEY, values } });
const JOINT_ENDS = jointRows(...TWO_ANCHOR_TYPES);
const ONLY_SPRING = jointRows('spring');
const ONLY_HINGE = jointRows('hinge');
const ONLY_SLIDER = jointRows('slider');
const ON_AXIS = jointRows(...AXIS_TYPES);
const motorRows = (...values: JointMotor[]) => ({
shownFor: [ON_AXIS.shownFor, { key: JOINT_MOTOR_KEY, values }],
});
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
export type PropertyDef =
| FlatDef
/**
* A list declares nothing about its items: each one carries its own kind (see `listItems.ts`),
* and the graph types a list as a list, so one `list` port fits them all. An `itemType` makes
* every item a struct of that Struct asset (#254), each marked with it (`ITEM_TYPE_KEY`).
* `noRow` keeps it out of the inspector: its items are edited somewhere else (#295).
*/
| { key: string; label: string; kind: 'list'; default: ListValue; itemType?: string; noRow?: boolean }
/**
* `fields` is the declaration; the value carries exactly those keys, which is what lets a struct
* keep its shape while single fields are written (`mergeStruct`). With a `structType` the fields
* are a copy of that Struct asset's (#253), kept in step by `syncStructTypes`.
*/
| {
key: string;
label: string;
kind: 'struct';
default: StructValue;
fields: readonly StructFieldDef[];
structType?: string;
}
/** An object variable holds a live handle during a run, and nothing at rest (`NO_OBJECT`). */
| ObjectDef;
export interface ObjectDef {
key: string;
label: string;
kind: 'object';
default: typeof NO_OBJECT;
}
export { NO_OBJECT };
export interface PrimitiveFieldDef {
key: string;
kind: FieldKind;
}
/**
* A list held by a struct field. Its items declare nothing either, and may nest like any list –
* unless `itemType` names the Struct asset every item is (#254), as on a list variable.
*/
export interface ListFieldDef {
key: string;
kind: 'list';
itemType?: string;
}
/** A struct held by a struct field declares its fields like a struct variable does (#239). */
export interface StructFieldStructDef {
key: string;
kind: 'struct';
fields: readonly StructFieldDef[];
structType?: string;
}
export type StructFieldDef = PrimitiveFieldDef | ListFieldDef | StructFieldStructDef;
export type StructDef = Extract<PropertyDef, { kind: 'struct' }>;
export type PropertySchema = readonly PropertyDef[];
/**
* The definition of a field kind, its default read from `value` (empty when nothing is given). The
* cast is the table's promise that `coerce` yields the kind.
*/
export function flatDef<K extends FieldKind>(
key: string,
kind: K,
{ label = key, value }: { label?: string; value?: PropertyValue } = {},
): FlatDef<K> {
return { key, label, kind, default: coerceField(value, kind) } as FlatDef<K>;
}
export const KEEP_ON_SCENE_LOAD_KEY = 'keepOnSceneLoad';
/**
* Properties every placeable asset has. A disabled object is not drawn, does not collide and its
* behavior does not run – and the same goes for everything parented below it. A kept one travels
* through a `Load scene` with everything below it (#165), which only a top-level object can.
*/
export const COMMON_PROPS: PropertySchema = [
{ key: 'enabled', label: 'Enabled', kind: 'boolean', default: true },
{ key: KEEP_ON_SCENE_LOAD_KEY, label: 'Keep on scene load', kind: 'boolean', default: false },
];
/**
* How a drawn or grouping object is placed inside its parent, on top of its position. They cascade
* like a position does (see `properties/transform.ts`), so a child turns and grows with its parent.
*/
export const TRANSFORM_PROPS: PropertySchema = [
{ key: 'scale', label: 'Scale', kind: 'number', default: 1, min: 0, step: 0.1 },
{ key: 'rotation', label: 'Rotation', kind: 'number', default: 0, step: 1 },
{ key: 'mirrorX', label: 'Mirror X', kind: 'boolean', default: false },
{ key: 'mirrorY', label: 'Mirror Y', kind: 'boolean', default: false },
];
/**
* Cameras and behaviors are placed by the chain but have no look of their own to turn or size. A
* joint turns for a slider's sake, whose rail is its x axis (#154).
*/
const TRANSFORM_TYPES: ReadonlySet<AssetType> = new Set([
'sprite',
'text',
'collider',
'container',
'clip',
'particles',
'tilemap',
'joint',
]);
export function hasTransformProps(type: AssetType): boolean {
return TRANSFORM_TYPES.has(type);
}
const TRANSFORM_KEYS = new Set(TRANSFORM_PROPS.map((d) => d.key));
export function isTransformProp(key: string): boolean {
return TRANSFORM_KEYS.has(key);
}
export const SORT_WITH_PARENT_KEY = 'sortWithParent';
/**
* Whether a child is drawn in its parent's place instead of on its own (#232, see `drawSlots`).
* A project stored before it reads as off, so it draws as it did; a new asset starts with it on.
*/
const SORT_PROPS: PropertySchema = [
{ key: SORT_WITH_PARENT_KEY, label: 'Sort with parent', kind: 'boolean', default: false, initial: true },
];
/**
* A container is not drawn, but with the switch on it passes its content on to its own parent
* (#243). It starts off, so content placed before draws as it did.
*/
const CONTAINER_SORT_PROPS: PropertySchema = [
{ key: SORT_WITH_PARENT_KEY, label: 'Sort with parent', kind: 'boolean', default: false },
];
/** A tilemap has no single place in the draw order, and the other types are not drawn at all. */
const SORT_TYPES: ReadonlySet<AssetType> = new Set(['sprite', 'text', 'particles', 'container']);
function hasSortProps(type: AssetType): boolean {
return SORT_TYPES.has(type);
}
function sortPropsOf(type: AssetType): PropertySchema {
if (!hasSortProps(type)) return [];
return type === 'container' ? CONTAINER_SORT_PROPS : SORT_PROPS;
}
export function isSortProp(key: string): boolean {
return key === SORT_WITH_PARENT_KEY;
}
/**
* Which cameras see a drawn object (#302). Without a choice of its own it is on its parent's layer,
* so a HUD container on `UI` takes everything in it along.
*/
export const RENDER_LAYER_ROW: FlatDef<'layer'> = {
key: RENDER_LAYER_KEY,
label: 'Render layer',
kind: 'layer',
default: INHERIT_LAYER,
renderLayers: true,
};
const RENDER_LAYER_TYPES: ReadonlySet<AssetType> = new Set([
'sprite',
'text',
'particles',
'container',
'tilemap',
]);
function renderLayerPropsOf(type: AssetType): PropertySchema {
return RENDER_LAYER_TYPES.has(type) ? [RENDER_LAYER_ROW] : [];
}
const COMMON_KEYS = new Set(
[...COMMON_PROPS, ...TRANSFORM_PROPS, ...SORT_PROPS, RENDER_LAYER_ROW].map((d) => d.key),
);
/** The switches, the transform and sort rows: reserved names, shown apart from the type's own properties. */
export function isCommonProp(key: string): boolean {
return COMMON_KEYS.has(key);
}
/** A field lives inside its struct, so the reserved property keys do not apply to it. */
export function isValidFieldName(name: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
}
/** Identifier-like, and not a common property key – a variable "enabled" would shadow the switch. */
export function isValidVariableName(name: string): boolean {
return isValidFieldName(name) && !isCommonProp(name);
}
/** How a picture is magnified: smoothed, or sharp for pixels. Pictures and typefaces share it (#169). */
function filterRow(smoothByDefault: boolean): PropertyDef {
return {
key: 'filter',
label: 'Filter',
kind: 'enum',
default: smoothByDefault ? 'linear' : 'nearest',
options: [
{ value: 'linear', label: 'Linear' },
{ value: 'nearest', label: 'Nearest' },
],
};
}
/** Position is deliberately absent: it belongs to scene objects, not to asset types. */
const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
scene: [
{ key: 'backgroundColor', label: 'Background', kind: 'color', default: Theme.colors.graphBackground },
// Pulls on every dynamic rigidbody, in world units per second squared: 980 is Earth at 100 px = 1 m.
{ key: 'gravityX', label: 'Gravity X', kind: 'number', default: 0, step: 10 },
{ key: 'gravityY', label: 'Gravity Y', kind: 'number', default: 980, step: 10 },
],
camera: [
// A view is a pixel raster, so half a pixel of it means nothing.
{ key: 'width', label: 'View width', kind: 'number', default: 640, min: 16, step: 1, decimals: 0 },
{ key: 'height', label: 'View height', kind: 'number', default: 360, min: 16, step: 1, decimals: 0 },
{ key: 'zoom', label: 'Zoom', kind: 'number', default: 1, min: 0.1, max: 10, step: 0.1 },
{ key: SEES_KEY, label: 'Sees', kind: 'layers', default: ALL_LAYERS, renderLayers: true },
// Off: a candidate for the main camera (#5). On: drawn over the main one, see-through (#302).
{ key: OVERLAY_KEY, label: 'Overlay', kind: 'boolean', default: false },
// Laid over the camera's whole picture (#299); its parameters follow, as on a sprite.
{ key: SHADER_KEY, label: 'Shader', kind: 'asset', default: '', assetTypes: ['shader'] },
{
key: SHADER_RESOLUTION_KEY,
label: 'Shader resolution',
kind: 'enum',
default: 'screen',
options: SHADER_RESOLUTION_OPTIONS,
shownWhenSet: SHADER_KEY,
},
],
sprite: [
{ key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 },
{ key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
// The fill while there is no picture, and a tint multiplier once there is one: white = no tint.
{ key: 'color', label: 'Color', kind: 'color', default: 0xffffff },
{ key: 'texture', label: 'Texture', kind: 'texture', default: '' },
// The shader's own parameters follow this row – see `schemaForAsset`. Empty is no shader at all.
{ key: SHADER_KEY, label: 'Shader', kind: 'asset', default: '', assetTypes: ['shader'] },
],
// The picture itself is intrinsic data on the asset (see TextureAsset), not a property.
texture: [
filterRow(true),
// How big the borders of every sliced frame are drawn: 2 makes a 16 px border 32 units wide (#96).
{ key: SLICE_SCALE_KEY, label: 'Nine-slice scale', kind: 'number', default: 1, min: 0.01, step: 0.1 },
],
// The shapes are intrinsic data on the asset (see VectorArtAsset); these three say how big the
// picture they are drawn onto is and how it is sampled – the same `filter` row a texture has,
// because a generated picture goes through the same path (#185).
vectorart: [
{
key: VECTOR_WIDTH_KEY,
label: 'Canvas width',
kind: 'number',
default: VECTOR_CANVAS_DEFAULT,
min: 1,
max: VECTOR_CANVAS_MAX,
step: 1,
decimals: 0,
},
{
key: VECTOR_HEIGHT_KEY,
label: 'Canvas height',
kind: 'number',
default: VECTOR_CANVAS_DEFAULT,
min: 1,
max: VECTOR_CANVAS_MAX,
step: 1,
decimals: 0,
},
filterRow(true),
],
// Layers, frames and the canvas size are intrinsic data on the asset (see PixelArtAsset). A palette
// is shared, so the asset names one; the filter is the texture's row, sharp by default for pixels.
pixelart: [
{ key: PALETTE_KEY, label: 'Palette', kind: 'asset', default: '', assetTypes: ['palette'] },
filterRow(false),
],
// The colours are intrinsic data on the asset (see PaletteAsset).
palette: [],
// The points are intrinsic data on the asset (see CurveAsset).
curve: [],
// The root and the rows are intrinsic data on the asset (see AnimationAsset).
animation: [
{ key: 'fps', label: 'Frames per second', kind: 'number', default: 10, min: 0.1, max: 120, step: 1 },
{
key: 'playback',
label: 'Playback',
kind: 'enum',
default: 'loop',
options: [
{ value: 'loop', label: 'Loop' },
{ value: 'once', label: 'Once (hold)' },
{ value: 'once-reset', label: 'Once (reset)' },
{ value: 'pingpong', label: 'Ping-pong' },
],
},
],
// The pictures are intrinsic data on the asset (see TileAsset); these three say what the
// block holds and how its steps run. There is no tile size: the block's width in tiles is known
// from the kind, so the picture already says it (see `readTile`). A new tile is a plain one; the
// autotile blocks are chosen on purpose (#131).
tile: [
{
key: TILE_KIND_KEY,
label: 'Kind',
kind: 'enum',
default: 'none',
options: [
{ value: 'none', label: 'None (1 × 1)' },
{ value: 'floor', label: 'Floor (2 × 3)' },
{ value: 'wall', label: 'Wall (2 × 2)' },
{ value: 'terrain', label: 'Terrain (6 × 4)' },
],
},
{ key: TILE_FPS_KEY, label: 'Frames per second', kind: 'number', default: 4, min: 0.1, max: 120 },
{
key: TILE_PLAYBACK_KEY,
label: 'Playback',
kind: 'enum',
default: 'loop',
options: [
{ value: 'loop', label: 'Loop' },
{ value: 'pingpong', label: 'Ping-pong' },
],
},
// Solid is what the tile *is* – water, a wall – so it holds wherever the tile lies (#132).
{ key: TILE_SOLID_KEY, label: 'Solid', kind: 'boolean', default: false },
// A trigger tile blocks nothing and tells the collision events what lies on it (#160).
{ key: TILE_TRIGGER_KEY, label: 'Trigger (reports, never blocks)', kind: 'boolean', default: false },
// A solid tile that only blocks from above, like a platform one jumps through (#155).
{ key: TILE_ONE_WAY_KEY, label: 'One-way (from above)', kind: 'boolean', default: false },
{ ...ONE_WAY_ANGLE_ROW, key: TILE_ONE_WAY_ANGLE_KEY, shownWith: TILE_ONE_WAY_KEY },
// No density: a map never moves, so what its cells would weigh counts for nothing.
{ ...FRICTION_ROW, key: TILE_FRICTION_KEY, shownWith: TILE_SOLID_KEY },
{ ...BOUNCINESS_ROW, key: TILE_BOUNCINESS_KEY, shownWith: TILE_SOLID_KEY },
// Below 1 a cell blocks less than its square, above 1 more – but only on the sides without a
// blocking neighbour, so a wall of such cells stays closed (#140).
{
key: TILE_COLLISION_SCALE_KEY,
label: 'Collision scale',
kind: 'number',
default: 1,
min: TILE_COLLISION_SCALE_MIN,
max: TILE_COLLISION_SCALE_MAX,
step: 0.05,
},
{
key: TILE_SORT_OFFSET_KEY,
label: 'Sort offset',
kind: 'number',
default: 0,
min: -TILE_SORT_OFFSET_LIMIT,
max: TILE_SORT_OFFSET_LIMIT,
step: 1,
decimals: 0,
},
],
// The layers and their cells are intrinsic data on the asset (see TilemapAsset); these three say
// how far the grid reaches and how big one cell is drawn. A tile's own pictures are scaled onto
// that cell, so one tilemap can mix tiles drawn at different resolutions.
tilemap: [
{
key: TILEMAP_COLUMNS_KEY,
label: 'Columns',
kind: 'number',
default: 20,
min: 1,
max: TILEMAP_MAX_CELLS,
step: 1,
},
{
key: TILEMAP_ROWS_KEY,
label: 'Rows',
kind: 'number',
default: 15,
min: 1,
max: TILEMAP_MAX_CELLS,
step: 1,
},
{ key: TILEMAP_TILE_KEY, label: 'Tile size', kind: 'number', default: 48, min: 1, step: 1 },
...COLLISION_LAYER_ROWS,
],
// Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`.
behavior: [
{ key: 'runInEditor', label: 'Run in editor', kind: 'boolean', default: false },
{ key: 'runWhilePaused', label: 'Run while paused', kind: 'boolean', default: false },
],
// Every row is declared on the asset itself – see `schemaForBag`.
variables: [],
struct: [],
// The states are the asset's graph (see StateMachineAsset), not a property.
statemachine: [],
// The nodes are the asset's graph (see ShaderAsset); its parameters are rows of the sprites wearing it.
shader: [],
shaderfunction: [],
// The typeface itself is intrinsic data on the asset (see FontAsset), not a property. Nearest draws
// a pixel font a pixel per unit, magnified sharp.
font: [filterRow(true)],
// The file is intrinsic data (see SoundAsset); these two say how it is played by default, and a
// `Play sound` node scales the one and can add to the other.
sound: [
{ key: SOUND_VOLUME_KEY, label: 'Volume', kind: 'number', default: 1, min: 0, max: 1, step: 0.05 },
{ key: SOUND_LOOP_KEY, label: 'Loop', kind: 'boolean', default: false },
],
// How many particles and for how long; what one of them does is the asset's graph, whose Property
// nodes add their rows after these – see `schemaForBag`.
particles: [
{ key: EMITTING_KEY, label: 'Emitting', kind: 'boolean', default: true },
{ key: MODE_KEY, label: 'Mode', kind: 'enum', default: 'continuous', options: MODE_OPTIONS },
{ key: RATE_KEY, label: 'Rate', kind: 'number', default: 20, min: 0, step: 1 },
{ key: BURST_COUNT_KEY, label: 'Burst count', kind: 'number', default: 20, min: 0, step: 1, decimals: 0 },
{ key: LIFETIME_KEY, label: 'Lifetime', kind: 'number', default: 1, min: 0.01, step: 0.1 },
{ key: TIME_SCALE_KEY, label: 'Time scale', kind: 'number', default: 1, min: 0, step: 0.1 },
{ key: PREWARM_KEY, label: 'Prewarm', kind: 'boolean', default: false },
{
key: MAX_PARTICLES_KEY,
label: 'Max particles',
kind: 'number',
default: 500,
min: 1,
step: 50,
decimals: 0,
},
{ key: PARTICLE_SIZE_KEY, label: 'Size', kind: 'number', default: 16, min: 0.1, step: 1 },
{ key: PARTICLE_TEXTURE_KEY, label: 'Texture', kind: 'texture', default: '' },
{ key: PARTICLE_COLOR_KEY, label: 'Color', kind: 'color', default: 0xffffff },
{ key: MOVE_WITH_SOURCE_KEY, label: 'Move with source', kind: 'boolean', default: false },
// Worn by every particle of the source, not by the field as a whole – see `schemaForBag`.
{ key: SHADER_KEY, label: 'Shader', kind: 'asset', default: '', assetTypes: ['shader'] },
// Laid over the whole field at once (#170), and the frame it covers: shown only with one set.
{ key: FIELD_SHADER_KEY, label: 'Field shader', kind: 'asset', default: '', assetTypes: ['shader'] },
{
key: FIELD_WIDTH_KEY,
label: 'Field width',
kind: 'number',
default: FIELD_SIZE_DEFAULT,
min: 1,
step: 8,
shownWhenSet: FIELD_SHADER_KEY,
},
{
key: FIELD_HEIGHT_KEY,
label: 'Field height',
kind: 'number',
default: FIELD_SIZE_DEFAULT,
min: 1,
step: 8,
shownWhenSet: FIELD_SHADER_KEY,
},
],
collider: [
{
key: 'shape',
label: 'Shape',
kind: 'enum',
default: 'box',
options: [
{ value: 'box', label: 'Box' },
{ value: 'circle', label: 'Circle' },
{ value: 'capsule', label: 'Capsule' },
{ value: 'polygon', label: 'Polygon' },
{ value: 'frame', label: 'Frame collision' },
],
},
// A box and a capsule fill width × height; the capsule is rounded along the longer side.
{ key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 },
{ key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
{ key: 'radius', label: 'Radius', kind: 'number', default: 50, min: 1, step: 1 },
// A polygon's corners in the collider's own units, edited on the outline in the scene (#151) –
// and only there: the inspector does not list them (#295).
{
key: 'points',
label: 'Points',
kind: 'list',
noRow: true,
default: [
{ x: 0, y: -50 },
{ x: 50, y: 50 },
{ x: -50, y: 50 },
],
},
// The frame whose collision outlines (#141) the "Frame collision" shape wears, kept linked (#193).
{ key: 'frame', label: 'Frame', kind: 'texture', default: '' },
{ key: 'isTrigger', label: 'Trigger (no blocking)', kind: 'boolean', default: false },
// The material, read by the physics engine where a rigidbody takes part (#146).
FRICTION_ROW,
BOUNCINESS_ROW,
{ key: 'density', label: 'Density', kind: 'number', default: 1, min: 0, step: 0.1 },
// Blocks only what comes from that side, turned with the object; the rest passes through (#155).
{ key: 'oneWay', label: 'One-way', kind: 'boolean', default: false },
{
key: 'oneWayDirection',
label: 'Blocks from',
kind: 'enum',
default: 'up',
options: [
{ value: 'up', label: 'Above' },
{ value: 'down', label: 'Below' },
{ value: 'left', label: 'Left' },
{ value: 'right', label: 'Right' },
],
shownWith: 'oneWay',
},
// How far a contact may lean from that side and still land (#159).
ONE_WAY_ANGLE_ROW,
...COLLISION_LAYER_ROWS,
],
rigidbody: [
{
key: 'bodyType',
label: 'Body type',
kind: 'enum',
default: 'dynamic',
options: [
{ value: 'dynamic', label: 'Dynamic' },
{ value: 'static', label: 'Static' },
{ value: 'kinematic', label: 'Kinematic' },
],
},
// 0 leaves the mass to the colliders' density and size; anything else is the mass itself.
{ key: 'mass', label: 'Mass (0 = auto)', kind: 'number', default: 0, min: 0, step: 0.1 },
{ key: 'gravityScale', label: 'Gravity scale', kind: 'number', default: 1, step: 0.1 },
{ key: 'linearDamping', label: 'Linear damping', kind: 'number', default: 0, min: 0, step: 0.1 },
{ key: 'angularDamping', label: 'Angular damping', kind: 'number', default: 0, min: 0, step: 0.1 },
{ key: 'fixedRotation', label: 'Fixed rotation', kind: 'boolean', default: false },
{ key: 'ccd', label: 'Continuous (CCD)', kind: 'boolean', default: false },
],
// What it ties its object to, and how (#154). Lengths are world units, angles degrees counted
// from how the two were placed, stiffness a force per 100 units as the engine weighs things.
joint: [
{
key: JOINT_TYPE_KEY,
label: 'Type',
kind: 'enum',
default: 'hinge',
options: JOINT_TYPES.map((value) => ({ value, label: JOINT_TYPE_LABELS[value] })),
},
// Empty is a point of the scene that never moves.
{ key: JOINT_CONNECTED_KEY, label: 'Connected object', kind: 'object', default: NO_OBJECT },
// Where a spring or a rope ends on the other object, in that object's own units – or, with no
// object, in the scene's. A hinge and a fixed joint have one point only: where the joint sits.
{
key: JOINT_ANCHOR_X_KEY,
label: 'Connected anchor X',
kind: 'number',
default: 0,
step: 1,
...JOINT_ENDS,
},
{
key: JOINT_ANCHOR_Y_KEY,
label: 'Connected anchor Y',
kind: 'number',
default: 0,
step: 1,
...JOINT_ENDS,
},
{
key: JOINT_LENGTH_KEY,
label: 'Length (0 = as placed)',
kind: 'number',
default: 0,
min: 0,
step: 1,
...JOINT_ENDS,
},
{
key: JOINT_STIFFNESS_KEY,
label: 'Stiffness',
kind: 'number',
default: 50,
min: 0,
step: 1,
...ONLY_SPRING,
},
{
key: JOINT_DAMPING_KEY,
label: 'Damping',
kind: 'number',
default: 1,
min: 0,
step: 0.1,
...ONLY_SPRING,
},
{ key: JOINT_LIMIT_KEY, label: 'Limit angle', kind: 'boolean', default: false, ...ONLY_HINGE },
{
key: JOINT_MIN_ANGLE_KEY,
label: 'Min angle',
kind: 'number',
default: -45,
max: 0,
step: 1,
shownWith: JOINT_LIMIT_KEY,
...ONLY_HINGE,
},
{
key: JOINT_MAX_ANGLE_KEY,
label: 'Max angle',
kind: 'number',
default: 45,
min: 0,
step: 1,
shownWith: JOINT_LIMIT_KEY,
...ONLY_HINGE,
},
// A slider glides along the joint's own x axis: turning the joint turns the rail.
{
key: JOINT_LIMIT_DISTANCE_KEY,
label: 'Limit distance',
kind: 'boolean',
default: false,
...ONLY_SLIDER,
},
{
key: JOINT_MIN_DISTANCE_KEY,
label: 'Min distance',
kind: 'number',
default: -100,
max: 0,
step: 1,
shownWith: JOINT_LIMIT_DISTANCE_KEY,
...ONLY_SLIDER,
},
{
key: JOINT_MAX_DISTANCE_KEY,
label: 'Max distance',
kind: 'number',
default: 100,
min: 0,
step: 1,
shownWith: JOINT_LIMIT_DISTANCE_KEY,
...ONLY_SLIDER,
},
// Degrees for a hinge, world units for a slider, both counted from how it was placed.
{
key: JOINT_MOTOR_KEY,
label: 'Motor',
kind: 'enum',
default: 'off',
options: JOINT_MOTORS.map((value) => ({ value, label: JOINT_MOTOR_LABELS[value] })),
...ON_AXIS,
},
{
key: JOINT_MOTOR_SPEED_KEY,
label: 'Motor speed',
kind: 'number',
default: 90,
step: 1,
...motorRows('speed', 'target'),
},
{
key: JOINT_MOTOR_TARGET_KEY,
label: 'Motor target',
kind: 'number',
default: 0,
step: 1,
...motorRows('target'),
},
{
key: JOINT_MAX_FORCE_KEY,
label: 'Max force (0 = no limit)',
kind: 'number',
default: 0,
min: 0,
step: 10,
...motorRows('speed', 'target'),
},
{ key: JOINT_COLLIDE_KEY, label: 'Collide with connected', kind: 'boolean', default: false },
],
// A container is pure structure: its content is the template, not a property.
container: [],
// What its children are cut to (#260). Units, not pixels: it turns and grows with its parent.
clip: [
{ key: 'width', label: 'Width', kind: 'number', default: 200, min: 1, step: 1 },
{ key: 'height', label: 'Height', kind: 'number', default: 150, min: 1, step: 1 },
],
text: [
{ key: 'text', label: 'Text', kind: 'text', default: 'Text' },
// Empty is the editor's own typeface, the one every text was drawn in before fonts existed.
{ key: FONT_KEY, label: 'Font', kind: 'asset', default: '', assetTypes: ['font'] },
{ key: 'fontSize', label: 'Font size', kind: 'number', default: 32, min: 1, step: 1, decimals: 0 },
{ key: 'color', label: 'Color', kind: 'color', default: 0xffffff },
{
key: 'align',
label: 'Align',
kind: 'enum',
default: 'center',
options: [
{ value: 'left', label: 'Left' },
{ value: 'center', label: 'Center' },
{ value: 'right', label: 'Right' },
],
},
// Worn by the rectangle of the rendered text, as by a sprite's picture (#169).
{ key: SHADER_KEY, label: 'Shader', kind: 'asset', default: '', assetTypes: ['shader'] },
],
};
const FULL_SCHEMAS: Record<AssetType, PropertySchema> = Object.fromEntries(
(Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [
type,
isPlaceableType(type)
? [
...COMMON_PROPS,
...(hasTransformProps(type) ? TRANSFORM_PROPS : []),
...sortPropsOf(type),
...renderLayerPropsOf(type),
...ASSET_SCHEMAS[type],
]
: ASSET_SCHEMAS[type],
]),
) as Record<AssetType, PropertySchema>;
export function schemaFor(type: AssetType): PropertySchema {
return FULL_SCHEMAS[type];
}
const SPRITE_KEYS = new Set(FULL_SCHEMAS.sprite.map((d) => d.key));
/**
* A row every sprite has already: the switch, the transform and the sprite's own. A shader
* parameter under one of these names would be a second row on the same bag key, and its uniform
* would read the sprite's value instead of the default the Property node declares (#63).
*/
export function isSpriteProp(key: string): boolean {
return SPRITE_KEYS.has(key);
}
/**
* The type schema, for behaviors followed by their declared variables, for sprites by the
* parameters of the shader asset their bag names – which is why the project is needed.
*/
export function schemaForAsset(project: Project, asset: Asset): PropertySchema {
return schemaForBag(project, asset, asset.props);
}
/** A placed object's rows: an object overriding its shader has that shader's parameters. */
export function schemaForObject(project: Project, asset: Asset, object: SceneObject): PropertySchema {
const bag: PropertyBag = {};
for (const key of [SHADER_KEY, FIELD_SHADER_KEY]) {
const value = object.overrides[key] ?? asset.props[key];
if (value !== undefined) bag[key] = value;
}
return schemaForBag(project, asset, bag);
}
function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): PropertySchema {
if (asset.type === 'behavior') return [...FULL_SCHEMAS.behavior, ...asset.variables];
// Nothing but declarations: the starting values live in `props` under the very same keys.
if (asset.type === 'variables' || asset.type === 'struct') return asset.variables;
if (asset.type === 'sprite') return [...FULL_SCHEMAS.sprite, ...shaderParamRows(project, shaderOf(bag))];
// Unlike a sprite's shader, a source's graph is its own asset's, so no id has to be looked up.
if (asset.type === 'particles')
return sourceRows(
[...FULL_SCHEMAS.particles, ...particleParamDefs(asset.graph)],
shaderParamRows(project, shaderOf(bag)),
shaderParamRows(project, fieldShaderOf(bag)),
);
if (asset.type === 'text' || asset.type === 'camera')
return [
...FULL_SCHEMAS[asset.type],
...rowsNotClaimedBy(shaderParamRows(project, shaderOf(bag)), FULL_SCHEMAS[asset.type]),
];
return FULL_SCHEMAS[asset.type];
}
/**
* A source draws its rows from two graphs, so one name can arrive twice: `speed` on the particle
* graph and `speed` on the shader it wears. The source's own row wins and the uniform keeps its
* declared default – the shader cannot be blamed for the collision, the same one is legitimate on a
* sprite, so nothing is rejected on its side. A text's own rows (`fontSize`, `align` …) win the same way.
*/
export function rowsNotClaimedBy(rows: readonly PropertyDef[], taken: readonly PropertyDef[]): PropertyDef[] {
const claimed = new Set(taken.map((d) => d.key));
return rows.filter((row) => !claimed.has(row.key));
}
/**
* A source's rows: its own, then its particles' shader's, then its field shader's (#170). A name
* both shaders declare is one row, and both uniforms read it.
*/
export function sourceRows(
own: readonly PropertyDef[],
shaderRows: readonly PropertyDef[],
fieldRows: readonly PropertyDef[],
): PropertyDef[] {
const worn = [...own, ...rowsNotClaimedBy(shaderRows, own)];
return [...worn, ...rowsNotClaimedBy(fieldRows, worn)];
}
/** The rows a shader asset adds to the sprites wearing it; a dangling id adds none. */
export function shaderParamRows(project: Project, shaderId: string): PropertyDef[] {
const shader = shaderId ? findAssetOf(project, shaderId, 'shader') : undefined;
return shader ? shaderParamDefs(shader.graph) : [];
}
/** A shader parameter a sprite *may* have, with the shaders that declare it under that name. */
export interface ShaderParamRow {
def: PropertyDef;
shaders: string[];
}
/**
* The parameters of every shader in the project, one row per name – which sprite wears which shader
* is only known at runtime, so the picker offers them all and names where each one comes from.
*/
export function allShaderParamRows(project: Project): ShaderParamRow[] {
const rows = new Map<string, ShaderParamRow>();
for (const asset of project.assets)
if (asset.type === 'shader')
for (const def of shaderParamDefs(asset.graph)) {
const row = rows.get(def.key);
if (!row) rows.set(def.key, { def, shaders: [asset.name] });
else if (!row.shaders.includes(asset.name)) row.shaders.push(asset.name);
}
return [...rows.values()];
}
export function propDef(project: Project, asset: Asset, key: string): PropertyDef | undefined {
return schemaForAsset(project, asset).find((d) => d.key === key);
}
/** A fresh bag of the type's default values (callers may mutate it). */
export function defaultsFor(type: AssetType): PropertyBag {
return defaultsOf(FULL_SCHEMAS[type]);
}
export function defaultsOf(schema: PropertySchema): PropertyBag {
const bag: PropertyBag = {};
for (const def of schema) bag[def.key] = def.default;
return bag;
}
/** The bag a new asset of the type is created with. */
export function initialPropsFor(type: AssetType): PropertyBag {
const bag: PropertyBag = {};
for (const def of FULL_SCHEMAS[type]) bag[def.key] = initialOf(def);
return bag;
}
export function variableDef(
key: string,
kind: VariableKind,
defaultValue?: PropertyValue,
fields?: readonly StructFieldDef[],
): PropertyDef {
switch (kind) {
case 'list':
return { key, label: key, kind, default: (isList(defaultValue) ? defaultValue : []).map(listItemOf) };
case 'struct': {
const declared = fields ?? [];
return { key, label: key, kind, fields: declared, default: structOf(declared, defaultValue) };
}
case 'object':
return { key, label: key, kind, default: NO_OBJECT };
default:
return flatDef(key, kind, { value: defaultValue });
}
}
/** The empty value of a kind, so a field that is not there yet still has one. */
export function emptyFieldValue(field: StructFieldDef): FieldValue {
if (field.kind === 'object') return NO_OBJECT;
if (field.kind === 'struct') return structOf(field.fields, undefined);
return field.kind === 'list' ? [] : emptyOf(field.kind);
}
/**
* A struct over exactly the declared fields: a declared field missing from `from` starts empty
* rather than absent, so a struct value always has the shape its declaration promises.
*/
export function structOf(fields: readonly StructFieldDef[], from: PropertyValue | undefined): StructValue {
const source = isStruct(from) ? from : {};
const value: Record<string, FieldValue> = {};
for (const field of fields)
value[field.key] =
field.key in source ? coerceFieldValue(source[field.key]!, field) : emptyFieldValue(field);
return value;
}
export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue {
switch (def.kind) {
case 'number': {
let n = coerceAs(value, def.kind, def.default);
if (def.min !== undefined) n = Math.max(def.min, n);
if (def.max !== undefined) n = Math.min(def.max, n);
return n;
}
case 'enum': {
const s = textOf(value);
return def.options.some((o) => o.value === s) ? s : def.default;
}
case 'list':
// Each item keeps the kind it arrived as; there is no declaration to force it into.
return isList(value) ? value.map(listItemOf) : def.default;
case 'struct':
return structOf(def.fields, value);
case 'object':
return objectPlace(value);
default:
return coerceAs(value, def.kind, def.default);
}
}
/**
* A struct field as its declaration promises: a list field takes only a list (see `ListFieldDef`),
* a struct field is brought into its declared shape, however deep it sits.
*/
export function coerceFieldValue(value: PropertyValue, field: StructFieldDef): FieldValue {
if (field.kind === 'object') return objectPlace(value);
if (field.kind === 'struct') return structOf(field.fields, value);
return field.kind === 'list' ? listOf(value) : coerceField(value, field.kind);
}