Drop the per-list item declaration; every item carries its own kind

A list no longer says what its items are. ListDef.items, ListDef.fields,
ListFieldDef.items and SetListItemsCommand are gone; the single seam is
src/core/properties/listItems.ts. JSON already tells number, text, boolean
and struct apart, so only two kinds need a tag: {"$color": n} and the new
{"$texture": id}. Field names cannot start with "$", so a tag never
collides with a struct field. On a data link a texture item arrives as
its bare id; the graph has no images.

List tab: the mark in front of each row is the kind picker, "+ Item"
asks for the kind, both are ordinary value edits (one undo step). A list
inside a struct field only offers the flat kinds because the depth rule
forbids a struct item there (ListAccess.kinds); the menu used to offer
it and silently do nothing.

Legacy lists that declared items: 'color' are tagged on load.

Actor demo: the four walk animations and four standing frames live in
two list variables of texture items indexed by facing (Item at), so the
graph can be rebuilt in the editor. The stand frames get their grid ids
back; 44 nodes instead of 46.

Also: the user's edits to on-start-during-play.md and the new note
reseed-because-of-list.md.

Verified: check.sh 794 tests, full e2e 631 checks, smokes 24/19/20.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-12 13:35:02 +02:00
1 parent e147dd6d21
commit 11c900c998
34 files changed
+1218 -640

No files matched your search

