Files
feuerwerk/src/core/properties/PropertySchema.ts
T
adminandClaude Opus 5.5 c9e8a3f6ac
game-maker / gate (push) Successful in 4m16s
game-maker / suite (push) Failing after 16m12s
Let an animation drive a sprite's video picture by picture (#404)
A video used to run on its own clock as soon as a sprite wore it. An
animation could switch the sprite on and off, but never say which
picture shows at which frame. Cutting a video in an animation needs
the opposite: the animation sets the time and the video follows.

- Sprites wearing a video or a clip get two rows, Video clock and
  Video time. They show only while the texture holds a video.
- With Video clock on Driven the video does not play by itself. It
  shows the picture at Video time, counted from the clip's start.
  Past the end it goes round with Loop and holds the last picture
  without.
- Video time is a number like any other, so keys, step, easing and
  curves give stills, slow motion and ramps.
- A driven sprite gets a player of its own, keyed by the wearer's
  name. The same clip can run twice at different times.
- Following has two ways (`videoFollow.ts`). Running forward at 0.25x
  to 4x the video plays at that rate and is nudged. A still, a scrub
  or a step backwards seeks, and only the newest seek counts.
- Sound is heard only near 1x. The animation tab now plays it too, so
  a cut can follow the sound. Scrubbing is silent.
- Scene, game view, the inspector's PREVIEW and the shader preview
  show the driven picture. In a running game Play animation sets it.

Found on the way:

- After a scrub the video in the animation tab kept running. The
  preview redrew only while the playhead moved, so nobody asked for
  the video once it stood. `AnimationEditor.tick` now redraws it every
  frame while it shows a video.
- Every sprite has two more defaults (`videoClock: 'free'`,
  `videoTime: 0`). Eight tests in five files pinned the old list and
  are updated.

Tests:
- Unit: video-clock and video-follow new, 4 files with 42 tests green.
- e2e 66 (melektaus-atx): ALL PASSED, 22 checks, 3 new. 66i: two
  sprites on one clip stand at 0.2 s red and 0.7 s green. 66j: a
  scrub to frames 2, 7, 12 and 3 shows red, green, blue, red, and
  each colour still stands 0.7 s later. 66k: playing runs 10-30 ms
  behind at rate 1.00-1.03 and is not muted.
- Counterchecks: a seek that keeps the rate turns the seek test red.
  Not seeing Driven turns 66i, 66j and 66k red.
- Gate (melektaus-atx): exit 0, 297 files, 4546 tests.
- Not tested in the browser: Play animation in a running game.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:23:07 +02:00

