Files
feuerwerk/src/core/properties/PropertySchema.ts
T
adminandClaude Opus 5.5 101b12504e Play a sound backwards with a Reverse switch on the asset (#321)
A sound asset can now play its stretch backwards. The switch sits on
the asset, so every way the sound is heard plays it reversed: Play
sound, a song item, an instrument's Sample and a Pitch in a sound FX.
There is no Reverse node in the sound FX graph. The user dropped it,
because playing backwards needs the whole sound in advance, which a
bus or a Chip does not have.

- The cut gets a `reverse` flag, stored as the asset property
  `reverse` (default off). The cut name ends in `~reverse` when it is
  on, so a reversed sound over the whole file is a cut of its own.
- The handles keep marking the same part of the file. The reversed
  copy is made once, like any cut, and the fades follow what is heard:
  Fade in ramps at the end handle, Fade out at the start handle.
- The Sound tab has a toggle with a left-pointing play triangle. Its
  playhead runs from right to left, and the wave draws the ramps at
  the edges they now belong to. The inspector has a Reverse row.
- The icon is a tab header icon (`HEADER_ICON_SVG`, next to the snap
  magnet), not a top bar icon. The first gate run had it in the top
  bar set, and the test that fixes that set went red.
- A song item draws the waveform of a reversed sound backwards.
  Lengths stay as they are.
- The header label reads "Fade in" instead of "Fade in (ms)" and the
  fade fields are 8 px narrower. With the new button the readout was
  cut to "0.03 s of 0..." at 1280 px, now it shows in full. The
  inspector keeps the unit.

Tests:
- Unit sounds 22, sound-library 85 (1 new each). Counter-checks:
  without turning the samples round, without moving the fade in to
  the end, and without mirroring the peaks, all red.
- e2e 34: ALL PASSED, 72 checks (2 new). The reversed stretch starts
  with the last samples of the forward one, and the playhead ran
  0.75, 0.60, 0.45, 0.30, 0.15 over the whole file.
- Gate: first run red, 1 of 4031 (the top bar icon list). After
  moving the icon, CHECK EXIT 0, 260 files, 4031 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:08:37 +02:00

1308 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 { FADE_CURVES } from '../model/sound';
import {
DEFAULT_FADE_CURVE,
DEFAULT_FADE_MS,
END_KEY as SOUND_END_KEY,
FADE_IN_CURVE_KEY as SOUND_FADE_IN_CURVE_KEY,
FADE_IN_KEY as SOUND_FADE_IN_KEY,
FADE_OUT_CURVE_KEY as SOUND_FADE_OUT_CURVE_KEY,
FADE_OUT_KEY as SOUND_FADE_OUT_KEY,
LOOP_KEY as SOUND_LOOP_KEY,
REVERSE_KEY as SOUND_REVERSE_KEY,
START_KEY as SOUND_START_KEY,
VOLUME_KEY as SOUND_VOLUME_KEY,
} from './sounds';
import {
BEAT_OPTIONS,
DEFAULT_BARS,
DEFAULT_GRID,
GRID_OPTIONS,
MAX_BARS,
PATTERN_BARS_KEY,
PATTERN_BEAT_KEY,
PATTERN_BEATS_KEY,
PATTERN_GRID_KEY,
PATTERN_INSTRUMENT_KEY,
PATTERN_TEMPO_KEY,
} from '../music/pattern';
import {
DEFAULT_SONG_GRID,
SONG_BEAT_KEY,
SONG_BEATS_KEY,
SONG_GRID_KEY,
SONG_GRID_OPTIONS,
SONG_TEMPO_KEY,
} from '../music/song';
import { DEFAULT_TEMPO, MAX_TEMPO, MIN_TEMPO } from '../music/time';
import { Theme } from '../../ui';
/**
* What a kind's definition carries beyond key, label and default. `decimals` belongs only on
* properties that are integral by meaning (counts, sizes in whole pixels), not on geometry: dragging
* produces fractions, and the field rounds the value away rather than hiding it. Without
* `assetTypes` an asset picker offers every placeable asset; `uploadedOnly` keeps drawn pictures out
* of a texture picker (#186). `renderLayers` makes a layer row pick from the project's render layers
* instead of its collision layers (#302), with "Inherit" as the first choice of a single layer.
*/
type Extras<K extends FlatKind> = K extends 'number'
? { min?: number; max?: number; step?: number; decimals?: number }
: K extends 'enum'
? { options: { value: string; label: string }[] }
: K extends 'asset'
? { assetTypes?: readonly AssetType[] }
: K extends 'texture'
? { uploadedOnly?: boolean }
: K extends 'layer' | 'layers'
? { renderLayers?: boolean }
: unknown;
/**
* The definition of a flat kind: its default is a value of that kind, as the table says. Not an
* object's: that default is no value of the kind but its tagged place (`ObjectDef`).
* `shownWith` names a switch on the same bag; the inspector shows the row only while it is on
* (see `isRowShown`), everything else – graph, defaults, files – always has it. `shownFor` does the
* same by a choice: the row is shown while the enum under `key` holds one of `values` (#154); a
* list of such choices must all hold. `shownWhenSet` names a text or asset row the row belongs to:
* shown while that one is not empty (#170).
*/
export type FlatDef<K extends FlatKind = FlatKind> = {
[P in Exclude<K, 'object'>]: {
key: string;
label: string;
kind: P;
default: ValueOf<P>;
/** What a new asset starts with when it differs from `default`, the value a missing key reads as. */
initial?: ValueOf<P>;
shownWith?: string;
shownFor?: ShownFor | readonly ShownFor[];
shownWhenSet?: string;
} & Extras<P>;
}[Exclude<K, 'object'>];
/** The value a new asset starts with, which is also what Reset goes back to. */
export function initialOf(def: PropertyDef): PropertyValue {
return 'initial' in def && def.initial !== undefined ? def.initial : def.default;
}
interface ShownFor {
key: string;
values: readonly string[];
}
/** Whether the inspector shows the row, given the values of the bag it is on. */
export function isRowShown(def: PropertyDef, values: PropertyBag): boolean {
if ('noRow' in def && def.noRow) return false;
const choices: readonly ShownFor[] =
'shownFor' in def && def.shownFor ? ('key' in def.shownFor ? [def.shownFor] : def.shownFor) : [];
if (choices.some(({ key, values: wanted }) => !wanted.includes(textOf(values[key] ?? '')))) return false;
if ('shownWhenSet' in def && def.shownWhenSet && textOf(values[def.shownWhenSet] ?? '') === '')
return false;
return !('shownWith' in def) || def.shownWith === undefined || values[def.shownWith] === true;
}
/** How far a contact normal may lean from a one-way side and still land on it, in degrees (#155, #159). */
export const ONE_WAY_ANGLE_DEFAULT = 45;
export const ONE_WAY_ANGLE_MAX = 90;
const ONE_WAY_ANGLE_ROW: FlatDef<'number'> = {
key: 'oneWayAngle',
label: 'One-way angle',
kind: 'number',
default: ONE_WAY_ANGLE_DEFAULT,
min: 0,
max: ONE_WAY_ANGLE_MAX,
step: 5,
shownWith: 'oneWay',
};
/** The surface a collider or a solid tile (#193) offers, read by the physics engine and by `Move` (#146, #150). */
const FRICTION_ROW: FlatDef<'number'> = {
key: 'friction',
label: 'Friction',
kind: 'number',
default: 0.5,
min: 0,
step: 0.05,
};
const BOUNCINESS_ROW: FlatDef<'number'> = {
key: 'bounciness',
label: 'Bounciness',
kind: 'number',
default: 0,
min: 0,
max: 1,
step: 0.05,
};
/**
* Which colliders may meet (#152). Both rows sit on colliders and on tilemaps, so a map can be
* ground that only some things walk on. The defaults are layer 0 against every layer, which is
* what a project without any named layer does – nothing collides differently than before.
*/
const COLLISION_LAYER_ROWS: readonly FlatDef[] = [
{ key: COLLISION_LAYER_KEY, label: 'Layer', kind: 'layer', default: 0 },
{ key: COLLIDES_WITH_KEY, label: 'Collides with', kind: 'layers', default: ALL_LAYERS },
];
const jointRows = (...values: JointType[]) => ({ shownFor: { key: JOINT_TYPE_KEY, values } });
const JOINT_ENDS = jointRows(...TWO_ANCHOR_TYPES);
const ONLY_SPRING = jointRows('spring');
const ONLY_HINGE = jointRows('hinge');
const ONLY_SLIDER = jointRows('slider');
const ON_AXIS = jointRows(...AXIS_TYPES);
const motorRows = (...values: JointMotor[]) => ({
shownFor: [ON_AXIS.shownFor, { key: JOINT_MOTOR_KEY, values }],
});
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
export type PropertyDef =
| FlatDef
/**
* A list declares nothing about its items: each one carries its own kind (see `listItems.ts`),
* and the graph types a list as a list, so one `list` port fits them all. An `itemType` makes
* every item a struct of that Struct asset (#254), each marked with it (`ITEM_TYPE_KEY`).
* `noRow` keeps it out of the inspector: its items are edited somewhere else (#295).
*/
| { key: string; label: string; kind: 'list'; default: ListValue; itemType?: string; noRow?: boolean }
/**
* `fields` is the declaration; the value carries exactly those keys, which is what lets a struct
* keep its shape while single fields are written (`mergeStruct`). With a `structType` the fields
* are a copy of that Struct asset's (#253), kept in step by `syncStructTypes`.
*/
| {
key: string;
label: string;
kind: 'struct';
default: StructValue;
fields: readonly StructFieldDef[];
structType?: string;
}
/** An object variable holds a live handle during a run, and nothing at rest (`NO_OBJECT`). */
| ObjectDef;
export interface ObjectDef {
key: string;
label: string;
kind: 'object';
default: typeof NO_OBJECT;
}
export { NO_OBJECT };
export interface PrimitiveFieldDef {
key: string;
kind: FieldKind;
}
/**
* A list held by a struct field. Its items declare nothing either, and may nest like any list –
* unless `itemType` names the Struct asset every item is (#254), as on a list variable.
*/
export interface ListFieldDef {
key: string;
kind: 'list';
itemType?: string;
}
/** A struct held by a struct field declares its fields like a struct variable does (#239). */
export interface StructFieldStructDef {
key: string;
kind: 'struct';
fields: readonly StructFieldDef[];
structType?: string;
}
export type StructFieldDef = PrimitiveFieldDef | ListFieldDef | StructFieldStructDef;
export type StructDef = Extract<PropertyDef, { kind: 'struct' }>;
export type PropertySchema = readonly PropertyDef[];
/**
* The definition of a field kind, its default read from `value` (empty when nothing is given). The
* cast is the table's promise that `coerce` yields the kind.
*/
export function flatDef<K extends FieldKind>(
key: string,
kind: K,
{ label = key, value }: { label?: string; value?: PropertyValue } = {},
): FlatDef<K> {
return { key, label, kind, default: coerceField(value, kind) } as FlatDef<K>;
}
export const KEEP_ON_SCENE_LOAD_KEY = 'keepOnSceneLoad';
/**
* Properties every placeable asset has. A disabled object is not drawn, does not collide and its
* behavior does not run – and the same goes for everything parented below it. A kept one travels
* through a `Load scene` with everything below it (#165), which only a top-level object can.
*/
export const COMMON_PROPS: PropertySchema = [
{ key: 'enabled', label: 'Enabled', kind: 'boolean', default: true },
{ key: KEEP_ON_SCENE_LOAD_KEY, label: 'Keep on scene load', kind: 'boolean', default: false },
];
/**
* How a drawn or grouping object is placed inside its parent, on top of its position. They cascade
* like a position does (see `properties/transform.ts`), so a child turns and grows with its parent.
*/
export const TRANSFORM_PROPS: PropertySchema = [
{ key: 'scale', label: 'Scale', kind: 'number', default: 1, min: 0, step: 0.1 },
{ key: 'rotation', label: 'Rotation', kind: 'number', default: 0, step: 1 },
{ key: 'mirrorX', label: 'Mirror X', kind: 'boolean', default: false },
{ key: 'mirrorY', label: 'Mirror Y', kind: 'boolean', default: false },
];
/**
* Cameras and behaviors are placed by the chain but have no look of their own to turn or size. A
* joint turns for a slider's sake, whose rail is its x axis (#154).
*/
const TRANSFORM_TYPES: ReadonlySet<AssetType> = new Set([
'sprite',
'text',
'collider',
'container',
'clip',
'particles',
'tilemap',
'joint',
]);
export function hasTransformProps(type: AssetType): boolean {
return TRANSFORM_TYPES.has(type);
}
const TRANSFORM_KEYS = new Set(TRANSFORM_PROPS.map((d) => d.key));
export function isTransformProp(key: string): boolean {
return TRANSFORM_KEYS.has(key);
}
export const SORT_WITH_PARENT_KEY = 'sortWithParent';
/**
* Whether a child is drawn in its parent's place instead of on its own (#232, see `drawSlots`).
* A project stored before it reads as off, so it draws as it did; a new asset starts with it on.
*/
const SORT_PROPS: PropertySchema = [
{ key: SORT_WITH_PARENT_KEY, label: 'Sort with parent', kind: 'boolean', default: false, initial: true },
];
/**
* A container is not drawn, but with the switch on it passes its content on to its own parent
* (#243). It starts off, so content placed before draws as it did.
*/
const CONTAINER_SORT_PROPS: PropertySchema = [
{ key: SORT_WITH_PARENT_KEY, label: 'Sort with parent', kind: 'boolean', default: false },
];
/** A tilemap has no single place in the draw order, and the other types are not drawn at all. */
const SORT_TYPES: ReadonlySet<AssetType> = new Set(['sprite', 'text', 'particles', 'container']);
function hasSortProps(type: AssetType): boolean {
return SORT_TYPES.has(type);
}
function sortPropsOf(type: AssetType): PropertySchema {
if (!hasSortProps(type)) return [];
return type === 'container' ? CONTAINER_SORT_PROPS : SORT_PROPS;
}
export function isSortProp(key: string): boolean {
return key === SORT_WITH_PARENT_KEY;
}
/**
* Which cameras see a drawn object (#302). Without a choice of its own it is on its parent's layer,
* so a HUD container on `UI` takes everything in it along.
*/
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,
};
/** 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 },
{ key: 'texture', label: 'Texture', kind: 'texture', default: '' },
// The shader's own parameters follow this row – see `schemaForAsset`. Empty is no shader at all.
{ key: SHADER_KEY, label: 'Shader', kind: 'asset', default: '', assetTypes: ['shader'] },
],
// The picture itself is intrinsic data on the asset (see TextureAsset), not a property.
texture: [filterRow(true), 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.
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 },
// The stretch of the file it is (#343), set in its tab; an end of 0 is the end of the file.
{ key: SOUND_START_KEY, label: 'Start (s)', kind: 'number', default: 0, min: 0, step: 0.01, decimals: 3 },
{ key: SOUND_END_KEY, label: 'End (s)', kind: 'number', default: 0, min: 0, step: 0.01, decimals: 3 },
// That stretch backwards (#321), its fade in at the end it starts from.
{ key: SOUND_REVERSE_KEY, label: 'Reverse', kind: 'boolean', default: false },
// The ramps at a start and an end inside the file, each with its shape (#345).
...(
[
[SOUND_FADE_IN_KEY, 'Fade in (ms)', SOUND_FADE_IN_CURVE_KEY, 'Fade in curve'],
[SOUND_FADE_OUT_KEY, 'Fade out (ms)', SOUND_FADE_OUT_CURVE_KEY, 'Fade out curve'],
] as const
).flatMap(([key, label, curveKey, curveLabel]) => [
{
key,
label,
kind: 'number' as const,
default: DEFAULT_FADE_MS,
min: 0,
max: 1000,
step: 1,
decimals: 0,
},
{
key: curveKey,
label: curveLabel,
kind: 'enum' as const,
default: DEFAULT_FADE_CURVE,
options: FADE_CURVES,
},
]),
],
// 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: [],
// 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_BEATS_KEY,
label: 'Beats per bar',
kind: 'number',
default: 4,
min: 1,
max: 32,
step: 1,
decimals: 0,
},
{ key: PATTERN_BEAT_KEY, label: 'Beat', kind: 'enum', default: '4', options: BEAT_OPTIONS },
{
key: PATTERN_TEMPO_KEY,
label: 'Tempo (BPM)',
kind: 'number',
default: DEFAULT_TEMPO,
min: MIN_TEMPO,
max: MAX_TEMPO,
step: 1,
},
],
// 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_BEATS_KEY,
label: 'Beats per bar',
kind: 'number',
default: 4,
min: 1,
max: 32,
step: 1,
decimals: 0,
},
{ key: SONG_BEAT_KEY, label: 'Beat', kind: 'enum', default: '4', options: BEAT_OPTIONS },
{
key: SONG_GRID_KEY,
label: 'Grid',
kind: 'enum',
default: DEFAULT_SONG_GRID,
options: SONG_GRID_OPTIONS,
},
],
// 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);
}