// 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 { pluginsOf } from './projectPlugins'; 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(); const variables = new Map(); const rawProps = new Map>(); const shaderGraphs = new Map(); const shaderRows = new Map(); const particleGraphs = new Map(); const wearers = new Map(); 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): 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, // The same reading the editor did before it loaded the plugins (#119), so the model says what // is actually running. plugins: pluginsOf(r), }; 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) => 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 { const edits: Record = {}; 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(); 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(); 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): 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(); 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(); 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): 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, takenIds: Set, ): { 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 = {}; 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; if (typeof l.z === 'number' && Number.isFinite(l.z) && l.z !== 0) layer.z = l.z; 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 { return value && typeof value === 'object' ? (value as Record) : {}; } /** * 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 = {}; 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; }