617 lines
25 KiB
TypeScript
617 lines
25 KiB
TypeScript
// Importing the graph package index is what registers the built-in nodes.
|
||
import {
|
||
ANNOTATION_KINDS,
|
||
annotationList,
|
||
createEmptyGraph,
|
||
type DeclaredPort,
|
||
type GraphAnnotation,
|
||
MIN_ANNOTATION_SIZE,
|
||
type DeclaredPortKind,
|
||
type Graph,
|
||
type GraphEdge,
|
||
type GraphNode,
|
||
isValidPortName,
|
||
fixedPortKeys,
|
||
type NodeDef,
|
||
nodeDef,
|
||
STATE,
|
||
} from '../graph';
|
||
import { isParticleNodeDef } from '../particles/nodes';
|
||
import { isShaderNodeDef } from '../shader/nodes';
|
||
import { isKeyInterpolation, normalizeKeys } from '../model/animation';
|
||
import type {
|
||
AnimationKey,
|
||
AnimationTrack,
|
||
Asset,
|
||
StateMachineAsset,
|
||
TextureFrame,
|
||
TilemapLayer,
|
||
} from '../model/Asset';
|
||
import { parseCellKey } from '../model/tilemap';
|
||
import { decodeCells, encodeCells } from './tilemapCells';
|
||
import { isAssetType } from '../model/AssetTypes';
|
||
import { isFontFile } from '../model/font';
|
||
import { isSoundFile } from '../model/sound';
|
||
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
|
||
import {
|
||
type FieldValue,
|
||
type PropertyBag,
|
||
type PropertyValue,
|
||
type StructValue,
|
||
tagOf,
|
||
type Vec2,
|
||
} from '../model/Properties';
|
||
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
|
||
import { readScope } from '../model/Scope';
|
||
import type { SceneObject } from '../model/SceneObject';
|
||
import {
|
||
DEFAULT_FILL,
|
||
isTextureFile,
|
||
normalizeBorders,
|
||
normalizeFrameRect,
|
||
setFrameCollision,
|
||
setFrameSlice,
|
||
} from '../model/texture';
|
||
import { pointsOf } from '../model/polygon';
|
||
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,
|
||
isValidFieldName,
|
||
isValidVariableName,
|
||
type PropertyDef,
|
||
type PropertySchema,
|
||
rowsNotClaimedBy,
|
||
schemaFor,
|
||
type StructFieldDef,
|
||
variableDef,
|
||
} from '../properties/PropertySchema';
|
||
import { particleParamDefs } from '../properties/particles';
|
||
import { SHADER_KEY, shaderParamDefs } from '../properties/shaders';
|
||
|
||
/**
|
||
* 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[]>();
|
||
const rawProps = new Map<string, Record<string, unknown>>();
|
||
const shaderGraphs = new Map<string, Graph>();
|
||
const shaderRows = new Map<string, PropertyDef[]>();
|
||
const particleGraphs = new Map<string, Graph>();
|
||
const wearers = new Map<string, 'sprite' | 'particles'>();
|
||
for (const a of rawAssets) {
|
||
if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue;
|
||
rawProps.set(a.id, asRecord(a.props));
|
||
if (a.type === 'sprite' || a.type === 'particles') wearers.set(a.id, a.type);
|
||
if (a.type === 'behavior' || a.type === 'variables') {
|
||
const vars = normalizeVariables(a.variables);
|
||
variables.set(a.id, vars);
|
||
// The full schema, not just the common rows: this is what pass 2 coerces `props` against, and
|
||
// a behavior's own `runInEditor` would be dropped as undeclared.
|
||
schemas.set(a.id, [...schemaFor(a.type), ...vars]);
|
||
} else if (a.type === 'particles') {
|
||
// A source's rows end with its own graph's parameters, so the graph is needed before the
|
||
// overrides of the objects placed from it can be coerced.
|
||
const graph = normalizeGraph(a.graph, isParticleNodeDef);
|
||
particleGraphs.set(a.id, graph);
|
||
schemas.set(a.id, [...schemaFor('particles'), ...particleParamDefs(graph)]);
|
||
} else {
|
||
if (a.type === 'shader') {
|
||
const graph = normalizeGraph(a.graph, isShaderNodeDef);
|
||
shaderGraphs.set(a.id, graph);
|
||
shaderRows.set(a.id, shaderParamDefs(graph));
|
||
}
|
||
schemas.set(a.id, schemaFor(a.type));
|
||
}
|
||
}
|
||
/**
|
||
* A sprite's or source's rows end with the parameters of the shader its bag names – an override's,
|
||
* else the asset's. A source already carries its own graph's rows, so a name it has stays its own.
|
||
*/
|
||
const schemaOf = (assetId: string, bag: Record<string, unknown>): PropertySchema | undefined => {
|
||
const schema = schemas.get(assetId);
|
||
const wearer = wearers.get(assetId);
|
||
if (!schema || !wearer) return schema;
|
||
const own = bag[SHADER_KEY];
|
||
const shader = typeof own === 'string' ? own : rawProps.get(assetId)?.[SHADER_KEY];
|
||
const rows = shaderRows.get(typeof shader === 'string' ? shader : '') ?? [];
|
||
return [...schema, ...(wearer === 'particles' ? rowsNotClaimedBy(rows, schema) : rows)];
|
||
};
|
||
|
||
// 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 = schemaOf(id, asRecord(a.props));
|
||
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, schemaOf) });
|
||
} else if (type === 'behavior') {
|
||
assets.push({
|
||
id,
|
||
type,
|
||
name,
|
||
props,
|
||
variables: variables.get(id) ?? [],
|
||
graph: normalizeGraph(a.graph),
|
||
});
|
||
} else if (type === 'variables') {
|
||
assets.push({ id, type, name, props, variables: variables.get(id) ?? [] });
|
||
} else if (type === 'statemachine') {
|
||
const machine: StateMachineAsset = {
|
||
id,
|
||
type,
|
||
name,
|
||
props,
|
||
graph: normalizeGraph(a.graph, (def) => def.type === STATE),
|
||
};
|
||
if (readScope(a.scope) === 'global') machine.scope = 'global';
|
||
// Kept as written even where it names no state any more: the same as a deleted start.
|
||
if (typeof a.start === 'string' && a.start !== '') machine.start = a.start;
|
||
assets.push(machine);
|
||
} else if (type === 'shader') {
|
||
assets.push({ id, type, name, props, graph: shaderGraphs.get(id) ?? createEmptyGraph() });
|
||
} else if (type === 'particles') {
|
||
assets.push({ id, type, name, props, graph: particleGraphs.get(id) ?? createEmptyGraph() });
|
||
} else if (type === 'texture') {
|
||
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
|
||
} else if (type === 'font') {
|
||
const file = typeof a.file === 'string' && isFontFile(a.file) ? a.file : '';
|
||
assets.push({ id, type, name, props, file });
|
||
} else if (type === 'sound') {
|
||
const file = typeof a.file === 'string' && isSoundFile(a.file) ? a.file : '';
|
||
assets.push({ id, type, name, props, file });
|
||
} else if (type === 'tile') {
|
||
// A picture naming nothing is kept, like an animation's dangling root: it reads as missing
|
||
// in the tab and comes back when the texture it named is restored by an undo.
|
||
const pictures = (Array.isArray(a.pictures) ? a.pictures : []).filter(
|
||
(id): id is string => typeof id === 'string' && id !== '',
|
||
);
|
||
assets.push({ id, type, name, props, pictures });
|
||
} else if (type === 'tilemap') {
|
||
assets.push({ id, type, name, props, layers: normalizeLayers(a.layers) });
|
||
} else if (type === 'animation') {
|
||
// A root or row aiming at something gone is kept: it reads as missing and keeps its keys.
|
||
const root = typeof a.root === 'string' ? a.root : '';
|
||
assets.push({ id, type, name, props, root, tracks: normalizeTracks(a.tracks) });
|
||
} 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 : '';
|
||
}
|
||
|
||
// A dangling start scene reads as none at all – the run then falls back to the active scene,
|
||
// which is also what every project written before there was such a field does.
|
||
const startSceneId =
|
||
typeof r.startSceneId === 'string' && assets.some((a) => a.type === 'scene' && a.id === r.startSceneId)
|
||
? r.startSceneId
|
||
: '';
|
||
|
||
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,
|
||
startSceneId,
|
||
};
|
||
return project;
|
||
}
|
||
|
||
/**
|
||
* Plain JSON-ready copy of the project (deep clone, stable key order for readable diffs). The cells
|
||
* of a tilemap are the one thing written differently from how they are held: see `tilemapCells.ts`.
|
||
*/
|
||
export function serializeProject(project: Project): unknown {
|
||
const copy = JSON.parse(JSON.stringify(project)) as Project;
|
||
for (const asset of copy.assets)
|
||
if (asset.type === 'tilemap')
|
||
(asset as { layers: unknown[] }).layers = asset.layers.map(({ cells, ...layer }) => ({
|
||
...layer,
|
||
...encodeCells(cells),
|
||
}));
|
||
return copy;
|
||
}
|
||
|
||
function normalizeObjects(
|
||
raw: unknown,
|
||
schemaOf: (assetId: string, overrides: Record<string, unknown>) => PropertySchema | undefined,
|
||
): 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 = schemaOf(o.assetId, asRecord(o.overrides));
|
||
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;
|
||
}
|
||
|
||
/** `keep` restricts the node types kept: a state machine's graph holds states, a shader's shader nodes, and nothing else. */
|
||
function normalizeGraph(raw: unknown, keep?: (def: NodeDef) => boolean): 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 || (keep !== undefined && !keep(def))) 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;
|
||
const label = typeof e.label === 'string' ? e.label.trim() : '';
|
||
if (label) edge.label = label;
|
||
graph.edges.push(edge);
|
||
}
|
||
for (const kind of ANNOTATION_KINDS) {
|
||
const list = normalizeAnnotations(g[kind === 'group' ? 'groups' : 'notes'], nodeIds);
|
||
if (list.length > 0) annotationList(graph, kind).push(...list);
|
||
}
|
||
return graph;
|
||
}
|
||
|
||
/**
|
||
* Annotations share the id space with nodes and with each other: a duplicate would make one
|
||
* selection mean two boxes. A size below the minimum is raised rather than dropped – a box nobody
|
||
* can see is worse than one that is a little larger than it was written.
|
||
*/
|
||
function normalizeAnnotations(raw: unknown, takenIds: Set<string>): GraphAnnotation[] {
|
||
const list: GraphAnnotation[] = [];
|
||
for (const item of Array.isArray(raw) ? raw : []) {
|
||
const a = asRecord(item);
|
||
const id = typeof a.id === 'string' ? a.id : '';
|
||
if (!id || takenIds.has(id)) continue;
|
||
takenIds.add(id);
|
||
const annotation: GraphAnnotation = {
|
||
id,
|
||
x: toNumber(a.x),
|
||
y: toNumber(a.y),
|
||
w: Math.max(toNumber(a.w), MIN_ANNOTATION_SIZE.w),
|
||
h: Math.max(toNumber(a.h), MIN_ANNOTATION_SIZE.h),
|
||
text: typeof a.text === 'string' ? a.text : '',
|
||
};
|
||
if (typeof a.color === 'number' && Number.isFinite(a.color))
|
||
annotation.color = Math.max(0, Math.min(0xffffff, Math.floor(a.color)));
|
||
list.push(annotation);
|
||
}
|
||
return list;
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
);
|
||
const frame: TextureFrame = { id, name: typeof f.name === 'string' ? f.name : '', ...rect };
|
||
// Against the frame, not the image: the borders are distances inside the cut-out. A frame
|
||
// without them is one nobody sliced, and keeps saying so.
|
||
const border = asRecord(f.border);
|
||
setFrameSlice(frame, {
|
||
border: normalizeBorders(
|
||
{
|
||
left: toNumber(border.left),
|
||
top: toNumber(border.top),
|
||
right: toNumber(border.right),
|
||
bottom: toNumber(border.bottom),
|
||
},
|
||
rect.w,
|
||
rect.h,
|
||
),
|
||
fill: f.fill === 'stretch' ? 'stretch' : DEFAULT_FILL,
|
||
});
|
||
// Corners that are no numbers fall away; what is left is kept only as an outline (#141).
|
||
const outlines = collisionOutlines(f.collision).filter((outline) => outline.length >= 3);
|
||
if (outlines.length > 0) setFrameCollision(frame, outlines);
|
||
frames.push(frame);
|
||
}
|
||
return { file, width, height, frames };
|
||
}
|
||
|
||
/**
|
||
* A frame's stored outlines: a list of corner lists, or – as saved before a frame held several –
|
||
* one corner list, which is one outline.
|
||
*/
|
||
function collisionOutlines(raw: unknown): Vec2[][] {
|
||
if (!Array.isArray(raw)) return [];
|
||
if (raw.some((item) => !Array.isArray(item))) return [pointsOf(raw as PropertyValue)];
|
||
return raw.map((outline) => pointsOf(outline as PropertyValue));
|
||
}
|
||
|
||
/**
|
||
* The rows of an animation. Keys hold flat values only – a row animates one property – and are
|
||
* sorted onto whole frames; a second row on the same target and property, or with an id already
|
||
* taken, is dropped like a doubled node.
|
||
*/
|
||
function normalizeTracks(raw: unknown): AnimationTrack[] {
|
||
const tracks: AnimationTrack[] = [];
|
||
for (const item of Array.isArray(raw) ? raw : []) {
|
||
const t = asRecord(item);
|
||
if (typeof t.id !== 'string' || !t.id || typeof t.property !== 'string' || !t.property) continue;
|
||
const target = typeof t.target === 'string' ? t.target : '';
|
||
if (tracks.some((o) => o.id === t.id || (o.target === target && o.property === t.property))) continue;
|
||
const keys: AnimationKey[] = [];
|
||
for (const k of Array.isArray(t.keys) ? t.keys : []) {
|
||
const key = asRecord(k);
|
||
const frame = Number(key.frame);
|
||
if (!Number.isFinite(frame) || !isPrimitive(key.value)) continue;
|
||
const kept: AnimationKey = { frame, value: key.value };
|
||
// `linear` is what no field means, so it is not kept; an unknown one reads as linear, too.
|
||
if (isKeyInterpolation(key.interpolation) && key.interpolation !== 'linear')
|
||
kept.interpolation = key.interpolation;
|
||
keys.push(kept);
|
||
}
|
||
const track: AnimationTrack = { id: t.id, target, property: t.property, keys: normalizeKeys(keys) };
|
||
if (t.relative === true) track.relative = true;
|
||
tracks.push(track);
|
||
}
|
||
return tracks;
|
||
}
|
||
|
||
/**
|
||
* The layers of a tilemap. A cell whose key is not a pair of whole numbers, or which names nothing,
|
||
* is dropped; one naming a deleted tile is kept, like an animation's dangling row, so an undo
|
||
* brings the drawing back. A doubled layer id is dropped as a doubled node is.
|
||
*/
|
||
function normalizeLayers(raw: unknown): TilemapLayer[] {
|
||
const layers: TilemapLayer[] = [];
|
||
for (const item of Array.isArray(raw) ? raw : []) {
|
||
const l = asRecord(item);
|
||
if (typeof l.id !== 'string' || !l.id || layers.some((o) => o.id === l.id)) continue;
|
||
const stored = decodeCells(l);
|
||
const cells: Record<string, string> = {};
|
||
for (const [key, value] of Object.entries(stored))
|
||
if (parseCellKey(key) && typeof value === 'string' && value !== '') cells[key] = value;
|
||
const layer: TilemapLayer = { id: l.id, name: typeof l.name === 'string' ? l.name : 'Layer', cells };
|
||
if (l.hidden === true) layer.hidden = true;
|
||
layers.push(layer);
|
||
}
|
||
return layers;
|
||
}
|
||
|
||
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;
|
||
}
|