1285 lines
46 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,
STREAM_KEY as SOUND_STREAM_KEY,
VOLUME_KEY as SOUND_VOLUME_KEY,
} from './sounds';
import {
DEFAULT_BARS,
DEFAULT_GRID,
GRID_OPTIONS,
MAX_BARS,
PATTERN_BARS_KEY,
PATTERN_GRID_KEY,
PATTERN_INSTRUMENT_KEY,
PATTERN_SIGN_KEY,
PATTERN_TEMPO_KEY,
} from '../music/pattern';
import {
DEFAULT_SONG_GRID,
SONG_GRID_KEY,
SONG_GRID_OPTIONS,
SONG_SIGN_KEY,
SONG_TEMPO_KEY,
} from '../music/song';
import { DEFAULT_SIGN, DEFAULT_TEMPO, MAX_TEMPO, MIN_TEMPO, SIGN_OPTIONS } from '../music/time';
import { ASSET_FX_KEY } from './assetFx';
import { BUS_FX_KEY, BUS_VOLUME_KEY } from './buses';
import { SPRITE_VIDEO_ROWS, VIDEO_ROWS } from './videos';
import { findVideo } from '../model/video';
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; `none` is what it calls an empty pick,
* `master` offers the master bus as a choice of its own, `noFx` "None" as one where empty means
* "from the asset" (#352); `uploadedOnly` keeps drawn pictures out
* of a texture picker (#186), `moving` lets videos and their clips in (#402). `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[];
none?: string;
master?: boolean;
noFx?: boolean;
}
: K extends 'texture'
? { uploadedOnly?: boolean; moving?: 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). `shownForVideo` names a texture row: shown while it
* holds a video or a clip of one (#404), which only a check that knows the project can tell.
*/
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;
shownForVideo?: 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, project?: Project): boolean {
if ('noRow' in def && def.noRow) return false;
if ('shownForVideo' in def && def.shownForVideo && project)
if (!findVideo(project, textOf(values[def.shownForVideo] ?? ''))) 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.
*/
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',
]);
/** Whether objects of that type are drawn and so sit on a render layer. */
export function hasRenderLayer(type: AssetType): boolean {
return RENDER_LAYER_TYPES.has(type);
}
function renderLayerPropsOf(type: AssetType): PropertySchema {
return hasRenderLayer(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' },
],
};
}
/** How big the borders of a sliced picture are drawn: 2 makes a 16 px border 32 units wide (#96). */
const SLICE_SCALE_ROW: PropertyDef = {
key: SLICE_SCALE_KEY,
label: 'Nine-slice scale',
kind: 'number',
default: 1,
min: 0.01,
step: 0.1,
};
/** The sound FX a sound, an instrument, a pattern or a song goes through, before its bus (#352). */
const FX_ROW: PropertyDef = {
key: ASSET_FX_KEY,
label: 'Sound FX',
kind: 'asset',
default: '',
assetTypes: ['soundfx'],
};
/** 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 },
// Draws on the game view's pixel grid at a fixed scale; fit and the scene view stay exact.
{ key: 'pixelPerfect', label: 'Pixel perfect', 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 },
// A video or a clip of one plays on the sprite (#402).
{ key: 'texture', label: 'Texture', kind: 'texture', default: '', moving: true },
...SPRITE_VIDEO_ROWS,
// 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), SLICE_SCALE_ROW],
// 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),
SLICE_SCALE_ROW,
],
// 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),
SLICE_SCALE_ROW,
],
// 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); volume and loop say how it is played by default, and
// a `Play sound` node scales the one and can add to the other.
// The sound is the whole file; a clip of it is a stretch with a cut of its own (#357, `CLIP_ROWS`).
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 },
// A long one is streamed from a copy as Opus rather than decoded whole (#349).
{ key: SOUND_STREAM_KEY, label: 'Stream', kind: 'boolean', default: false },
FX_ROW,
],
// The effects are the asset's graph (see SoundFxAsset), not a property.
soundfx: [],
// What a note sounds like is the asset's graph (see InstrumentAsset), not a property.
instrument: [FX_ROW],
// The notes and the loop are intrinsic data on the asset (see PatternAsset). The meter and the
// tempo are for drawing and hearing it on its own; a song plays it at its own tempo (#334).
pattern: [
{
key: PATTERN_INSTRUMENT_KEY,
label: 'Instrument',
kind: 'asset',
default: '',
assetTypes: ['instrument'],
},
{
key: PATTERN_BARS_KEY,
label: 'Length (bars)',
kind: 'number',
default: DEFAULT_BARS,
min: 1,
max: MAX_BARS,
step: 1,
decimals: 0,
},
{ key: PATTERN_GRID_KEY, label: 'Grid', kind: 'enum', default: DEFAULT_GRID, options: GRID_OPTIONS },
{ key: PATTERN_SIGN_KEY, label: 'Sign', kind: 'enum', default: DEFAULT_SIGN, options: SIGN_OPTIONS },
{
key: PATTERN_TEMPO_KEY,
label: 'Tempo (BPM)',
kind: 'number',
default: DEFAULT_TEMPO,
min: MIN_TEMPO,
max: MAX_TEMPO,
step: 1,
},
FX_ROW,
],
// The tracks, items and loop are intrinsic data on the asset (see SongAsset).
song: [
{
key: SONG_TEMPO_KEY,
label: 'Tempo (BPM)',
kind: 'number',
default: DEFAULT_TEMPO,
min: MIN_TEMPO,
max: MAX_TEMPO,
step: 1,
},
{ key: SONG_SIGN_KEY, label: 'Sign', kind: 'enum', default: DEFAULT_SIGN, options: SIGN_OPTIONS },
{
key: SONG_GRID_KEY,
label: 'Grid',
kind: 'enum',
default: DEFAULT_SONG_GRID,
options: SONG_GRID_OPTIONS,
},
FX_ROW,
],
// Where sounds are mixed (#325): how loud and through which sound FX. Where it goes on is the
// master routing's (#351).
video: [...VIDEO_ROWS],
bus: [
{ key: BUS_VOLUME_KEY, label: 'Volume', kind: 'number', default: 1, min: 0, max: 1, step: 0.05 },
{ key: BUS_FX_KEY, label: 'Sound FX', kind: 'asset', default: '', assetTypes: ['soundfx'] },
],
// 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);
}