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'; /** The inspector is generated from these entries, so a new property is one line in a schema below. */ export type PropertyDef = /** * `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. */ | { key: string; label: string; kind: 'number'; default: number; min?: number; max?: number; step?: number; decimals?: number; } | { key: string; label: string; kind: 'text'; default: string } | { key: string; label: string; kind: 'color'; default: number } | { key: string; label: string; kind: 'boolean'; default: boolean } | { key: string; label: string; kind: 'enum'; default: string; options: { value: string; label: string }[] } /** A keyboard key name (`KeyboardEvent.key`), captured by pressing it. */ | { key: string; label: string; kind: 'key'; default: string } /** * An asset id (`''` = none); without `assetTypes` the picker offers every placeable asset. * Ids are not validated on load, so a reference can dangle and readers must cope. */ | { key: string; label: string; kind: 'asset'; default: string; assetTypes?: readonly AssetType[] } /** * The id of a texture asset or of one of its frames. Its own kind rather than an `asset` * reference, because a frame is not an asset and the picker has to offer both. May dangle. */ | { key: string; label: string; kind: 'texture'; default: string } /** * `items` only decides how the inspector reads and writes the line; 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; items: ListItemKind } /** * `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 const LIST_ITEM_KINDS = ['number', 'text', 'boolean', 'color'] as const; export type ListItemKind = (typeof LIST_ITEM_KINDS)[number]; /** A field holds what a list item holds: a field that were itself a struct is what `FieldValue` rules out. */ export interface StructFieldDef { key: string; kind: ListItemKind; } export type StructDef = Extract; export type PropertySchema = readonly PropertyDef[]; export const VARIABLE_KINDS = ['number', 'boolean', 'text', 'list', 'struct'] as const; export type VariableKind = (typeof VARIABLE_KINDS)[number]; /** * 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); } /** 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 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: 'Smooth' }, { value: 'nearest', label: 'Pixel art' }, ], }, ], // Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`. behavior: [{ key: 'runInEditor', label: 'Run in editor', kind: 'boolean', default: false }], 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. */ export function schemaForAsset(asset: Asset): PropertySchema { return asset.type === 'behavior' ? [...FULL_SCHEMAS.behavior, ...asset.variables] : FULL_SCHEMAS[asset.type]; } export function propDef(asset: Asset, key: string): PropertyDef | undefined { return schemaForAsset(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, items?: ListItemKind, fields?: readonly StructFieldDef[], ): PropertyDef { switch (kind) { case 'number': return { key, label: key, kind, default: typeof defaultValue === 'number' ? defaultValue : 0 }; case 'boolean': return { key, label: key, kind, default: Boolean(defaultValue ?? false) }; case 'text': return { key, label: key, kind, default: defaultValue === undefined ? '' : textOf(defaultValue) }; case 'list': return { key, label: key, kind, default: isList(defaultValue) ? defaultValue : [], items: items ?? 'number', }; case 'struct': { const declared = fields ?? []; return { key, label: key, kind, fields: declared, default: structOf(declared, defaultValue) }; } } } /** The empty value of a kind, so a field that is not there yet still has one. */ const EMPTY_FIELD: Record = { number: 0, text: '', boolean: false, color: 0, }; export function emptyField(kind: ListItemKind): number | string | boolean { return EMPTY_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 ? coerceListItem(source[field.key]!, field.kind) : emptyField(field.kind); return value; } /** Clamps / coerces a raw value so it satisfies the definition. */ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue { switch (def.kind) { case 'number': { let n = typeof value === 'number' ? value : Number(value); if (!Number.isFinite(n)) n = 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 'color': { const n = typeof value === 'number' ? value : parseInt(textOf(value).replace('#', ''), 16); return Number.isFinite(n) ? Math.max(0, Math.min(0xffffff, Math.floor(n))) : def.default; } case 'boolean': return Boolean(value); case 'text': case 'key': case 'asset': case 'texture': return textOf(value); case 'enum': { const s = textOf(value); return def.options.some((o) => o.value === s) ? s : def.default; } case 'list': return isList(value) ? value.map((item) => coerceListItem(item, def.items)) : def.default; case 'struct': return structOf(def.fields, value); } } export function coerceListItem(value: PropertyValue, items: ListItemKind): number | string | boolean { if (items === 'text') return isList(value) ? value.join(', ') : textOf(value); if (items === 'boolean') { if (typeof value === 'boolean') return value; // A list is typed as text, so the spelling has to count: Boolean('false') would be true. const s = textOf(value).trim().toLowerCase(); return !(s === '' || s === 'false' || s === '0' || s === 'no'); } if (items === 'color') { const c = typeof value === 'number' ? value : parseInt(textOf(value).replace('#', ''), 16); return Number.isFinite(c) ? Math.max(0, Math.min(0xffffff, Math.floor(c))) : 0; } const n = typeof value === 'number' ? value : Number(value); return Number.isFinite(n) ? n : 0; }