Files
feuerwerk/src/core/properties/PropertySchema.ts
T
adminandClaude Opus 5.5 f84defb9b6
game-maker / gate (push) Successful in 3m32s
game-maker / suite (push) Failing after 10m2s
Pick an object of the same scene for an Object variable (#258)
An Object variable could only be set by a graph while the game runs.
A placed object always showed "(null)", although the other objects of
its scene are there to choose from and the runtime can already find
any object by its id. Now the placed object stores a reference to one
of them, and the running game turns it into the real object.

- Stored as {"$object": "<id>"}. The empty placeholder stays as it
  was, so old projects load unchanged and empty still means "(null)".
- The row of an Object variable on a placed object is a dropdown with
  None and every object of the scene, indented like the Hierarchy.
  The same goes for Object fields in structs and Object items in
  lists, also in the Variables and List tabs.
- The behavior asset keeps "(null)". An asset has no scene, so "Apply
  to asset" is gone from Object rows.
- The runtime resolves the reference when the Variables or Properties
  node reads it. A destroyed target reads as null, a missing one shows
  "(missing)" in the row.
- Inside a container only objects of the same container are offered.
  The reference keeps the template id, so every placement finds its
  own copy.
- Deleting the target clears the reference in the same undo step.
  Paste points references inside the pasted group at the copies and
  leaves outside references alone. Create container and Unpack carry
  references along with the new ids.
- A value a graph writes while running stays in the run. The scene
  keeps the picked object, also in "Run in editor".

Found on the way:

- Setting an override coerced every object slot to empty, in plain
  values, struct fields and list items. The shared objectPlace() now
  keeps the reference.

Not done:

- No eyedropper to pick in the scene, the dropdown is enough for now.
- Deleting a template object in the container editor does not clean up
  references in other scenes. They read as null there.

Tests:

- ./check.sh: 187 files, 2724 tests, CHECK EXIT 0
- e2e 1-9, 51, 57: ALL PASSED, 389 checks
- e2e 1, 57 on the final bundle: ALL PASSED, 17 checks
- 13 unit tests in object-references.test.ts, 5 checks in e2e 57
- Counter-checks: 8 unit breaks fail their tests, the browser break
  without a pick list fails 57b-57d

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:35:17 +02:00

851 lines
32 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 {
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,
} from '../model/tile';
import { TILEMAP_COLUMNS_KEY, TILEMAP_MAX_CELLS, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap';
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,
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 { 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).
*/
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 }
: 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.
*/
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;
} & 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;
}
/** Whether the inspector shows the row, given the values of the bag it is on. */
export function isRowShown(def: PropertyDef, values: PropertyBag): boolean {
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 },
];
/** 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`).
*/
| { key: string; label: string; kind: 'list'; default: ListValue; itemType?: string }
/**
* `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>;
}
/**
* 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.
*/
export const COMMON_PROPS: PropertySchema = [
{ key: 'enabled', label: 'Enabled', kind: 'boolean', default: true },
];
/**
* 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. */
const TRANSFORM_TYPES: ReadonlySet<AssetType> = new Set([
'sprite',
'text',
'collider',
'container',
'particles',
'tilemap',
]);
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.
*/
export 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']);
export 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;
}
const COMMON_KEYS = new Set([...COMMON_PROPS, ...TRANSFORM_PROPS, ...SORT_PROPS].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);
}
/** 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 },
],
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: [
{
key: 'filter',
label: 'Filter',
kind: 'enum',
default: 'linear',
options: [
{ value: 'linear', label: 'Linear' },
{ value: 'nearest', label: 'Nearest' },
],
},
// 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,
},
{
key: 'filter',
label: 'Filter',
kind: 'enum',
default: 'linear',
options: [
{ value: 'linear', label: 'Linear' },
{ value: 'nearest', label: 'Nearest' },
],
},
],
// 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'] },
{
key: 'filter',
label: 'Filter',
kind: 'enum',
default: 'nearest',
options: [
{ value: 'linear', label: 'Linear' },
{ value: 'nearest', label: 'Nearest' },
],
},
],
// 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 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: [],
// The typeface itself is intrinsic data on the asset (see FontAsset), not a property.
font: [],
// 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'] },
],
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).
{
key: 'points',
label: 'Points',
kind: 'list',
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 },
],
// A container is pure structure: its content is the template, not a property.
container: [],
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' },
],
},
],
};
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),
...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 shader = object.overrides[SHADER_KEY];
return schemaForBag(project, asset, shader === undefined ? asset.props : { [SHADER_KEY]: shader });
}
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') {
const own = [...FULL_SCHEMAS.particles, ...particleParamDefs(asset.graph)];
return [...own, ...rowsNotClaimedBy(shaderParamRows(project, shaderOf(bag)), own)];
}
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.
*/
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));
}
/** 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);
}