import type { Asset } from '../model/Asset'; import { type AssetType, isPlaceableType } from '../model/AssetTypes'; import { type FieldValue, isList, isStruct, type ListValue, type PropertyBag, type PropertyValue, 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 type { SceneObject } from '../model/SceneObject'; import { flatListOf, listItemOf } from './listItems'; import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders'; /** * 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. */ 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[] } : unknown; /** The definition of a flat kind: its default is a value of that kind, as the table says. */ export type FlatDef = { [P in K]: { key: string; label: string; kind: P; default: ValueOf

} & Extras

; }[K]; /** 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. There is no `object` * item kind: a list is stored in the project file, and an object is a live handle that no reload * survives. */ | { key: string; label: string; kind: 'list'; default: ListValue } /** * `fields` is the declaration; the value carries exactly those keys, which is what lets a struct * keep its shape while single fields are written (`mergeStruct`). */ | { key: string; label: string; kind: 'struct'; default: StructValue; fields: readonly StructFieldDef[] }; export interface PrimitiveFieldDef { key: string; kind: FieldKind; } /** A list held by a struct field. Its items declare nothing either, but they stay flat. */ export interface ListFieldDef { key: string; kind: 'list'; } export type StructFieldDef = PrimitiveFieldDef | ListFieldDef; 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 }, ]; const COMMON_KEYS = new Set(COMMON_PROPS.map((d) => d.key)); 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: 0x2a2d34 }], 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' }, ], }, ], // The pictures 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' }, { value: 'pingpong', label: 'Ping-pong' }, ], }, ], // Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`. behavior: [{ key: 'runInEditor', label: 'Run in editor', kind: 'boolean', default: false }], // 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: [], collider: [ { key: 'shape', label: 'Shape', kind: 'enum', default: 'box', options: [ { value: 'box', label: 'Box' }, { value: 'circle', label: 'Circle' }, ], }, { 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 }, { key: 'isTrigger', label: 'Trigger (no blocking)', 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' }, { 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, ...ASSET_SCHEMAS[type]] : ASSET_SCHEMAS[type], ]), ) as Record; export function schemaFor(type: AssetType): PropertySchema { return FULL_SCHEMAS[type]; } /** * 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: a sprite 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]; if (asset.type === 'sprite') return [...FULL_SCHEMAS.sprite, ...shaderParamRows(project, shaderOf(bag))]; return FULL_SCHEMAS[asset.type]; } /** 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; } 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) }; } 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 { 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); default: return coerceAs(value, def.kind, def.default); } } /** A struct field as its declaration promises. A list field takes only a list – see `ListFieldDef`. */ export function coerceFieldValue(value: PropertyValue, field: StructFieldDef): FieldValue { return field.kind === 'list' ? flatListOf(value) : coerceField(value, field.kind); }