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 '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 = { [P in Exclude]: { key: string; label: string; kind: P; default: ValueOf

; /** What a new asset starts with when it differs from `default`, the value a missing key reads as. */ initial?: ValueOf

; shownWith?: string; shownFor?: ShownFor | readonly ShownFor[]; shownWhenSet?: string; } & Extras

; }[Exclude]; /** 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; 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( key: string, kind: K, { label = key, value }: { label?: string; value?: PropertyValue } = {}, ): FlatDef { return { key, label, kind, default: coerceField(value, kind) } as FlatDef; } 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 = 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 = 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 = 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 = { 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 = 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; 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(); 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 = {}; 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); }