+13 -122
View File
@@ -1,8 +1,6 @@
import {
type FieldValue,
isList,
isStruct,
type ListValue,
type PropertyBag,
type PropertyValue,
type StructValue,
@@ -10,13 +8,9 @@ import {
import { overrideSites } from '../model/instance';
import type { Project } from '../model/Project';
import {
coerceValue,
emptyFieldValue,
isValidFieldName,
isValidVariableName,
type ListItemKind,
type PrimitiveFieldDef,
primitiveFieldsOf,
type PropertyDef,
type StructFieldDef,
type VariableKind,
@@ -145,10 +139,8 @@ export class RenameVariableCommand implements Command {
const INVALID_NAME = 'Variable names must start with a letter and contain only letters, digits or _';
const INVALID_FIELD = 'Field names must start with a letter and contain only letters, digits or _';
/** Where declared fields live: on a struct variable, or on the struct items of a list variable. */
/** Where declared fields live: on a struct variable. A list item carries its own shape instead. */
interface FieldOwner {
/** True for a list of structs, where a field may not itself be a list (`ListFieldDef`). */
readonly inList: boolean;
fields(): readonly StructFieldDef[];
setFields(next: readonly StructFieldDef[]): void;
}
@@ -159,24 +151,13 @@ function variableOf(project: Project, assetId: string, key: string): PropertyDef
function requireFieldOwner(project: Project, assetId: string, key: string): FieldOwner {
const def = variableOf(project, assetId, key);
if (def?.kind === 'struct')
return {
inList: false,
fields: () => def.fields,
setFields: (next) => (def.fields = next),
};
if (def?.kind === 'list' && def.items === 'struct')
return {
inList: true,
fields: () => def.fields ?? [],
setFields: (next) => (def.fields = primitiveFieldsOf(next)),
};
throw new CommandError(`Variable "${key}" has no fields`);
if (def?.kind !== 'struct') throw new CommandError(`Variable "${key}" has no fields`);
return { fields: () => def.fields, setFields: (next) => (def.fields = next) };
}
/**
* One struct-carrying value of a variable (default, own, override, or list item); a field change hits
* all of them at once so none keeps a stale shape. `id` pairs a site up again on undo.
* One struct-carrying value of a variable (its declared default, the behavior's own, an override); a
* field change hits all of them at once so none keeps a stale shape. `id` pairs a site up on undo.
*/
interface StructSite {
id: string;
@@ -185,36 +166,16 @@ interface StructSite {
}
function structSites(project: Project, assetId: string, key: string): StructSite[] {
const def = variableOf(project, assetId, key);
const holders = valueHolders(project, assetId, key).filter(
// An override the object does not have stays absent: writing one here would invent a deviation.
(h) => !h.optional || (def?.kind === 'list' ? isList(h.read()) : isStruct(h.read())),
);
const sites: StructSite[] = [];
for (const holder of holders) {
if (def?.kind !== 'list') {
sites.push({
return (
valueHolders(project, assetId, key)
// An override the object does not have stays absent: writing one here would invent a deviation.
.filter((h) => !h.optional || isStruct(h.read()))
.map((holder) => ({
id: holder.id,
read: () => (isStruct(holder.read()) ? (holder.read() as StructValue) : {}),
write: (value) => holder.write(value),
});
continue;
}
const list = holder.read();
if (!isList(list)) continue;
for (let index = 0; index < list.length; index++) {
sites.push({
id: `${holder.id}#${index}`,
read: () => {
const item = (holder.read() as ListValue)[index];
return isStruct(item) ? item : {};
},
write: (value) =>
holder.write((holder.read() as ListValue).map((item, at) => (at === index ? value : item))),
});
}
}
return sites;
write: (value: StructValue) => holder.write(value),
}))
);
}
/** Everywhere one variable's value is kept: its declared default, the behavior's own, the overrides. */
@@ -278,8 +239,6 @@ export class AddStructFieldCommand implements Command {
if (!isValidFieldName(this.field.key)) throw new CommandError(INVALID_FIELD);
if (owner.fields().some((f) => f.key === this.field.key))
throw new CommandError(`Field "${this.field.key}" already exists`);
if (owner.inList && this.field.kind === 'list')
throw new CommandError('A list field cannot sit in the items of a list');
owner.setFields([...owner.fields(), { ...this.field }]);
for (const site of structSites(project, this.assetId, this.key))
site.write({ ...site.read(), [this.field.key]: emptyFieldValue(this.field) });
@@ -367,71 +326,3 @@ export class RenameStructFieldCommand implements Command {
site.write(renameField(site.read(), from, to));
}
}
/**
* Changes what a list variable holds. The declaration is the only place that says it, so every
* value has to follow at once – each is run through `coerceValue` against the new declaration,
* which is the same conversion an item edited in the List tab goes through.
*/
export class SetListItemsCommand implements Command {
readonly label: string;
private before?: {
items: ListItemKind;
fields?: readonly PrimitiveFieldDef[];
values: Map<string, PropertyValue>;
};
constructor(
private readonly assetId: string,
private readonly key: string,
private readonly items: ListItemKind,
) {
this.label = `Set ${key} items`;
}
execute(project: Project): void {
const def = requireListDef(project, this.assetId, this.key);
if (def.items === this.items)
throw new CommandError(`Variable "${this.key}" already holds ${this.items}`);
const values = new Map<string, PropertyValue>();
for (const holder of valueHolders(project, this.assetId, this.key)) {
const value = holder.read();
if (value !== undefined) values.set(holder.id, value);
}
this.before = { items: def.items, fields: def.fields, values };
def.items = this.items;
// Fields belong to struct items alone: kept while leaving so an undo has them back.
if (this.items === 'struct' && !def.fields) def.fields = [];
this.recoerce(project);
}
undo(project: Project): void {
if (!this.before) return;
const def = requireListDef(project, this.assetId, this.key);
def.items = this.before.items;
if (this.before.fields) def.fields = this.before.fields;
else delete def.fields;
for (const holder of valueHolders(project, this.assetId, this.key)) {
const value = this.before.values.get(holder.id);
if (value !== undefined) holder.write(value);
}
}
private recoerce(project: Project): void {
const def = requireListDef(project, this.assetId, this.key);
for (const holder of valueHolders(project, this.assetId, this.key)) {
const value = holder.read();
if (value !== undefined) holder.write(coerceValue(def, value));
}
}
}
function requireListDef(
project: Project,
assetId: string,
key: string,
): Extract<PropertyDef, { kind: 'list' }> {
const def = variableOf(project, assetId, key);
if (def?.kind !== 'list') throw new CommandError(`Variable "${key}" is not a list`);
return def;
}
+1 -9
View File
@@ -148,16 +148,8 @@ export function inlineInputDef(port: PortDef): PropertyDef {
case 'string':
case 'any':
return { key: port.key, label: port.label, kind: 'text', default: asString(port.default ?? '') };
// No port declares an item kind, so an inline list reads as numbers – the same default
// `+ Variable ▸ List` picks. The graph types a list as a list either way.
case 'list':
return {
key: port.key,
label: port.label,
kind: 'list',
default: asList(port.default),
items: 'number',
};
return { key: port.key, label: port.label, kind: 'list', default: asList(port.default) };
default:
return {
key: port.key,
+7 -3
View File
@@ -1,4 +1,5 @@
import { sameValue } from '../../../model/Properties';
import { itemValue } from '../../../properties/listItems';
import type { NodeDef } from '../../NodeRegistry';
import { asList, asListItem } from '../../values';
import { any, list, num } from '../shared';
@@ -11,9 +12,12 @@ export const IndexOf: NodeDef = {
inputs: [list('list'), any('value')],
outputs: [num('index')],
params: [],
// Compared by content, not by identity: two equal structs are never the same object.
// Compared by content, not by identity: two equal structs are never the same object. A color item
// is compared as its number, or searching a list of colors with one would never find it.
run: (_ctx, inputs) => {
const wanted = asListItem(inputs.value);
return { outputs: { index: asList(inputs.list).findIndex((item) => sameValue(item, wanted)) } };
const wanted = itemValue(asListItem(inputs.value));
return {
outputs: { index: asList(inputs.list).findIndex((item) => sameValue(itemValue(item), wanted)) },
};
},
};
+3 -1
View File
@@ -1,3 +1,4 @@
import { itemValue } from '../../../properties/listItems';
import type { NodeDef } from '../../NodeRegistry';
import { asList, asNumber } from '../../values';
import { any, list, num } from '../shared';
@@ -9,10 +10,11 @@ export const ItemAt: NodeDef = {
description: 'The item at an index, counting from 0. Out of range gives 0.',
inputs: [list('list'), num('index')],
outputs: [any('item')],
// A color item reads as its number: the graph has no color of its own (see `portKindOf`).
run: (_ctx, inputs) => {
const items = asList(inputs.list);
const i = Math.floor(asNumber(inputs.index));
return { outputs: { item: items[i] ?? 0 } };
return { outputs: { item: itemValue(items[i]) ?? 0 } };
},
params: [],
};
+2 -2
View File
@@ -6,9 +6,9 @@ export const ListLiteral: NodeDef = {
type: 'List',
label: 'List',
category: 'data',
description: 'A constant list, edited item by item in the List tab.',
description: 'A constant list, edited item by item in the List tab – each item with its own kind.',
inputs: [],
outputs: [list('value')],
params: [{ key: 'value', label: 'Value', kind: 'list', default: [], items: 'number' }],
params: [{ key: 'value', label: 'Value', kind: 'list', default: [] }],
run: (_ctx, _inputs, params) => ({ outputs: { value: asList(params.value) } }),
};
+4 -1
View File
@@ -1,3 +1,4 @@
import { itemLike } from '../../../properties/listItems';
import type { NodeDef } from '../../NodeRegistry';
import { asList, asListItem, asNumber } from '../../values';
import { any, list, num } from '../shared';
@@ -14,7 +15,9 @@ export const SetItem: NodeDef = {
const i = Math.floor(asNumber(inputs.index));
if (i < 0 || i >= items.length) return { outputs: { result: items } };
const next = [...items];
next[i] = asListItem(inputs.value);
// The slot keeps its kind: writing a number into a color item leaves a color behind, the same
// way writing into a number variable leaves a number (see `asTypeOf`).
next[i] = itemLike(items[i], asListItem(inputs.value));
return { outputs: { result: next } };
},
params: [],
+7 -5
View File
@@ -1,3 +1,4 @@
import { itemKindOf, structItemFields } from '../properties/listItems';
import type { PropertyDef, StructFieldDef } from '../properties/PropertySchema';
import { DECLARED_PORT_KINDS, type DeclaredPort, type DeclaredPortKind, type GraphNode } from './Graph';
@@ -15,14 +16,15 @@ export function fieldPortKind(field: StructFieldDef): DeclaredPortKind {
}
/**
* The bundle shape a variable declares, if it declares one: a struct variable holds it directly, a
* list of structs declares the shape of every item. Both are what a Pack builds and an Expand takes
* apart, so both can seed a node's fields.
* The bundle shape of a variable, if it has one: a struct variable declares it, a list takes it from
* the first struct item its default holds – items declare nothing, so the value is the only source.
* Both are what a Pack builds and an Expand takes apart, so both can seed a node's fields.
*/
export function structShapeOf(def: PropertyDef): readonly StructFieldDef[] | undefined {
if (def.kind === 'struct') return def.fields;
if (def.kind === 'list' && def.items === 'struct') return def.fields ?? [];
return undefined;
if (def.kind !== 'list') return undefined;
const item = def.default.find((entry) => itemKindOf(entry) === 'struct');
return item === undefined ? undefined : structItemFields(item);
}
/**
+44 -3
View File
@@ -1,8 +1,8 @@
/** The declarations cap depth: `list → struct → primitive` is the deepest a stored value goes. */
/** Depth is capped: `list → struct → primitive` is the deepest a stored value goes. */
export type ListValue = readonly (number | string | boolean | StructValue)[];
/** What a struct field may hold. A field is never itself a struct – see `ListValue`. */
export type FieldValue = number | string | boolean | ListValue;
/** What a struct field may hold. A field is never itself a struct – except a tagged colour. */
export type FieldValue = number | string | boolean | ListValue | ColorItem;
/** Several named values travelling as one, so a bundle needs one port instead of one per part. */
export type StructValue = { readonly [field: string]: FieldValue };
@@ -14,6 +14,44 @@ export type PropertyBag = Record<string, PropertyValue>;
export const isList = (value: unknown): value is ListValue => Array.isArray(value);
/**
* Inside a list a color and a texture have to say so: a list declares nothing about its items, and
* there 0xRRGGBB is a plain number and an asset id a plain text. A field name cannot start with `$`
* (`isValidFieldName`), so a tag can never collide with a struct item of one field.
*/
export const COLOR_TAG = '$color';
export const TEXTURE_TAG = '$texture';
/** Indexable like any struct, so the one place that reads fields by name needs no special case. */
export interface ColorItem {
readonly $color: number;
readonly [field: string]: FieldValue;
}
/** A picture a sprite can wear: a texture, one of its frames, or an animation – all one id. */
export interface TextureItem {
readonly $texture: string;
readonly [field: string]: FieldValue;
}
const isTagged = (value: unknown, tag: string): boolean =>
isStruct(value) && tag in value && Object.keys(value).length === 1;
export const isColorItem = (value: unknown): value is ColorItem => isTagged(value, COLOR_TAG);
export const isTextureItem = (value: unknown): value is TextureItem =>
isTagged(value, TEXTURE_TAG) && typeof (value as TextureItem).$texture === 'string';
export const colorItem = (color: number): ColorItem => ({ $color: color });
export const textureItem = (id: string): TextureItem => ({ $texture: id });
/** The number behind a tagged color. */
export const colorItemValue = (item: ColorItem): number => colorOf(item.$color, 0);
/** The id behind a tagged texture. */
export const textureItemValue = (item: TextureItem): string => item.$texture;
/**
* Model-level only: a live entity handle is an object too, and the graph tells the two apart by
* the handle's brand (see `values.ts`). Nothing in a PropertyBag is ever a handle.
@@ -28,6 +66,9 @@ export const isStruct = (value: unknown): value is StructValue =>
export function textOf(value: PropertyValue | undefined): string {
if (value === undefined) return '';
if (isList(value)) return value.map((item) => textOf(item)).join(', ');
// A tagged value reads as what it wraps, the same as the matching property does.
if (isColorItem(value)) return String(colorItemValue(value));
if (isTextureItem(value)) return textureItemValue(value);
if (isStruct(value))
return Object.entries(value)
.map(([field, item]) => `${field}: ${textOf(item)}`)
+14 -55
View File
@@ -12,6 +12,7 @@ import {
booleanOf,
colorOf,
} from '../model/Properties';
import { coerceField, flatListOf, listItemOf } from './listItems';
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
export type PropertyDef =
@@ -47,19 +48,12 @@ export type PropertyDef =
*/
| { 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.
* 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;
items: ListItemKind;
/** The shape of every item, and only for `items: 'struct'` – a list of structs is homogeneous. */
fields?: readonly PrimitiveFieldDef[];
}
| { 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`).
@@ -70,8 +64,8 @@ export type PropertyDef =
export const FIELD_KINDS = ['number', 'text', 'boolean', 'color'] as const;
export type FieldKind = (typeof FIELD_KINDS)[number];
/** What a list holds – a primitive plus `struct`, so a list of bundles is one declaration. */
export const LIST_ITEM_KINDS = [...FIELD_KINDS, 'struct'] as const;
/** What one item of a list may be – a primitive plus `struct`, so a bundle can sit in a list. */
export const LIST_ITEM_KINDS = [...FIELD_KINDS, 'texture', 'struct'] as const;
export type ListItemKind = (typeof LIST_ITEM_KINDS)[number];
/** What a field of a struct property holds – a primitive plus `list`. */
@@ -83,11 +77,10 @@ export interface PrimitiveFieldDef {
kind: FieldKind;
}
/** A list held by a struct field; its items are primitives only, which keeps storable shapes finite. */
/** A list held by a struct field. Its items declare nothing either, but they stay flat. */
export interface ListFieldDef {
key: string;
kind: 'list';
items: FieldKind;
}
export type StructFieldDef = PrimitiveFieldDef | ListFieldDef;
@@ -242,7 +235,6 @@ export function variableDef(
key: string,
kind: VariableKind,
defaultValue?: PropertyValue,
items?: ListItemKind,
fields?: readonly StructFieldDef[],
): PropertyDef {
switch (kind) {
@@ -252,19 +244,8 @@ export function variableDef(
return { key, label: key, kind, default: Boolean(defaultValue ?? false) };
case 'text':
return { key, label: key, kind, default: defaultValue === undefined ? '' : textOf(defaultValue) };
case 'list': {
const item = items ?? 'number';
const shape = item === 'struct' ? primitiveFieldsOf(fields ?? []) : undefined;
const raw = isList(defaultValue) ? defaultValue : [];
return {
key,
label: key,
kind,
default: raw.map((entry) => coerceListItem(entry, item, shape)),
items: item,
...(shape ? { fields: shape } : {}),
};
}
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) };
@@ -272,10 +253,6 @@ export function variableDef(
}
}
export function primitiveFieldsOf(fields: readonly StructFieldDef[]): PrimitiveFieldDef[] {
return fields.filter((f): f is PrimitiveFieldDef => f.kind !== 'list');
}
/** The empty value of a kind, so a field that is not there yet still has one. */
const EMPTY_FIELD: Record<FieldKind, number | string | boolean> = {
number: 0,
@@ -324,32 +301,14 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
return def.options.some((o) => o.value === s) ? s : def.default;
}
case 'list':
return isList(value) ? value.map((item) => coerceListItem(item, def.items, def.fields)) : def.default;
// 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);
}
}
/** `fields` is only read for `items: 'struct'`, where it is the shape every item is forced into. */
export function coerceListItem(
value: PropertyValue,
items: ListItemKind,
fields?: readonly StructFieldDef[],
): number | string | boolean | StructValue {
return items === 'struct' ? structOf(fields ?? [], value) : coerceField(value, items);
}
/** A struct field as its declaration promises. A list field takes only a list – see `ListFieldDef`. */
export function coerceFieldValue(value: PropertyValue, field: StructFieldDef): FieldValue {
if (field.kind !== 'list') return coerceField(value, field.kind);
return isList(value) ? value.map((item) => coerceField(item, field.items)) : [];
}
/** The primitive half of `coerceListItem`, split off because a struct field is never a struct. */
export function coerceField(value: PropertyValue, items: FieldKind): number | string | boolean {
if (items === 'text') return textOf(value);
if (items === 'boolean') return typeof value === 'boolean' ? value : booleanOf(textOf(value));
if (items === 'color') return colorOf(value, 0);
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : 0;
return field.kind === 'list' ? flatListOf(value) : coerceField(value, field.kind);
}
+136
View File
@@ -0,0 +1,136 @@
/**
* A list declares nothing about what it holds: every item carries its own kind, read back off the
* value. JSON already says what a number, a text, a boolean and a struct are, so only a color and a
* texture need a tag (`COLOR_TAG`, `TEXTURE_TAG`) – which keeps every list ever stored readable.
*
* Depth is still capped the way the declarations used to cap it: a struct item holds flat items,
* and so does a list inside a struct field. A texture is a list item only; in a flat position it
* keeps its id as text rather than vanishing.
*/
import {
booleanOf,
type ColorItem,
colorItem,
colorItemValue,
colorOf,
type FieldValue,
isColorItem,
isList,
isStruct,
isTextureItem,
type PropertyValue,
type StructValue,
textOf,
textureItem,
textureItemValue,
} from '../model/Properties';
import type { FieldKind, ListItemKind } from './PropertySchema';
/** What a list holds. Identical to `ListValue[number]`, named for the places that mean one item. */
export type ListItem = number | string | boolean | StructValue;
/** What a flat position holds: no struct except a tagged color, so the depth stays capped. */
export type FlatItem = number | string | boolean | ColorItem;
/** The kind an item is, taken from the item itself – there is nowhere else it could come from. */
export function itemKindOf(item: PropertyValue | undefined): ListItemKind {
if (isColorItem(item)) return 'color';
if (isTextureItem(item)) return 'texture';
if (isStruct(item) || isList(item)) return 'struct';
if (typeof item === 'string') return 'text';
if (typeof item === 'boolean') return 'boolean';
return 'number';
}
/** Flat positions hold no struct and no texture, so either of them reads as its text. */
export function fieldKindOf(value: PropertyValue | undefined): FieldKind {
const kind = itemKindOf(value);
return kind === 'struct' || kind === 'texture' ? 'text' : kind;
}
/** What the widgets and the graph see: a tag is what it wraps, everything else is itself. */
export function itemValue(item: PropertyValue | undefined): PropertyValue | undefined {
if (isColorItem(item)) return colorItemValue(item);
if (isTextureItem(item)) return textureItemValue(item);
return item;
}
/** `value` as an item of `kind`, which is how a row changes what it holds. */
export function makeItem(kind: ListItemKind, value: PropertyValue | undefined): ListItem {
switch (kind) {
case 'color':
// Through `itemValue`: a tagged color handed to `colorOf` would be read as hex text.
return colorItem(colorOf(itemValue(value) ?? 0, 0));
case 'texture':
return textureItem(textOf(itemValue(value)));
case 'struct':
return structItemOf(value);
default:
return coerceField(itemValue(value), kind);
}
}
/** `value` as an item of the kind the slot already holds, so writing a color keeps it a color. */
export function itemLike(current: PropertyValue | undefined, value: PropertyValue | undefined): ListItem {
return makeItem(itemKindOf(current), value);
}
/** `value` as a flat item of `kind` – a struct item's field, where a struct itself cannot go. */
export function makeFlatItem(kind: FieldKind, value: PropertyValue | undefined): FlatItem {
return kind === 'color'
? colorItem(colorOf(itemValue(value) ?? 0, 0))
: coerceField(itemValue(value), kind);
}
/** A flat slot (a struct item's field, an item of a list in a struct field) keeps its kind too. */
export function fieldLike(current: PropertyValue | undefined, value: PropertyValue | undefined): FlatItem {
return makeFlatItem(fieldKindOf(current), value);
}
/** Any value as a valid item; the one coercion a list value goes through. */
export function listItemOf(value: PropertyValue | undefined): ListItem {
return makeItem(itemKindOf(value), value);
}
/**
* A flat position holds a primitive or a tagged color. Anything deeper is dropped rather than
* flattened – the same bound the declarations used to draw, and the one the store writes.
*/
export function flatItemOf(value: PropertyValue | undefined): FlatItem | undefined {
if (isColorItem(value)) return colorItem(colorItemValue(value));
if (isTextureItem(value)) return textureItemValue(value);
if (isList(value) || isStruct(value)) return undefined;
return coerceField(value, fieldKindOf(value));
}
/** A struct item keeps the fields it carries, each as the kind its own value says. */
export function structItemOf(value: PropertyValue | undefined): StructValue {
if (!isStruct(value) || isColorItem(value) || isTextureItem(value)) return {};
const fields: Record<string, FieldValue> = {};
for (const [key, item] of Object.entries(value)) {
const flat = flatItemOf(item);
if (flat !== undefined) fields[key] = flat;
}
return fields;
}
/** The items of a list sitting in a struct field: flat ones only. */
export function flatListOf(value: PropertyValue | undefined): readonly FlatItem[] {
if (!isList(value)) return [];
return value.map(flatItemOf).filter((item): item is FlatItem => item !== undefined);
}
/** The shape a struct item has right now: its keys in order, each with the kind of its value. */
export function structItemFields(item: PropertyValue | undefined): { key: string; kind: FieldKind }[] {
if (!isStruct(item) || isColorItem(item) || isTextureItem(item)) return [];
return Object.entries(item).map(([key, value]) => ({ key, kind: fieldKindOf(value) }));
}
/** A primitive as one of the four kinds. The spelling of a text counts, see `booleanOf`. */
export function coerceField(value: PropertyValue | undefined, kind: FieldKind): number | string | boolean {
if (kind === 'text') return textOf(value);
if (kind === 'boolean') return typeof value === 'boolean' ? value : booleanOf(textOf(value));
if (kind === 'color') return colorOf(value ?? 0, 0);
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : 0;
}
+86 -16
View File
@@ -13,13 +13,28 @@ import {
} from '../graph';
import type { Asset, TextureFrame } from '../model/Asset';
import { isAssetType } from '../model/AssetTypes';
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
import type { FieldValue, PropertyBag, PropertyValue, StructValue } from '../model/Properties';
import { emptyEdit, type InstanceEdit, isEmptyEdit, overrideSites } from '../model/instance';
import {
type ColorItem,
colorItem,
colorItemValue,
type FieldValue,
isTextureItem,
textureItem,
textureItemValue,
isColorItem,
isList,
type PropertyBag,
type PropertyValue,
setBagValue,
type StructValue,
} from '../model/Properties';
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { isTextureFile, normalizeFrameRect } from '../model/texture';
import { hasParentCycle } from '../model/walk';
import { upgradePropertyNodes, upgradeVariableNodes } from './legacyNodes';
import { makeItem } from '../properties/listItems';
import {
coerceValue,
COMMON_PROPS,
@@ -32,6 +47,7 @@ import {
type PropertyDef,
type PropertySchema,
schemaFor,
structOf,
type StructFieldDef,
VARIABLE_KINDS,
variableDef,
@@ -49,10 +65,13 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
// Pass 1: asset ids and their effective schemas, needed to coerce overrides in pass 2.
const schemas = new Map<string, PropertySchema>();
const variables = new Map<string, PropertyDef[]>();
const legacyLists = new Map<string, Map<string, DeclaredItems>>();
for (const a of rawAssets) {
if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue;
if (a.type === 'behavior') {
const vars = normalizeVariables(a.variables);
const declared = new Map<string, DeclaredItems>();
const vars = normalizeVariables(a.variables, declared);
if (declared.size > 0) legacyLists.set(a.id, declared);
variables.set(a.id, vars);
schemas.set(a.id, [...COMMON_PROPS, ...vars]);
} else {
@@ -107,13 +126,48 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
activeSceneId = first ? first.id : '';
}
return {
const project: Project = {
formatVersion: PROJECT_FORMAT_VERSION,
id: typeof r.id === 'string' && r.id ? r.id : fallbackId,
name: typeof r.name === 'string' ? r.name : 'Untitled',
assets,
activeSceneId,
};
migrateDeclaredLists(project, legacyLists);
return project;
}
/** What a list variable declared before its items carried their own kind – see `migrateDeclaredLists`. */
interface DeclaredItems {
kind: ListItemKind;
fields: readonly StructFieldDef[];
}
/**
* A list used to declare what it held, so its stored items did not have to say: a `color` list held
* plain numbers, and a list of structs could hold an item missing a declared field. Items carry
* their own kind now, so every value of such a variable is converted here – the declaration is read
* one last time and then gone.
*/
function migrateDeclaredLists(project: Project, legacy: Map<string, Map<string, DeclaredItems>>): void {
const convert =
({ kind, fields }: DeclaredItems) =>
(value: PropertyValue | undefined): PropertyValue | undefined =>
isList(value)
? value.map((item) => (kind === 'struct' ? structOf(fields, item) : makeItem(kind, item)))
: value;
for (const [assetId, declared] of legacy) {
const asset = project.assets.find((a) => a.id === assetId);
if (asset?.type !== 'behavior') continue;
for (const [key, items] of declared) {
const as = convert(items);
const def = asset.variables.find((v) => v.key === key);
if (def) def.default = as(def.default) ?? def.default;
setBagValue(asset.props, key, as(asset.props[key]));
for (const site of overrideSites(project, (id) => id === assetId))
setBagValue(site.bag, key, as(site.bag[key]));
}
}
}
/** Plain JSON-ready copy of the project (deep clone, stable key order for readable diffs). */
@@ -179,7 +233,8 @@ function normalizeInstanceEdits(raw: unknown): Record<string, InstanceEdit> {
return edits;
}
function normalizeVariables(raw: unknown): PropertyDef[] {
/** `legacy` collects what a list variable still declares about its items – see `migrateDeclaredLists`. */
function normalizeVariables(raw: unknown, legacy?: Map<string, DeclaredItems>): PropertyDef[] {
const defs: PropertyDef[] = [];
const seen = new Set<string>();
for (const item of Array.isArray(raw) ? raw : []) {
@@ -191,16 +246,20 @@ function normalizeVariables(raw: unknown): PropertyDef[] {
// Common property keys are reserved – a variable of that name would shadow the switch.
if (!isValidVariableName(key) || seen.has(key)) continue;
seen.add(key);
const items = (LIST_ITEM_KINDS as readonly string[]).includes(String(v.items))
? (v.items as ListItemKind)
: undefined;
const def = variableDef(key, kind, undefined, items, normalizeStructFields(v.fields));
const fields = normalizeStructFields(v.fields);
if (kind === 'list' && (LIST_ITEM_KINDS as readonly string[]).includes(String(v.items)))
legacy?.set(key, { kind: v.items as ListItemKind, fields: primitivesOf(fields) });
const def = variableDef(key, kind, undefined, fields);
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue);
defs.push(def);
}
return defs;
}
/** A declared item shape held primitives only; a list field of one would have been a level too deep. */
const primitivesOf = (fields: readonly StructFieldDef[]): StructFieldDef[] =>
fields.filter((f) => f.kind !== 'list');
/** An unreadable field is dropped rather than defaulted: an invented key would claim a value. */
function normalizeStructFields(raw: unknown): StructFieldDef[] {
const fields: StructFieldDef[] = [];
@@ -219,8 +278,7 @@ function normalizeStructFields(raw: unknown): StructFieldDef[] {
function readField(key: string, f: Record<string, unknown>): StructFieldDef | null {
const isFieldKind = (value: unknown) => (FIELD_KINDS as readonly string[]).includes(String(value));
if (f.kind === 'list')
return { key, kind: 'list', items: isFieldKind(f.items) ? (f.items as FieldKind) : 'number' };
if (f.kind === 'list') return { key, kind: 'list' };
return isFieldKind(f.kind) ? { key, kind: f.kind as FieldKind } : null;
}
@@ -352,13 +410,25 @@ function asStoredValue(value: unknown): PropertyValue | undefined {
const isPrimitive = (value: unknown): value is number | string | boolean =>
typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean';
/** A list item: a primitive or a struct of primitives. */
const asStoredItem = (value: unknown): number | string | boolean | StructValue | undefined =>
isPrimitive(value) ? value : asStoredStruct(value, (item) => (isPrimitive(item) ? item : undefined));
/**
* A primitive, or the one object a flat position may hold: a tagged color. A tagged texture is a
* list item only, so here it keeps its id as text – the same degrade `flatItemOf` makes.
*/
const asFlatItem = (value: unknown): number | string | boolean | ColorItem | undefined => {
if (isPrimitive(value)) return value;
if (isColorItem(value)) return colorItem(colorItemValue(value));
return isTextureItem(value) ? textureItemValue(value) : undefined;
};
/** A struct field: a primitive or a flat list. A field that is itself a struct is dropped. */
/** A list item: a tagged texture, a flat one, or a struct of flat ones. */
const asStoredItem = (value: unknown): number | string | boolean | StructValue | undefined =>
isTextureItem(value)
? textureItem(textureItemValue(value))
: (asFlatItem(value) ?? asStoredStruct(value, asFlatItem));
/** A struct field: a flat item or a flat list. A field that is itself a struct is dropped. */
const asStoredField = (value: unknown): FieldValue | undefined =>
isPrimitive(value) ? value : Array.isArray(value) ? value.filter(isPrimitive) : undefined;
Array.isArray(value) ? value.map(asFlatItem).filter((item) => item !== undefined) : asFlatItem(value);
function asStoredStruct(
value: unknown,