Files
feuerwerk/src/core/properties/PropertySchema.ts
T
adminandClaude Opus 5.5 393b8ec7f9
game-maker / gate (push) Successful in 3m44s
game-maker / suite (push) Successful in 10m25s
Build your own easing curve and use it on keys and Lerp (#247)
The built-in eases cover the usual shapes, but a game often wants its
own: a coin that bounces twice, a door that overshoots and settles.
A new asset type holds such a curve, and keys and the Lerp node can
pick it instead of a built-in ease.

- New asset type "curve": points like in Vector art, x is t and y is
  the value. A new curve is the straight line from (0, 0) to (1, 1).
- One pure evaluation in src/core/model/curve.ts serves the timeline
  preview, the running game and the Lerp node.
- The curve always has one value per t. The end points stay at t 0
  and 1, points cannot pass each other, and a handle that reaches past
  its neighbour is shortened along its direction. Loading a hand-edited
  file applies the same rules.
- Curve tab: click on the line to insert a point that keeps the shape,
  click on free space to put one there, drag points and handles,
  double-click or Delete removes, Alt-click toggles round and sharp.
  Every step is one undo step.
- Presets: all 30 built-in eases plus Linear, fitted with adaptive
  subdivision. Each stays within 0.4 % of the built-in ease.
- Preview: a dot runs along the curve once a second, a second dot
  shows only the value. The Preview button stops it.
- Animation keys get "Curve" under Easing with a curve picker. Lerp
  gets "Curve" under Ease, and its result may overshoot b.
- A missing or unpicked curve runs linear. The inspector says so, the
  node shows it as a problem, and deleting a used curve warns with the
  number of keys and Lerp nodes.

Tests:
- ./check.sh: 180 files, 2612 tests, CHECK EXIT 0
- GM_STEPS=1-6,25,53: ALL PASSED, 309 checks
- New e2e module 53 with 13 checks, all with the real mouse. Expected
  values come from its own Bezier evaluation.
- Counter-checks bite: without Bezier evaluation 4 unit tests fail,
  without handle clamping 2. In the browser, a key that cannot find its
  curve, a click that sets no point and a handle drag that moves the
  point all turn checks red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:58:05 +02:00

841 lines
31 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,
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.
*/
| { key: string; label: string; kind: 'list'; default: ListValue }
/**
* `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`).
*/
| {
key: string;
label: string;
kind: 'struct';
default: StructValue;
fields: readonly StructFieldDef[];
}
/** 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. */
export interface ListFieldDef {
key: string;
kind: 'list';
}
/** 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[];
}
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: [],
// 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') 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 NO_OBJECT;
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 NO_OBJECT;
if (field.kind === 'struct') return structOf(field.fields, value);
return field.kind === 'list' ? listOf(value) : coerceField(value, field.kind);
}