A state machine is an asset whose graph holds one `State` node per state (no ports, name as title), edited in a new "State machine" tab that reuses the graph canvas: right-click places "State n", snap toggle shared with the Behavior tab via SnapToggle. `statemachine` is a value kind like texture (port, literal node, variable, struct field, tagged list item). Behaviors use it through `State branch` (one exec output per state) and `Set state` (one exec input per state; the interpreter now hands `run` the entry port). States live per behavior instance in NodeContext.states, with no initial state. Ports derive from the inline picker or a wired `State machine` literal; BehaviorLookup became GraphLookup with behavior(id) and machine(id). The Graph tab is labelled "Behavior" (key unchanged). Unit tests (state-machine, kinds, catalog, ports), e2e step 26 and README added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
393 lines
15 KiB
TypeScript
393 lines
15 KiB
TypeScript
// Importing the graph package index is what registers the built-in nodes.
|
||
import {
|
||
createEmptyGraph,
|
||
type DeclaredPort,
|
||
type DeclaredPortKind,
|
||
type Graph,
|
||
type GraphEdge,
|
||
type GraphNode,
|
||
isValidPortName,
|
||
fixedPortKeys,
|
||
nodeDef,
|
||
STATE,
|
||
} 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,
|
||
type PropertyBag,
|
||
type PropertyValue,
|
||
type StructValue,
|
||
tagOf,
|
||
} 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 {
|
||
DECLARED_PORT_KINDS,
|
||
FIELD_KINDS,
|
||
type FieldKind,
|
||
isTaggedKind,
|
||
VARIABLE_KINDS,
|
||
type VariableKind,
|
||
} from '../properties/kinds';
|
||
import { type FlatItem, makeFlatItem } from '../properties/listItems';
|
||
import {
|
||
coerceValue,
|
||
COMMON_PROPS,
|
||
isValidFieldName,
|
||
isValidVariableName,
|
||
type PropertyDef,
|
||
type PropertySchema,
|
||
schemaFor,
|
||
type StructFieldDef,
|
||
variableDef,
|
||
} from '../properties/PropertySchema';
|
||
|
||
/**
|
||
* Turns untrusted JSON (server file, import) into a well-formed Project: unknown keys are dropped,
|
||
* missing ones get defaults, values are coerced against the schema, dangling objects are removed.
|
||
*/
|
||
export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||
const r = asRecord(raw);
|
||
const rawAssets = (Array.isArray(r.assets) ? r.assets : []).map(asRecord);
|
||
|
||
// 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[]>();
|
||
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);
|
||
variables.set(a.id, vars);
|
||
schemas.set(a.id, [...COMMON_PROPS, ...vars]);
|
||
} else {
|
||
schemas.set(a.id, schemaFor(a.type));
|
||
}
|
||
}
|
||
|
||
// Pass 2: build assets. Frame ids share the reference space with asset ids, so `normalizeTexture`
|
||
// is handed the ids already spoken for.
|
||
const assets: Asset[] = [];
|
||
const takenIds = new Set(schemas.keys());
|
||
for (const a of rawAssets) {
|
||
const id = typeof a.id === 'string' ? a.id : '';
|
||
const schema = schemas.get(id);
|
||
if (!schema || !isAssetType(a.type)) continue;
|
||
const type = a.type;
|
||
const name = typeof a.name === 'string' ? a.name : type;
|
||
const props = normalizeBag(a.props, schema, false);
|
||
if (type === 'scene' || type === 'container') {
|
||
assets.push({ id, type, name, props, objects: normalizeObjects(a.objects, schemas) });
|
||
} else if (type === 'behavior') {
|
||
assets.push({
|
||
id,
|
||
type,
|
||
name,
|
||
props,
|
||
variables: variables.get(id) ?? [],
|
||
graph: normalizeGraph(a.graph),
|
||
});
|
||
} else if (type === 'statemachine') {
|
||
assets.push({ id, type, name, props, graph: normalizeGraph(a.graph, STATE) });
|
||
} else if (type === 'texture') {
|
||
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
|
||
} else if (type === 'animation') {
|
||
// Kept even when they dangle, like a sprite's `texture`: a frame reads as no picture.
|
||
const frames = Array.isArray(a.frames) ? a.frames : [];
|
||
assets.push({
|
||
id,
|
||
type,
|
||
name,
|
||
props,
|
||
frames: frames.filter((f): f is string => typeof f === 'string' && f !== ''),
|
||
});
|
||
} else {
|
||
assets.push({ id, type, name, props });
|
||
}
|
||
}
|
||
|
||
let activeSceneId = typeof r.activeSceneId === 'string' ? r.activeSceneId : '';
|
||
if (!assets.some((a) => a.type === 'scene' && a.id === activeSceneId)) {
|
||
const first = assets.find((a) => a.type === 'scene');
|
||
activeSceneId = first ? first.id : '';
|
||
}
|
||
|
||
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,
|
||
};
|
||
return project;
|
||
}
|
||
|
||
/** Plain JSON-ready copy of the project (deep clone, stable key order for readable diffs). */
|
||
export function serializeProject(project: Project): unknown {
|
||
return JSON.parse(JSON.stringify(project));
|
||
}
|
||
|
||
function normalizeObjects(raw: unknown, schemas: Map<string, PropertySchema>): SceneObject[] {
|
||
const objects: SceneObject[] = [];
|
||
for (const item of Array.isArray(raw) ? raw : []) {
|
||
const o = asRecord(item);
|
||
if (typeof o.id !== 'string' || !o.id || typeof o.assetId !== 'string') continue;
|
||
const schema = schemas.get(o.assetId);
|
||
if (!schema || schema === schemaFor('scene')) continue; // dangling reference or illegal scene-in-scene
|
||
const p = asRecord(o.position);
|
||
const offset = asRecord(o.editorOffset);
|
||
objects.push({
|
||
id: o.id,
|
||
assetId: o.assetId,
|
||
name: typeof o.name === 'string' ? o.name : '',
|
||
position: { x: toNumber(p.x), y: toNumber(p.y), z: toNumber(p.z) },
|
||
editorOffset: { x: toNumber(offset.x), y: toNumber(offset.y) },
|
||
overrides: normalizeBag(o.overrides, schema, true),
|
||
parentId: typeof o.parentId === 'string' ? o.parentId : '',
|
||
instanceEdits: normalizeInstanceEdits(o.instanceEdits),
|
||
});
|
||
}
|
||
// Parents must exist in the same scene, differ from the object itself and not form cycles.
|
||
const byId = new Map(objects.map((o) => [o.id, o]));
|
||
for (const o of objects) if (o.parentId && (o.parentId === o.id || !byId.has(o.parentId))) o.parentId = '';
|
||
// Cut one link per cycle: an object already unlinked here is a root for the objects checked after it.
|
||
const parentOf = (o: SceneObject) => (o.parentId ? byId.get(o.parentId) : undefined);
|
||
for (const o of objects) if (hasParentCycle(o, parentOf)) o.parentId = '';
|
||
return objects;
|
||
}
|
||
|
||
/**
|
||
* Deviations of container content, keyed by template path. Values are not coerced against a schema:
|
||
* which asset a path leads to depends on container assets that may not be built yet, and resolving
|
||
* drops what the schema does not declare anyway.
|
||
*/
|
||
function normalizeInstanceEdits(raw: unknown): Record<string, InstanceEdit> {
|
||
const edits: Record<string, InstanceEdit> = {};
|
||
for (const [path, value] of Object.entries(asRecord(raw))) {
|
||
if (!path) continue;
|
||
const e = asRecord(value);
|
||
const edit = emptyEdit();
|
||
if (typeof e.name === 'string') edit.name = e.name;
|
||
const position = asRecord(e.position);
|
||
for (const axis of ['x', 'y', 'z'] as const)
|
||
if (typeof position[axis] === 'number' && Number.isFinite(position[axis]))
|
||
edit.position[axis] = position[axis];
|
||
const offset = asRecord(e.editorOffset);
|
||
if (typeof offset.x === 'number' || typeof offset.y === 'number')
|
||
edit.editorOffset = { x: toNumber(offset.x), y: toNumber(offset.y) };
|
||
for (const [key, v] of Object.entries(asRecord(e.overrides))) {
|
||
const value = asStoredValue(v);
|
||
if (value !== undefined) edit.overrides[key] = value;
|
||
}
|
||
if (!isEmptyEdit(edit)) edits[path] = edit;
|
||
}
|
||
return edits;
|
||
}
|
||
|
||
function normalizeVariables(raw: unknown): PropertyDef[] {
|
||
const defs: PropertyDef[] = [];
|
||
const seen = new Set<string>();
|
||
for (const item of Array.isArray(raw) ? raw : []) {
|
||
const v = asRecord(item);
|
||
const key = typeof v.key === 'string' ? v.key : '';
|
||
const kind = (VARIABLE_KINDS as readonly string[]).includes(String(v.kind))
|
||
? (v.kind as VariableKind)
|
||
: 'number';
|
||
// 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 fields = normalizeStructFields(v.fields);
|
||
const def = variableDef(key, kind, undefined, fields);
|
||
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue);
|
||
defs.push(def);
|
||
}
|
||
return defs;
|
||
}
|
||
|
||
/** An unreadable field is dropped rather than defaulted: an invented key would claim a value. */
|
||
function normalizeStructFields(raw: unknown): StructFieldDef[] {
|
||
const fields: StructFieldDef[] = [];
|
||
const seen = new Set<string>();
|
||
for (const item of Array.isArray(raw) ? raw : []) {
|
||
const f = asRecord(item);
|
||
const key = typeof f.key === 'string' ? f.key : '';
|
||
if (!isValidFieldName(key) || seen.has(key)) continue;
|
||
const field = readField(key, f);
|
||
if (!field) continue;
|
||
seen.add(key);
|
||
fields.push(field);
|
||
}
|
||
return fields;
|
||
}
|
||
|
||
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' };
|
||
return isFieldKind(f.kind) ? { key, kind: f.kind as FieldKind } : null;
|
||
}
|
||
|
||
function normalizePorts(raw: unknown, fixed: readonly string[], writable: boolean): DeclaredPort[] {
|
||
const ports: DeclaredPort[] = [];
|
||
const seen = new Set(fixed);
|
||
for (const item of Array.isArray(raw) ? raw : []) {
|
||
const p = asRecord(item);
|
||
const key = typeof p.key === 'string' ? p.key : '';
|
||
if (!isValidPortName(key) || seen.has(key)) continue;
|
||
seen.add(key);
|
||
const kind = (DECLARED_PORT_KINDS as readonly string[]).includes(String(p.kind))
|
||
? (p.kind as DeclaredPortKind)
|
||
: 'number';
|
||
ports.push(writable && p.write === true ? { key, kind, write: true } : { key, kind });
|
||
}
|
||
return ports;
|
||
}
|
||
|
||
/** `only` restricts the node types kept: a state machine's graph holds states and nothing else. */
|
||
function normalizeGraph(raw: unknown, only?: string): Graph {
|
||
const g = asRecord(raw);
|
||
const graph = createEmptyGraph();
|
||
const nodeIds = new Set<string>();
|
||
for (const item of Array.isArray(g.nodes) ? g.nodes : []) {
|
||
const n = asRecord(item);
|
||
const id = typeof n.id === 'string' ? n.id : '';
|
||
const type = typeof n.type === 'string' ? n.type : '';
|
||
const def = nodeDef(type);
|
||
if (!id || nodeIds.has(id) || !def || (only !== undefined && type !== only)) continue;
|
||
nodeIds.add(id);
|
||
// Params carry both settings and inline input values, so there is no schema to coerce against.
|
||
const params: PropertyBag = {};
|
||
for (const [key, value] of Object.entries(asRecord(n.params))) {
|
||
const stored = asStoredValue(value);
|
||
if (stored !== undefined) params[key] = stored;
|
||
}
|
||
const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params };
|
||
const ports = normalizePorts(n.ports, fixedPortKeys(def), def.declaresPorts === 'both');
|
||
if (ports.length > 0) node.ports = ports;
|
||
if (n.collapsed === true) node.collapsed = true;
|
||
graph.nodes.push(node);
|
||
}
|
||
const edgeIds = new Set<string>();
|
||
for (const item of Array.isArray(g.edges) ? g.edges : []) {
|
||
const e = asRecord(item);
|
||
const from = asRecord(e.from);
|
||
const to = asRecord(e.to);
|
||
const id = typeof e.id === 'string' ? e.id : '';
|
||
if (!id || edgeIds.has(id)) continue;
|
||
if (
|
||
typeof from.node !== 'string' ||
|
||
typeof from.port !== 'string' ||
|
||
typeof to.node !== 'string' ||
|
||
typeof to.port !== 'string'
|
||
)
|
||
continue;
|
||
if (!nodeIds.has(from.node) || !nodeIds.has(to.node)) continue;
|
||
edgeIds.add(id);
|
||
const edge: GraphEdge = {
|
||
id,
|
||
from: { node: from.node, port: from.port },
|
||
to: { node: to.node, port: to.port },
|
||
};
|
||
if (e.folded === true) edge.folded = true;
|
||
graph.edges.push(edge);
|
||
}
|
||
return graph;
|
||
}
|
||
|
||
/**
|
||
* A file name that is not the server's content-addressed form is dropped: a request built from it
|
||
* would 404, and the texture then loads as "no picture".
|
||
*/
|
||
function normalizeTexture(
|
||
raw: Record<string, unknown>,
|
||
takenIds: Set<string>,
|
||
): { file: string; width: number; height: number; frames: TextureFrame[] } {
|
||
const file = typeof raw.file === 'string' && isTextureFile(raw.file) ? raw.file : '';
|
||
const size = (value: unknown): number =>
|
||
typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
||
const width = size(raw.width);
|
||
const height = size(raw.height);
|
||
const frames: TextureFrame[] = [];
|
||
for (const item of Array.isArray(raw.frames) ? raw.frames : []) {
|
||
const f = asRecord(item);
|
||
const id = typeof f.id === 'string' ? f.id : '';
|
||
// Frame ids share the reference space with asset ids, so a duplicate would make one
|
||
// reference mean two pictures.
|
||
if (!id || takenIds.has(id)) continue;
|
||
takenIds.add(id);
|
||
const rect = normalizeFrameRect(
|
||
{ x: toNumber(f.x), y: toNumber(f.y), w: toNumber(f.w), h: toNumber(f.h) },
|
||
width,
|
||
height,
|
||
);
|
||
frames.push({ id, name: typeof f.name === 'string' ? f.name : '', ...rect });
|
||
}
|
||
return { file, width, height, frames };
|
||
}
|
||
|
||
function normalizeBag(raw: unknown, schema: PropertySchema, sparse: boolean): PropertyBag {
|
||
const r = asRecord(raw);
|
||
const bag: PropertyBag = {};
|
||
for (const def of schema) {
|
||
const value = r[def.key];
|
||
if (value === undefined || value === null) {
|
||
if (!sparse) bag[def.key] = def.default;
|
||
continue;
|
||
}
|
||
bag[def.key] = coerceValue(def, value as PropertyValue);
|
||
}
|
||
return bag;
|
||
}
|
||
|
||
function asRecord(value: unknown): Record<string, unknown> {
|
||
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
|
||
}
|
||
|
||
/**
|
||
* A value from an untyped slot (node params, instance edits). Lists and structs are kept but
|
||
* flattened one level, so nothing deeper than the model allows can enter behind the type's back.
|
||
*/
|
||
function asStoredValue(value: unknown): PropertyValue | undefined {
|
||
if (isPrimitive(value)) return value;
|
||
if (Array.isArray(value)) return value.map(asStoredItem).filter((item) => item !== undefined);
|
||
return asStoredStruct(value, asStoredField);
|
||
}
|
||
|
||
const isPrimitive = (value: unknown): value is number | string | boolean =>
|
||
typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean';
|
||
|
||
/** A primitive, or the one object a flat position may hold: a tagged item. */
|
||
const asFlatItem = (value: unknown): FlatItem | undefined => {
|
||
if (isPrimitive(value)) return value;
|
||
const tag = tagOf(value);
|
||
return tag !== undefined && isTaggedKind(tag) ? makeFlatItem(tag, value as PropertyValue) : undefined;
|
||
};
|
||
|
||
/** A list item: a flat one, or a struct of flat ones. */
|
||
const asStoredItem = (value: unknown): number | string | boolean | StructValue | undefined =>
|
||
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 =>
|
||
Array.isArray(value) ? value.map(asFlatItem).filter((item) => item !== undefined) : asFlatItem(value);
|
||
|
||
function asStoredStruct(
|
||
value: unknown,
|
||
ofField: (item: unknown) => FieldValue | undefined,
|
||
): StructValue | undefined {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||
const fields: Record<string, FieldValue> = {};
|
||
for (const [key, item] of Object.entries(value)) {
|
||
const stored = ofField(item);
|
||
if (stored !== undefined) fields[key] = stored;
|
||
}
|
||
return fields;
|
||
}
|
||
|
||
function toNumber(value: unknown): number {
|
||
const n = Number(value);
|
||
return Number.isFinite(n) ? n : 0;
|
||
}
|