A composition can now sit in a scene like a container and play there. Its items become real objects while they run, so behaviors, colliders and events work on them as on any other object. - A composition is placeable with its own symbol, transform and render layer. The scene view shows its picture at frame 0. A click on that picture selects the placement, not a single item. - CompositionSystem spawns each item as an object under the placement when it begins, with the id <placement>~<item>. On start and the other events run. The object is destroyed when the item ends. - The top track lies in front no matter which item started first. World.arrange() orders the entity list and the scene objects. - Clips and driven videos follow the same rules as compositionAt(). Loop, restart, stop and the game pause work. - New nodes: Play composition (with restart), Stop composition and the event On composition finished. They were planned for step 4 but come now, because without autoplay nothing could play otherwise. - The editor run ignores Play composition and says so in the log. It writes into the saved project, and the items would stay behind. - contentId() moved to instance.ts. The animation system uses it for nested ids too. - Compositions get no "Sort with parent" for now. Tests: the gate passed with 301 files and 4643 tests, 18 more than step 1. 13 of them are in tests/core/composition-play.test.ts. The first gate run failed in event-order.test.ts, which counts the event nodes and now finds 19. Five counter-checks each turned exactly their test red. Two order tests stayed green without the fix at first and were rebuilt. e2e 68 is new and passed with 5 own checks. Its counter-check without arrange() failed as expected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
488 lines
18 KiB
TypeScript
488 lines
18 KiB
TypeScript
/**
|
||
* 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 `<instance>~<path>` 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<Record<Axis, number>>;
|
||
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<SceneObject, ContainerAsset> {
|
||
const parents = new Set(holder.objects.map((o) => o.parentId));
|
||
const live = new Map<SceneObject, ContainerAsset>();
|
||
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;
|
||
}
|