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:
1 parent
e147dd6d21
commit
11c900c998
34 files changed
+1218
-640
No files matched your search
+86
-16
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user