The value type was already there: StructValue, textOf, sameValue, writeTyped/mergeStruct and the struct port type came with the first part, and Get/Set variable carry any-ports, so a bundle already flowed through the graph unchanged. What was missing was the declaration. PropertyDef gains kind 'struct' with a list of fields, VARIABLE_KINDS a fifth kind, variableDef a fifth parameter. A declared field is a primitive - a list field would need a nested editor and therefore the path in the slot address, the same step "a list of structs" is waiting for. coerceValue now returns PropertyValue rather than FieldValue, the first value type that is not a field; none of its five callers break. In the inspector a struct variable is a block, not a tab. The list tab exists because hundreds of rows fit in no inspector row; a struct is the opposite case - few fields that fit under each other. It is the port editor Pack already has, one level down and with a value column: type, key and default value in one place. A placed object sees the same fields as plain value rows: overriding yes, declaring no. Adding or removing a field touches the declaration, the default, the behavior's value and every instance override in one command, so a stored value can never keep a shape its declaration no longer promises. The row handlers read the bundle at the moment of the change rather than closing over a snapshot - a field writes the whole bundle, so with a snapshot the second edited row revives the first one's old value. defSignature carries the fields, otherwise a new row appears only at the next rebuild. Fixes a fixture fault this uncovered: step 10 pushed its text object into the scene without instanceEdits, which overrideSites walks with Object.entries. AddStructFieldCommand is the first caller to reach it from that state; the step was green alone and failed three checks in the full suite. Moves the dynamic-ports note to done - all three of its parts are built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
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<PropertyDef, { kind: 'struct' }>;
|
||
|
||
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<AssetType, PropertySchema> = {
|
||
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<AssetType, PropertySchema> = Object.fromEntries(
|
||
(Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [
|
||
type,
|
||
isPlaceableType(type) ? [...COMMON_PROPS, ...ASSET_SCHEMAS[type]] : ASSET_SCHEMAS[type],
|
||
]),
|
||
) as Record<AssetType, PropertySchema>;
|
||
|
||
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<ListItemKind, number | string | boolean> = {
|
||
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<string, FieldValue> = {};
|
||
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;
|
||
}
|