/** * A placed container's content is resolved from its template on every read. Per-instance changes live * in sparse `instanceEdits` keyed by template-id path; derived ids `~` are never stored. */ import type { ContainerAsset } from './Asset'; import { groupedOrder, type ObjectTree } from './hierarchy'; import { type Axis, type Position, type PropertyBag, type PropertyValue, sameValue, setBagValue, type Vec2, } from './Properties'; import { findAssetOf, type ObjectHolder, objectHolders, type Project } from './Project'; import type { SceneObject } from './SceneObject'; /** Neither character can occur inside an id (see `newId`), so a derived id parses back uniquely. */ const ID_SEP = '~'; const PATH_SEP = '/'; /** A container inside a container inside … – the same guard `instantiate` needs against cycles. */ const MAX_NESTING = 8; const AXES: readonly Axis[] = ['x', 'y', 'z']; /** Sparse deviations of one resolved object; everything absent follows the template. */ export interface InstanceEdit { name?: string; position: Partial>; editorOffset?: Vec2; overrides: PropertyBag; } export function emptyEdit(): InstanceEdit { return { position: {}, overrides: {} }; } export function isEmptyEdit(edit: InstanceEdit): boolean { return ( edit.name === undefined && edit.editorOffset === undefined && Object.keys(edit.position).length === 0 && Object.keys(edit.overrides).length === 0 ); } function copyEdit(edit: InstanceEdit): InstanceEdit { return { ...(edit.name === undefined ? {} : { name: edit.name }), ...(edit.editorOffset === undefined ? {} : { editorOffset: { ...edit.editorOffset } }), position: { ...edit.position }, overrides: { ...edit.overrides }, }; } /** The outer edit wins per field and per key; anything it leaves out keeps the inner value. */ function mergeEdits( inner: InstanceEdit | undefined, outer: InstanceEdit | undefined, ): InstanceEdit | undefined { if (!inner) return outer; if (!outer) return inner; return { name: outer.name ?? inner.name, editorOffset: outer.editorOffset ?? inner.editorOffset, position: { ...inner.position, ...outer.position }, overrides: { ...inner.overrides, ...outer.overrides }, }; } function containerAssetOf(project: Project, object: SceneObject): ContainerAsset | undefined { return findAssetOf(project, object.assetId, 'container'); } /** * The placed containers still showing their template, with the asset behind each. Content spelled * out in the holder means the instance was unpacked (or baked for a play run) and is an ordinary * parent from then on. */ function liveInstances(project: Project, holder: ObjectTree): Map { const parents = new Set(holder.objects.map((o) => o.parentId)); const live = new Map(); for (const object of holder.objects) { if (parents.has(object.id)) continue; const asset = containerAssetOf(project, object); if (asset) live.set(object, asset); } return live; } export function isLiveInstance(project: Project, holder: ObjectTree, object: SceneObject): boolean { return liveInstances(project, holder).has(object); } export function instanceChildId(instanceId: string, path: string): string { return `${instanceId}${ID_SEP}${path}`; } /** Splits a derived id back into the instance it belongs to and the template path below it. */ export function splitInstanceId(id: string): { instanceId: string; path: string } | undefined { const at = id.indexOf(ID_SEP); if (at < 0) return undefined; return { instanceId: id.slice(0, at), path: id.slice(at + 1) }; } /** The template path an id of a holder's own view stands for once the holder is placed: `q~x` → `q/x`. */ export function pathOfId(id: string): string { return id.replace(ID_SEP, PATH_SEP); } /** The id a holder's own view gives the object at a template path: `q/x` → `q~x`. */ export function idOfPath(path: string): string { return path.replace(PATH_SEP, ID_SEP); } /** * Whether the object is only resolved from a template rather than stored in the holder. The * question is where it lives, not what its id looks like: unpacking keeps the derived id – so the * selection and the running game survive it – and the object is an ordinary one from then on. */ export function isTemplateContent(holder: ObjectTree, objectId: string): boolean { return !holder.objects.some((o) => o.id === objectId); } function resolveChild( template: SceneObject, edit: InstanceEdit | undefined, id: string, parentId: string, ): SceneObject { const position = { ...template.position }; for (const axis of AXES) { const own = edit?.position[axis]; if (own !== undefined) position[axis] = own; } return { id, assetId: template.assetId, name: edit?.name ?? template.name, position, editorOffset: edit?.editorOffset ? { ...edit.editorOffset } : { x: 0, y: 0 }, overrides: { ...template.overrides, ...edit?.overrides }, parentId, instanceEdits: {}, }; } function expand( project: Project, asset: ContainerAsset, instanceId: string, prefix: string, editAt: (path: string) => InstanceEdit | undefined, rootParentId: string, depth: number, out: SceneObject[], ): void { // In the order the template lists its top level, groups together, so equal `z` ties the same way. for (const template of groupedOrder(asset.objects)) { const path = prefix ? `${prefix}${PATH_SEP}${template.id}` : template.id; const id = instanceChildId(instanceId, path); const parentId = template.parentId ? instanceChildId(instanceId, prefix ? `${prefix}${PATH_SEP}${template.parentId}` : template.parentId) : rootParentId; out.push(resolveChild(template, editAt(path), id, parentId)); const nested = containerAssetOf(project, template); const spelledOut = asset.objects.some((o) => o.parentId === template.id); if (!nested || spelledOut || depth >= MAX_NESTING) continue; // The template object's own edits are the base, the outer instance's edits sit on top of them. // `editAt` always takes the full path; the base is keyed relative to the nested container. const own = template.instanceEdits; const below = (full: string) => mergeEdits(own[full.slice(path.length + 1)], editAt(full)); expand(project, nested, instanceId, path, below, id, depth + 1, out); } } /** * The holder's objects with the content of every live container instance woven in. Stored objects * are passed through by reference – only instance content is freshly built and must not be written * to (see `objectRef`). Without a live instance the stored list is returned unchanged. */ export function resolveObjects(project: Project, holder: ObjectTree): SceneObject[] { const live = liveInstances(project, holder); if (live.size === 0) return holder.objects; const out: SceneObject[] = []; for (const object of holder.objects) { out.push(object); const asset = live.get(object); if (asset) expand(project, asset, object.id, '', (p) => object.instanceEdits[p], object.id, 0, out); } return out; } /** The holder with its resolved objects – a read-only view for anything that walks the tree. */ export function resolvedHolder(project: Project, holder: ObjectHolder): ObjectHolder { const objects = resolveObjects(project, holder); return objects === holder.objects ? holder : { ...holder, objects }; } export interface InstanceTarget { /** The stored container object the edits live on. */ instance: SceneObject; path: string; /** The container asset that declares `template` – the one to open to change it for good. */ asset: ContainerAsset; template: SceneObject; /** * The template with the edits of every container in between woven in – this, not `template`, decides * whether an edit deviates. Built fresh on every read, since "apply" writes into `template` first. */ readonly base: SceneObject; } /** Where a derived id points: the instance holding the edits, and the template object behind it. */ export function instanceTargetOf( project: Project, holder: ObjectTree, objectId: string, ): InstanceTarget | undefined { if (!isTemplateContent(holder, objectId)) return undefined; const split = splitInstanceId(objectId); const instance = split && holder.objects.find((o) => o.id === split.instanceId); if (!split || !instance) return undefined; const segments = split.path.split(PATH_SEP); let asset = containerAssetOf(project, instance); let template: SceneObject | undefined; // Each container on the way holds edits for what lies below it, keyed relative to itself. const between: { holder: SceneObject; key: string }[] = []; for (const [index, segment] of segments.entries()) { const next = template ? containerAssetOf(project, template) : asset; if (!next) return undefined; asset = next; template = next.objects.find((t) => t.id === segment); if (!template) return undefined; if (index < segments.length - 1) between.push({ holder: template, key: segments.slice(index + 1).join(PATH_SEP) }); } if (!asset || !template) return undefined; const leaf = template; return { instance, path: split.path, asset, template: leaf, get base() { let edit: InstanceEdit | undefined; // Outermost wins, as in `expand`: it sits closest to the instance being edited. for (let i = between.length - 1; i >= 0; i--) edit = mergeEdits(edit, between[i]!.holder.instanceEdits[between[i]!.key]); return resolveChild(leaf, edit, leaf.id, leaf.parentId); }, }; } /** * Read/write access to a placed object, whether it is stored in the holder or resolved from a * container template. `object` is the current state either way; the setters keep an instance edit * to exactly what deviates from the template, so a value put back to the template's follows again. */ export interface ObjectRef { readonly object: SceneObject; /** The instance edits belong to, undefined for a stored object. */ readonly target: InstanceTarget | undefined; setName(name: string): void; setPosition(position: Position): void; setEditorOffset(offset: Vec2): void; setOverride(key: string, value: PropertyValue | undefined): void; } function editOf(target: InstanceTarget): InstanceEdit { const existing = target.instance.instanceEdits[target.path]; if (existing) return existing; const edit = emptyEdit(); target.instance.instanceEdits[target.path] = edit; return edit; } function prune(target: InstanceTarget): void { const edit = target.instance.instanceEdits[target.path]; if (edit && isEmptyEdit(edit)) delete target.instance.instanceEdits[target.path]; } function instanceRef(target: InstanceTarget, object: SceneObject): ObjectRef { const write = (mutate: (edit: InstanceEdit) => void): void => { mutate(editOf(target)); prune(target); }; return { object, target, setName: (name) => write((edit) => { if (name === target.base.name) delete edit.name; else edit.name = name; }), setPosition: (position) => write((edit) => { for (const axis of AXES) { if (position[axis] === target.base.position[axis]) delete edit.position[axis]; else edit.position[axis] = position[axis]; } }), setEditorOffset: (offset) => write((edit) => { if (offset.x === 0 && offset.y === 0) delete edit.editorOffset; else edit.editorOffset = { x: offset.x, y: offset.y }; }), setOverride: (key, value) => write((edit) => { if (sameValue(value, target.base.overrides[key])) delete edit.overrides[key]; else setBagValue(edit.overrides, key, value); }), }; } function storedRef(object: SceneObject): ObjectRef { return { object, target: undefined, setName: (name) => { object.name = name; }, setPosition: (position) => { object.position = { ...position }; }, setEditorOffset: (offset) => { object.editorOffset = { x: offset.x, y: offset.y }; }, setOverride: (key, value) => setBagValue(object.overrides, key, value), }; } export function objectRef(project: Project, holder: ObjectTree, objectId: string): ObjectRef | undefined { const stored = holder.objects.find((o) => o.id === objectId); if (stored) return storedRef(stored); const target = instanceTargetOf(project, holder, objectId); const object = resolveObjects(project, holder).find((o) => o.id === objectId); return target && object ? instanceRef(target, object) : undefined; } /** Everything a value edit can change, snapshotted so a command can put it back exactly. */ export type ObjectMemo = | { kind: 'stored'; object: SceneObject; name: string; position: Position; editorOffset: Vec2; overrides: PropertyBag; } | { kind: 'edit'; instance: SceneObject; path: string; edit: InstanceEdit | undefined }; export function captureObject(ref: ObjectRef): ObjectMemo { if (ref.target) { const edit = ref.target.instance.instanceEdits[ref.target.path]; return { kind: 'edit', instance: ref.target.instance, path: ref.target.path, edit: edit && copyEdit(edit), }; } const { object } = ref; return { kind: 'stored', object, name: object.name, position: { ...object.position }, editorOffset: { ...object.editorOffset }, overrides: { ...object.overrides }, }; } export function restoreObject(memo: ObjectMemo): void { if (memo.kind === 'edit') { if (memo.edit) memo.instance.instanceEdits[memo.path] = copyEdit(memo.edit); else delete memo.instance.instanceEdits[memo.path]; return; } memo.object.name = memo.name; memo.object.position = { ...memo.position }; memo.object.editorOffset = { ...memo.editorOffset }; memo.object.overrides = { ...memo.overrides }; } /** * One sparse override bag with the asset whose schema governs it: a placed object's own, or a container * instance's edits for its content. Project-wide rewrites must cover both or they miss container content. */ export interface OverrideSite { holder: ObjectHolder; /** The stored object the bag lives on – the placed object itself, or the container instance. */ object: SceneObject; /** Template path inside `object`; empty when the bag is the object's own overrides. */ path: string; assetId: string; bag: PropertyBag; } export function overrideSites(project: Project, wanted: (assetId: string) => boolean): OverrideSite[] { const sites: OverrideSite[] = []; for (const holder of objectHolders(project)) { for (const object of holder.objects) { if (wanted(object.assetId)) sites.push({ holder, object, path: '', assetId: object.assetId, bag: object.overrides }); for (const [path, edit] of Object.entries(object.instanceEdits)) { const target = instanceTargetOf(project, holder, instanceChildId(object.id, path)); if (target && wanted(target.template.assetId)) sites.push({ holder, object, path, assetId: target.template.assetId, bag: edit.overrides }); } } } return sites; } /** * The resolved content of an instance as objects that can be stored. The derived ids are kept: * they are unique already, and anything pointing at one – the selection, a running game, a check * script – keeps working across the unpack. */ export function materializeInstance( project: Project, holder: ObjectTree, instance: SceneObject, ): SceneObject[] { return resolveObjects(project, holder) .filter((o) => splitInstanceId(o.id)?.instanceId === instance.id) .map((o) => ({ ...o, position: { ...o.position }, editorOffset: { ...o.editorOffset }, overrides: { ...o.overrides }, instanceEdits: {}, })); } /** * The id the object at `path` below `ownerId` has – one level deeper when the owner is content * itself, so the content of `q~a` is `q~a/b`, the id a template path through nested containers gives. */ export function contentId(ownerId: string, path: string): string { const split = splitInstanceId(ownerId); return split ? instanceChildId(split.instanceId, `${split.path}${PATH_SEP}${path}`) : instanceChildId(ownerId, path); } /** * `object` as `id` below `parentId`, followed by its container content with the ids below that one – * how a composition puts its objects into a scene (#417). Copies, free to be stored and written to. */ export function placedWithContent( project: Project, object: SceneObject, id: string, parentId: string, ): SceneObject[] { const rename = (at: string) => (at === object.id ? id : contentId(id, at.slice(object.id.length + 1))); const { groupId: _, ...top } = object; return resolveObjects(project, { objects: [{ ...top, parentId: '' }] }).map((o) => ({ ...o, id: rename(o.id), parentId: o.id === object.id ? parentId : rename(o.parentId), position: { ...o.position }, editorOffset: { ...o.editorOffset }, overrides: { ...o.overrides }, instanceEdits: {}, })); } /** * Spells out every live instance of the holder. The runtime needs it: entities are built from * stored objects, and a behavior may destroy or move one of them – which a resolved copy could * not remember. Returns true when something was written. */ export function bakeInstances(project: Project, holder: ObjectTree): boolean { const parents = new Set(holder.objects.map((o) => o.parentId)); const live = holder.objects.filter((o) => !parents.has(o.id) && containerAssetOf(project, o) !== undefined); if (live.length === 0) return false; for (const instance of live) { holder.objects.push(...materializeInstance(project, holder, instance)); instance.instanceEdits = {}; } return true; }