import type { Asset } from '../model/Asset'; import { type AssetType, isPlaceableType } from '../model/AssetTypes'; import { type FieldValue, isList, isStruct, type ListValue, type PropertyBag, type PropertyValue, NO_OBJECT, objectPlace, type StructValue, textOf, } from '../model/Properties'; import { coerceAs, coerceField, emptyOf, type FieldKind, type FlatKind, type ValueOf, type VariableKind, } from './kinds'; import { findAssetOf, type Project } from '../model/Project'; import { ALL_LAYERS, COLLIDES_WITH_KEY, COLLISION_LAYER_KEY } from '../model/collisionLayers'; import { TILE_BOUNCINESS_KEY, TILE_COLLISION_SCALE_KEY, TILE_COLLISION_SCALE_MAX, TILE_COLLISION_SCALE_MIN, TILE_FPS_KEY, TILE_FRICTION_KEY, TILE_KIND_KEY, TILE_ONE_WAY_ANGLE_KEY, TILE_ONE_WAY_KEY, TILE_PLAYBACK_KEY, TILE_SORT_OFFSET_KEY, TILE_SORT_OFFSET_LIMIT, TILE_SOLID_KEY, } from '../model/tile'; import { TILEMAP_COLUMNS_KEY, TILEMAP_MAX_CELLS, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap'; import { PALETTE_KEY } from '../model/pixelart'; import { SLICE_SCALE_KEY } from '../model/texture'; import { VECTOR_CANVAS_DEFAULT, VECTOR_CANVAS_MAX, VECTOR_HEIGHT_KEY, VECTOR_WIDTH_KEY, } from '../model/vectorart'; import type { SceneObject } from '../model/SceneObject'; import { listItemOf, listOf } from './listItems'; import { FONT_KEY } from './fonts'; import { BURST_COUNT_KEY, EMITTING_KEY, LIFETIME_KEY, MAX_PARTICLES_KEY, MODE_KEY, MODE_OPTIONS, MOVE_WITH_SOURCE_KEY, PARTICLE_COLOR_KEY, PARTICLE_SIZE_KEY, PARTICLE_TEXTURE_KEY, particleParamDefs, PREWARM_KEY, RATE_KEY, TIME_SCALE_KEY, } from './particles'; import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders'; import { LOOP_KEY as SOUND_LOOP_KEY, VOLUME_KEY as SOUND_VOLUME_KEY } from './sounds'; import { Theme } from '../../ui'; /** * What a kind's definition carries beyond key, label and default. `decimals` belongs only on * properties that are integral by meaning (counts, sizes in whole pixels), not on geometry: dragging * produces fractions, and the field rounds the value away rather than hiding it. Without * `assetTypes` an asset picker offers every placeable asset; `uploadedOnly` keeps drawn pictures out * of a texture picker (#186). */ type Extras = K extends 'number' ? { min?: number; max?: number; step?: number; decimals?: number } : K extends 'enum' ? { options: { value: string; label: string }[] } : K extends 'asset' ? { assetTypes?: readonly AssetType[] } : K extends 'texture' ? { uploadedOnly?: boolean } : unknown; /** * The definition of a flat kind: its default is a value of that kind, as the table says. Not an * object's: that default is no value of the kind but its tagged place (`ObjectDef`). * `shownWith` names a switch on the same bag; the inspector shows the row only while it is on * (see `isRowShown`), everything else – graph, defaults, files – always has it. */ export type FlatDef = { [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; } & 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; } /** Whether the inspector shows the row, given the values of the bag it is on. */ export function isRowShown(def: PropertyDef, values: PropertyBag): boolean { return !('shownWith' in def) || def.shownWith === undefined || values[def.shownWith] === true; } /** How far a contact normal may lean from a one-way side and still land on it, in degrees (#155, #159). */ export const ONE_WAY_ANGLE_DEFAULT = 45; export const ONE_WAY_ANGLE_MAX = 90; const ONE_WAY_ANGLE_ROW: FlatDef<'number'> = { key: 'oneWayAngle', label: 'One-way angle', kind: 'number', default: ONE_WAY_ANGLE_DEFAULT, min: 0, max: ONE_WAY_ANGLE_MAX, step: 5, shownWith: 'oneWay', }; /** The surface a collider or a solid tile (#193) offers, read by the physics engine and by `Move` (#146, #150). */ const FRICTION_ROW: FlatDef<'number'> = { key: 'friction', label: 'Friction', kind: 'number', default: 0.5, min: 0, step: 0.05, }; const BOUNCINESS_ROW: FlatDef<'number'> = { key: 'bounciness', label: 'Bounciness', kind: 'number', default: 0, min: 0, max: 1, step: 0.05, }; /** * Which colliders may meet (#152). Both rows sit on colliders and on tilemaps, so a map can be * ground that only some things walk on. The defaults are layer 0 against every layer, which is * what a project without any named layer does – nothing collides differently than before. */ const COLLISION_LAYER_ROWS: readonly FlatDef[] = [ { key: COLLISION_LAYER_KEY, label: 'Layer', kind: 'layer', default: 0 }, { key: COLLIDES_WITH_KEY, label: 'Collides with', kind: 'layers', default: ALL_LAYERS }, ]; /** The inspector is generated from these entries, so a new property is one line in a schema below. */ export type PropertyDef = | FlatDef /** * A list declares nothing about its items: each one carries its own kind (see `listItems.ts`), * and the graph types a list as a list, so one `list` port fits them all. An `itemType` makes * every item a struct of that Struct asset (#254), each marked with it (`ITEM_TYPE_KEY`). */ | { key: string; label: string; kind: 'list'; default: ListValue; itemType?: string } /** * `fields` is the declaration; the value carries exactly those keys, which is what lets a struct * keep its shape while single fields are written (`mergeStruct`). With a `structType` the fields * are a copy of that Struct asset's (#253), kept in step by `syncStructTypes`. */ | { key: string; label: string; kind: 'struct'; default: StructValue; fields: readonly StructFieldDef[]; structType?: string; } /** An object variable holds a live handle during a run, and nothing at rest (`NO_OBJECT`). */ | ObjectDef; export interface ObjectDef { key: string; label: string; kind: 'object'; default: typeof NO_OBJECT; } export { NO_OBJECT }; export interface PrimitiveFieldDef { key: string; kind: FieldKind; } /** * A list held by a struct field. Its items declare nothing either, and may nest like any list – * unless `itemType` names the Struct asset every item is (#254), as on a list variable. */ export interface ListFieldDef { key: string; kind: 'list'; itemType?: string; } /** A struct held by a struct field declares its fields like a struct variable does (#239). */ export interface StructFieldStructDef { key: string; kind: 'struct'; fields: readonly StructFieldDef[]; structType?: string; } export type StructFieldDef = PrimitiveFieldDef | ListFieldDef | StructFieldStructDef; export type StructDef = Extract; 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; } /** * Properties every placeable asset has. A disabled object is not drawn, does not collide and its * behavior does not run – and the same goes for everything parented below it. */ export const COMMON_PROPS: PropertySchema = [ { key: 'enabled', label: 'Enabled', kind: 'boolean', default: true }, ]; /** * How a drawn or grouping object is placed inside its parent, on top of its position. They cascade * like a position does (see `properties/transform.ts`), so a child turns and grows with its parent. */ export const TRANSFORM_PROPS: PropertySchema = [ { key: 'scale', label: 'Scale', kind: 'number', default: 1, min: 0, step: 0.1 }, { key: 'rotation', label: 'Rotation', kind: 'number', default: 0, step: 1 }, { key: 'mirrorX', label: 'Mirror X', kind: 'boolean', default: false }, { key: 'mirrorY', label: 'Mirror Y', kind: 'boolean', default: false }, ]; /** Cameras and behaviors are placed by the chain but have no look of their own to turn or size. */ const TRANSFORM_TYPES: ReadonlySet = new Set([ 'sprite', 'text', 'collider', 'container', 'particles', 'tilemap', ]); export function hasTransformProps(type: AssetType): boolean { return TRANSFORM_TYPES.has(type); } const TRANSFORM_KEYS = new Set(TRANSFORM_PROPS.map((d) => d.key)); export function isTransformProp(key: string): boolean { return TRANSFORM_KEYS.has(key); } export const SORT_WITH_PARENT_KEY = 'sortWithParent'; /** * Whether a child is drawn in its parent's place instead of on its own (#232, see `drawSlots`). * A project stored before it reads as off, so it draws as it did; a new asset starts with it on. */ export const SORT_PROPS: PropertySchema = [ { key: SORT_WITH_PARENT_KEY, label: 'Sort with parent', kind: 'boolean', default: false, initial: true }, ]; /** * A container is not drawn, but with the switch on it passes its content on to its own parent * (#243). It starts off, so content placed before draws as it did. */ const CONTAINER_SORT_PROPS: PropertySchema = [ { key: SORT_WITH_PARENT_KEY, label: 'Sort with parent', kind: 'boolean', default: false }, ]; /** A tilemap has no single place in the draw order, and the other types are not drawn at all. */ const SORT_TYPES: ReadonlySet = new Set(['sprite', 'text', 'particles', 'container']); export function hasSortProps(type: AssetType): boolean { return SORT_TYPES.has(type); } function sortPropsOf(type: AssetType): PropertySchema { if (!hasSortProps(type)) return []; return type === 'container' ? CONTAINER_SORT_PROPS : SORT_PROPS; } export function isSortProp(key: string): boolean { return key === SORT_WITH_PARENT_KEY; } const COMMON_KEYS = new Set([...COMMON_PROPS, ...TRANSFORM_PROPS, ...SORT_PROPS].map((d) => d.key)); /** The switches, the transform and sort rows: reserved names, shown apart from the type's own properties. */ export function isCommonProp(key: string): boolean { return COMMON_KEYS.has(key); } /** A field lives inside its struct, so the reserved property keys do not apply to it. */ export function isValidFieldName(name: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name); } /** Identifier-like, and not a common property key – a variable "enabled" would shadow the switch. */ export function isValidVariableName(name: string): boolean { return isValidFieldName(name) && !isCommonProp(name); } /** Position is deliberately absent: it belongs to scene objects, not to asset types. */ const ASSET_SCHEMAS: Record = { scene: [ { key: 'backgroundColor', label: 'Background', kind: 'color', default: Theme.colors.graphBackground }, // Pulls on every dynamic rigidbody, in world units per second squared: 980 is Earth at 100 px = 1 m. { key: 'gravityX', label: 'Gravity X', kind: 'number', default: 0, step: 10 }, { key: 'gravityY', label: 'Gravity Y', kind: 'number', default: 980, step: 10 }, ], camera: [ // A view is a pixel raster, so half a pixel of it means nothing. { key: 'width', label: 'View width', kind: 'number', default: 640, min: 16, step: 1, decimals: 0 }, { key: 'height', label: 'View height', kind: 'number', default: 360, min: 16, step: 1, decimals: 0 }, { key: 'zoom', label: 'Zoom', kind: 'number', default: 1, min: 0.1, max: 10, step: 0.1 }, ], sprite: [ { key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 }, { key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 }, // The fill while there is no picture, and a tint multiplier once there is one: white = no tint. { key: 'color', label: 'Color', kind: 'color', default: 0xffffff }, { key: 'texture', label: 'Texture', kind: 'texture', default: '' }, // The shader's own parameters follow this row – see `schemaForAsset`. Empty is no shader at all. { key: SHADER_KEY, label: 'Shader', kind: 'asset', default: '', assetTypes: ['shader'] }, ], // The picture itself is intrinsic data on the asset (see TextureAsset), not a property. texture: [ { key: 'filter', label: 'Filter', kind: 'enum', default: 'linear', options: [ { value: 'linear', label: 'Linear' }, { value: 'nearest', label: 'Nearest' }, ], }, // How big the borders of every sliced frame are drawn: 2 makes a 16 px border 32 units wide (#96). { key: SLICE_SCALE_KEY, label: 'Nine-slice scale', kind: 'number', default: 1, min: 0.01, step: 0.1 }, ], // The shapes are intrinsic data on the asset (see VectorArtAsset); these three say how big the // picture they are drawn onto is and how it is sampled – the same `filter` row a texture has, // because a generated picture goes through the same path (#185). vectorart: [ { key: VECTOR_WIDTH_KEY, label: 'Canvas width', kind: 'number', default: VECTOR_CANVAS_DEFAULT, min: 1, max: VECTOR_CANVAS_MAX, step: 1, decimals: 0, }, { key: VECTOR_HEIGHT_KEY, label: 'Canvas height', kind: 'number', default: VECTOR_CANVAS_DEFAULT, min: 1, max: VECTOR_CANVAS_MAX, step: 1, decimals: 0, }, { key: 'filter', label: 'Filter', kind: 'enum', default: 'linear', options: [ { value: 'linear', label: 'Linear' }, { value: 'nearest', label: 'Nearest' }, ], }, ], // Layers, frames and the canvas size are intrinsic data on the asset (see PixelArtAsset). A palette // is shared, so the asset names one; the filter is the texture's row, sharp by default for pixels. pixelart: [ { key: PALETTE_KEY, label: 'Palette', kind: 'asset', default: '', assetTypes: ['palette'] }, { key: 'filter', label: 'Filter', kind: 'enum', default: 'nearest', options: [ { value: 'linear', label: 'Linear' }, { value: 'nearest', label: 'Nearest' }, ], }, ], // The colours are intrinsic data on the asset (see PaletteAsset). palette: [], // The points are intrinsic data on the asset (see CurveAsset). curve: [], // The root and the rows are intrinsic data on the asset (see AnimationAsset). animation: [ { key: 'fps', label: 'Frames per second', kind: 'number', default: 10, min: 0.1, max: 120, step: 1 }, { key: 'playback', label: 'Playback', kind: 'enum', default: 'loop', options: [ { value: 'loop', label: 'Loop' }, { value: 'once', label: 'Once (hold)' }, { value: 'once-reset', label: 'Once (reset)' }, { value: 'pingpong', label: 'Ping-pong' }, ], }, ], // The pictures are intrinsic data on the asset (see TileAsset); these three say what the // block holds and how its steps run. There is no tile size: the block's width in tiles is known // from the kind, so the picture already says it (see `readTile`). A new tile is a plain one; the // autotile blocks are chosen on purpose (#131). tile: [ { key: TILE_KIND_KEY, label: 'Kind', kind: 'enum', default: 'none', options: [ { value: 'none', label: 'None (1 × 1)' }, { value: 'floor', label: 'Floor (2 × 3)' }, { value: 'wall', label: 'Wall (2 × 2)' }, { value: 'terrain', label: 'Terrain (6 × 4)' }, ], }, { key: TILE_FPS_KEY, label: 'Frames per second', kind: 'number', default: 4, min: 0.1, max: 120 }, { key: TILE_PLAYBACK_KEY, label: 'Playback', kind: 'enum', default: 'loop', options: [ { value: 'loop', label: 'Loop' }, { value: 'pingpong', label: 'Ping-pong' }, ], }, // Solid is what the tile *is* – water, a wall – so it holds wherever the tile lies (#132). { key: TILE_SOLID_KEY, label: 'Solid', kind: 'boolean', default: false }, // A solid tile that only blocks from above, like a platform one jumps through (#155). { key: TILE_ONE_WAY_KEY, label: 'One-way (from above)', kind: 'boolean', default: false }, { ...ONE_WAY_ANGLE_ROW, key: TILE_ONE_WAY_ANGLE_KEY, shownWith: TILE_ONE_WAY_KEY }, // No density: a map never moves, so what its cells would weigh counts for nothing. { ...FRICTION_ROW, key: TILE_FRICTION_KEY, shownWith: TILE_SOLID_KEY }, { ...BOUNCINESS_ROW, key: TILE_BOUNCINESS_KEY, shownWith: TILE_SOLID_KEY }, // Below 1 a cell blocks less than its square, above 1 more – but only on the sides without a // blocking neighbour, so a wall of such cells stays closed (#140). { key: TILE_COLLISION_SCALE_KEY, label: 'Collision scale', kind: 'number', default: 1, min: TILE_COLLISION_SCALE_MIN, max: TILE_COLLISION_SCALE_MAX, step: 0.05, }, { key: TILE_SORT_OFFSET_KEY, label: 'Sort offset', kind: 'number', default: 0, min: -TILE_SORT_OFFSET_LIMIT, max: TILE_SORT_OFFSET_LIMIT, step: 1, decimals: 0, }, ], // The layers and their cells are intrinsic data on the asset (see TilemapAsset); these three say // how far the grid reaches and how big one cell is drawn. A tile's own pictures are scaled onto // that cell, so one tilemap can mix tiles drawn at different resolutions. tilemap: [ { key: TILEMAP_COLUMNS_KEY, label: 'Columns', kind: 'number', default: 20, min: 1, max: TILEMAP_MAX_CELLS, step: 1, }, { key: TILEMAP_ROWS_KEY, label: 'Rows', kind: 'number', default: 15, min: 1, max: TILEMAP_MAX_CELLS, step: 1, }, { key: TILEMAP_TILE_KEY, label: 'Tile size', kind: 'number', default: 48, min: 1, step: 1 }, ...COLLISION_LAYER_ROWS, ], // Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`. behavior: [ { key: 'runInEditor', label: 'Run in editor', kind: 'boolean', default: false }, { key: 'runWhilePaused', label: 'Run while paused', kind: 'boolean', default: false }, ], // Every row is declared on the asset itself – see `schemaForBag`. variables: [], struct: [], // The states are the asset's graph (see StateMachineAsset), not a property. statemachine: [], // The nodes are the asset's graph (see ShaderAsset); its parameters are rows of the sprites wearing it. shader: [], // The typeface itself is intrinsic data on the asset (see FontAsset), not a property. font: [], // The file is intrinsic data (see SoundAsset); these two say how it is played by default, and a // `Play sound` node scales the one and can add to the other. sound: [ { key: SOUND_VOLUME_KEY, label: 'Volume', kind: 'number', default: 1, min: 0, max: 1, step: 0.05 }, { key: SOUND_LOOP_KEY, label: 'Loop', kind: 'boolean', default: false }, ], // How many particles and for how long; what one of them does is the asset's graph, whose Property // nodes add their rows after these – see `schemaForBag`. particles: [ { key: EMITTING_KEY, label: 'Emitting', kind: 'boolean', default: true }, { key: MODE_KEY, label: 'Mode', kind: 'enum', default: 'continuous', options: MODE_OPTIONS }, { key: RATE_KEY, label: 'Rate', kind: 'number', default: 20, min: 0, step: 1 }, { key: BURST_COUNT_KEY, label: 'Burst count', kind: 'number', default: 20, min: 0, step: 1, decimals: 0 }, { key: LIFETIME_KEY, label: 'Lifetime', kind: 'number', default: 1, min: 0.01, step: 0.1 }, { key: TIME_SCALE_KEY, label: 'Time scale', kind: 'number', default: 1, min: 0, step: 0.1 }, { key: PREWARM_KEY, label: 'Prewarm', kind: 'boolean', default: false }, { key: MAX_PARTICLES_KEY, label: 'Max particles', kind: 'number', default: 500, min: 1, step: 50, decimals: 0, }, { key: PARTICLE_SIZE_KEY, label: 'Size', kind: 'number', default: 16, min: 0.1, step: 1 }, { key: PARTICLE_TEXTURE_KEY, label: 'Texture', kind: 'texture', default: '' }, { key: PARTICLE_COLOR_KEY, label: 'Color', kind: 'color', default: 0xffffff }, { key: MOVE_WITH_SOURCE_KEY, label: 'Move with source', kind: 'boolean', default: false }, // Worn by every particle of the source, not by the field as a whole – see `schemaForBag`. { key: SHADER_KEY, label: 'Shader', kind: 'asset', default: '', assetTypes: ['shader'] }, ], collider: [ { key: 'shape', label: 'Shape', kind: 'enum', default: 'box', options: [ { value: 'box', label: 'Box' }, { value: 'circle', label: 'Circle' }, { value: 'capsule', label: 'Capsule' }, { value: 'polygon', label: 'Polygon' }, { value: 'frame', label: 'Frame collision' }, ], }, // A box and a capsule fill width × height; the capsule is rounded along the longer side. { key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 }, { key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 }, { key: 'radius', label: 'Radius', kind: 'number', default: 50, min: 1, step: 1 }, // A polygon's corners in the collider's own units, edited on the outline in the scene (#151). { key: 'points', label: 'Points', kind: 'list', default: [ { x: 0, y: -50 }, { x: 50, y: 50 }, { x: -50, y: 50 }, ], }, // The frame whose collision outlines (#141) the "Frame collision" shape wears, kept linked (#193). { key: 'frame', label: 'Frame', kind: 'texture', default: '' }, { key: 'isTrigger', label: 'Trigger (no blocking)', kind: 'boolean', default: false }, // The material, read by the physics engine where a rigidbody takes part (#146). FRICTION_ROW, BOUNCINESS_ROW, { key: 'density', label: 'Density', kind: 'number', default: 1, min: 0, step: 0.1 }, // Blocks only what comes from that side, turned with the object; the rest passes through (#155). { key: 'oneWay', label: 'One-way', kind: 'boolean', default: false }, { key: 'oneWayDirection', label: 'Blocks from', kind: 'enum', default: 'up', options: [ { value: 'up', label: 'Above' }, { value: 'down', label: 'Below' }, { value: 'left', label: 'Left' }, { value: 'right', label: 'Right' }, ], shownWith: 'oneWay', }, // How far a contact may lean from that side and still land (#159). ONE_WAY_ANGLE_ROW, ...COLLISION_LAYER_ROWS, ], rigidbody: [ { key: 'bodyType', label: 'Body type', kind: 'enum', default: 'dynamic', options: [ { value: 'dynamic', label: 'Dynamic' }, { value: 'static', label: 'Static' }, { value: 'kinematic', label: 'Kinematic' }, ], }, // 0 leaves the mass to the colliders' density and size; anything else is the mass itself. { key: 'mass', label: 'Mass (0 = auto)', kind: 'number', default: 0, min: 0, step: 0.1 }, { key: 'gravityScale', label: 'Gravity scale', kind: 'number', default: 1, step: 0.1 }, { key: 'linearDamping', label: 'Linear damping', kind: 'number', default: 0, min: 0, step: 0.1 }, { key: 'angularDamping', label: 'Angular damping', kind: 'number', default: 0, min: 0, step: 0.1 }, { key: 'fixedRotation', label: 'Fixed rotation', kind: 'boolean', default: false }, { key: 'ccd', label: 'Continuous (CCD)', kind: 'boolean', default: false }, ], // A container is pure structure: its content is the template, not a property. container: [], text: [ { key: 'text', label: 'Text', kind: 'text', default: 'Text' }, // Empty is the editor's own typeface, the one every text was drawn in before fonts existed. { key: FONT_KEY, label: 'Font', kind: 'asset', default: '', assetTypes: ['font'] }, { key: 'fontSize', label: 'Font size', kind: 'number', default: 32, min: 1, step: 1, decimals: 0 }, { key: 'color', label: 'Color', kind: 'color', default: 0xffffff }, { key: 'align', label: 'Align', kind: 'enum', default: 'center', options: [ { value: 'left', label: 'Left' }, { value: 'center', label: 'Center' }, { value: 'right', label: 'Right' }, ], }, ], }; const FULL_SCHEMAS: Record = Object.fromEntries( (Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [ type, isPlaceableType(type) ? [ ...COMMON_PROPS, ...(hasTransformProps(type) ? TRANSFORM_PROPS : []), ...sortPropsOf(type), ...ASSET_SCHEMAS[type], ] : ASSET_SCHEMAS[type], ]), ) as Record; export function schemaFor(type: AssetType): PropertySchema { return FULL_SCHEMAS[type]; } const SPRITE_KEYS = new Set(FULL_SCHEMAS.sprite.map((d) => d.key)); /** * A row every sprite has already: the switch, the transform and the sprite's own. A shader * parameter under one of these names would be a second row on the same bag key, and its uniform * would read the sprite's value instead of the default the Property node declares (#63). */ export function isSpriteProp(key: string): boolean { return SPRITE_KEYS.has(key); } /** * The type schema, for behaviors followed by their declared variables, for sprites by the * parameters of the shader asset their bag names – which is why the project is needed. */ export function schemaForAsset(project: Project, asset: Asset): PropertySchema { return schemaForBag(project, asset, asset.props); } /** A placed object's rows: an object overriding its shader has that shader's parameters. */ export function schemaForObject(project: Project, asset: Asset, object: SceneObject): PropertySchema { const shader = object.overrides[SHADER_KEY]; return schemaForBag(project, asset, shader === undefined ? asset.props : { [SHADER_KEY]: shader }); } function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): PropertySchema { if (asset.type === 'behavior') return [...FULL_SCHEMAS.behavior, ...asset.variables]; // Nothing but declarations: the starting values live in `props` under the very same keys. if (asset.type === 'variables' || asset.type === 'struct') return asset.variables; if (asset.type === 'sprite') return [...FULL_SCHEMAS.sprite, ...shaderParamRows(project, shaderOf(bag))]; // Unlike a sprite's shader, a source's graph is its own asset's, so no id has to be looked up. if (asset.type === 'particles') { const own = [...FULL_SCHEMAS.particles, ...particleParamDefs(asset.graph)]; return [...own, ...rowsNotClaimedBy(shaderParamRows(project, shaderOf(bag)), own)]; } return FULL_SCHEMAS[asset.type]; } /** * A source draws its rows from two graphs, so one name can arrive twice: `speed` on the particle * graph and `speed` on the shader it wears. The source's own row wins and the uniform keeps its * declared default – the shader cannot be blamed for the collision, the same one is legitimate on a * sprite, so nothing is rejected on its side. */ export function rowsNotClaimedBy(rows: readonly PropertyDef[], taken: readonly PropertyDef[]): PropertyDef[] { const claimed = new Set(taken.map((d) => d.key)); return rows.filter((row) => !claimed.has(row.key)); } /** The rows a shader asset adds to the sprites wearing it; a dangling id adds none. */ export function shaderParamRows(project: Project, shaderId: string): PropertyDef[] { const shader = shaderId ? findAssetOf(project, shaderId, 'shader') : undefined; return shader ? shaderParamDefs(shader.graph) : []; } /** A shader parameter a sprite *may* have, with the shaders that declare it under that name. */ export interface ShaderParamRow { def: PropertyDef; shaders: string[]; } /** * The parameters of every shader in the project, one row per name – which sprite wears which shader * is only known at runtime, so the picker offers them all and names where each one comes from. */ export function allShaderParamRows(project: Project): ShaderParamRow[] { const rows = new Map(); 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); }