diff --git a/README.md b/README.md
index 26f70c0c..eaeacedc 100644
--- a/README.md
+++ b/README.md
@@ -15,13 +15,14 @@ Live: · Widget playground: ~`, joined by
+`/` for a container inside a container. That is what the hierarchy lists, what the viewport draws
+and what a command addresses. A container that reaches this one, directly or through another, is
+still refused – that cycle would resolve forever.
-Editing a template **moves the copies already placed** with it, by the same rule **Apply to
-container** follows: a copy that still holds the template's old value takes the new one, a copy
-changed on its own keeps what it has – per override key and per position axis, so a copy dragged
-sideways still follows a template change in `y`. This covers what a template object *is* (position,
-name, overrides); what a template *contains* is read at placement time only, so adding, deleting or
-re-parenting a template object decides what the next placement looks like and leaves the existing
-copies as they are.
+So **editing a template changes every instance**, structure included: add, delete or re-parent a
+template object and every placed container shows it immediately. What an instance may deviate in
+is values, and only per slot: an override key, one position axis, the name. Those live in
+`instanceEdits` on the container object and are dropped again the moment the value matches the
+template, which is what keeps "untouched follows, touched keeps" exact without any bookkeeping.
+
+The other direction is therefore just as small: **Apply to container** writes the value into the
+template and drops the edit, **Reset to container** drops the edit alone.
+
+Two places do spell the content out. **Unpack** does it on purpose – the objects become ordinary
+ones and stop following. And **play mode bakes** every placed container into stored objects, in
+the throwaway copy of the model, because a running behavior may move or destroy one of them and a
+resolved object has nowhere to remember that. Both keep the derived ids, so the selection and a
+check script survive them.
## Screens without scene switching
@@ -337,9 +346,9 @@ selected and switched back on.
```
web/index.html Vite source page (dev) index.html built page (generated, served)
-src/core/model/ Project (object holders), Asset, SceneObject, hierarchy, walk (generic parent/child walks), container templates
+src/core/model/ Project (object holders), Asset, SceneObject, hierarchy, walk (generic parent/child walks), instance (container content resolved from its template)
src/core/properties/ PropertySchema, resolve (the only override merge), collider/camera/text geometry, enabled
-src/core/commands/ undoable commands, CommandStack, SetBagValueCommand base, templateSync.ts (which copies follow a template), require.ts (validated lookups), naming.ts
+src/core/commands/ undoable commands, CommandStack, SetBagValueCommand base, require.ts (validated lookups, and what an instance may not change), naming.ts
src/core/graph/ graph model, NodeRegistry/NodeContext/values, interpreter; nodes//.ts
src/core/store/ serialize (untrusted JSON → Project), HttpProjectStore
src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, BehaviorSystem
@@ -407,7 +416,7 @@ and share the CDP driver in `tools/lib/harness.mjs` – `press`, `undo`, `play`,
platformer built only from assets, containers and graphs: a title screen with a clickable Start
button, a level of boxes with ten coins, gravity and jumping, a two-minute clock in the HUD, and
a win / lose screen with "Play again". A coin is a container of its own (sprite plus trigger
-collider) placed ten times inside the level container – nested containers expand on placement,
+collider) placed ten times inside the level container – nested containers resolve through both levels,
and picking one up destroys the container, not just the sprite the collider reported. `tools/smoke-jumprun.mjs` plays it in a headless browser
(start, jump, collect, win, restart, lose) – it is the regression test for everything screens,
text, mouse and platformer physics need.
diff --git a/src/core/commands/applyCommands.ts b/src/core/commands/applyCommands.ts
index 10c4cb4e..f3c08322 100644
--- a/src/core/commands/applyCommands.ts
+++ b/src/core/commands/applyCommands.ts
@@ -1,62 +1,40 @@
/**
* "Apply" is the opposite direction of an override: a value changed on a placed object is written
- * back to the asset, or to the template of the container it is part of. Prefab semantics for the
- * other copies: those still at the template's value follow, those modified on their own do not.
+ * back to the asset, or to the template of the container it belongs to. Reverting drops the
+ * instance's deviation again, which is all it takes for the value to follow the template – the
+ * content of a placed container is resolved from it, never copied (see `model/instance.ts`).
*/
-import { type TemplateLink, templateLinkOf } from '../model/container';
+import {
+ captureObject,
+ type InstanceTarget,
+ instanceTargetOf,
+ type ObjectMemo,
+ restoreObject,
+} from '../model/instance';
import { type Axis, type Position, type PropertyValue, setBagValue } from '../model/Properties';
-import { findObjectHolder, type Project } from '../model/Project';
-import type { SceneObject } from '../model/SceneObject';
+import type { Project } from '../model/Project';
import { resolveSceneObjectProps } from '../properties/resolve';
import { type Command, CommandError } from './Command';
-import { requireAsset, requireObject, requirePropDef } from './require';
-import {
- ALL_AXES,
- overrideSlot,
- positionSlot,
- restoreSlots,
- type Slot,
- type SlotMemo,
- syncInstances,
-} from './templateSync';
+import { requireAsset, requireHolder, requireObjectRef, requirePropDef } from './require';
-function requireLink(project: Project, holderId: string, object: SceneObject): TemplateLink {
- const holder = findObjectHolder(project, holderId);
- const link = holder && templateLinkOf(project, holder, object);
- if (!link) throw new CommandError(`${object.name || object.id} is not part of a placed container`);
- return link;
+const ALL_AXES: readonly Axis[] = ['x', 'y', 'z'];
+
+function requireTarget(project: Project, holderId: string, objectId: string): InstanceTarget {
+ const target = instanceTargetOf(project, requireHolder(project, holderId), objectId);
+ if (!target) throw new CommandError('That object is not part of a placed container');
+ return target;
}
/**
- * Writes `values` into the template's slots and carries along every instance that still agreed with
- * it. Returns the memos in application order; `restoreSlots` undoes them.
- */
-function applyToTemplate(
- project: Project,
- link: TemplateLink,
- slots: readonly Slot[],
- values: readonly T[],
-): SlotMemo[] {
- const before = slots.map((slot) => slot.read(link.template));
- const memos: SlotMemo[] = slots.map((slot, i) => ({
- object: link.template,
- slot,
- previous: before[i]!,
- }));
- memos.push(...syncInstances(project, link.template, slots, before, values));
- slots.forEach((slot, i) => slot.write(link.template, values[i]!));
- return memos;
-}
-
-/**
- * Writes the object's value for `key` into its asset and drops the override. For a container
- * instance the template's override is dropped too, along with the instances that shared it.
+ * Writes the object's value for `key` into its asset and drops the override. For content of a
+ * placed container the template's override goes too, so every instance follows the asset again.
*/
export class ApplyOverrideToAssetCommand implements Command {
readonly label: string;
private previousAssetValue: PropertyValue | undefined;
- private previousOverride: PropertyValue | undefined;
- private templateMemos: SlotMemo[] = [];
+ private previousTemplateValue: PropertyValue | undefined;
+ private memo: ObjectMemo | undefined;
+ private template: InstanceTarget | undefined;
constructor(
private readonly holderId: string,
@@ -67,31 +45,36 @@ export class ApplyOverrideToAssetCommand implements Command {
}
execute(project: Project): void {
- const holder = findObjectHolder(project, this.holderId);
- const object = requireObject(project, this.holderId, this.objectId);
- const asset = requireAsset(project, object.assetId);
+ const ref = requireObjectRef(project, this.holderId, this.objectId);
+ const asset = requireAsset(project, ref.object.assetId);
requirePropDef(asset, this.key);
- const value = resolveSceneObjectProps(asset, object)[this.key];
+ const value = resolveSceneObjectProps(asset, ref.object)[this.key];
this.previousAssetValue = asset.props[this.key];
- this.previousOverride = object.overrides[this.key];
+ this.memo = captureObject(ref);
asset.props[this.key] = value!;
- delete object.overrides[this.key];
- const link = holder && templateLinkOf(project, holder, object);
- this.templateMemos = link ? applyToTemplate(project, link, [overrideSlot(this.key)], [undefined]) : [];
+ ref.setOverride(this.key, undefined);
+ this.template = ref.target;
+ if (!this.template) return;
+ this.previousTemplateValue = this.template.template.overrides[this.key];
+ delete this.template.template.overrides[this.key];
}
undo(project: Project): void {
- const object = requireObject(project, this.holderId, this.objectId);
- const asset = requireAsset(project, object.assetId);
- restoreSlots(this.templateMemos);
- setBagValue(object.overrides, this.key, this.previousOverride);
+ const asset = requireAsset(
+ project,
+ requireObjectRef(project, this.holderId, this.objectId).object.assetId,
+ );
+ if (this.template) setBagValue(this.template.template.overrides, this.key, this.previousTemplateValue);
+ if (this.memo) restoreObject(this.memo);
setBagValue(asset.props, this.key, this.previousAssetValue);
}
}
export class ApplyOverrideToContainerCommand implements Command {
readonly label: string;
- private memos: SlotMemo[] = [];
+ private previous: PropertyValue | undefined;
+ private memo: ObjectMemo | undefined;
+ private target: InstanceTarget | undefined;
constructor(
private readonly holderId: string,
@@ -102,19 +85,25 @@ export class ApplyOverrideToContainerCommand implements Command {
}
execute(project: Project): void {
- const object = requireObject(project, this.holderId, this.objectId);
- const link = requireLink(project, this.holderId, object);
- this.memos = applyToTemplate(project, link, [overrideSlot(this.key)], [object.overrides[this.key]]);
+ const target = requireTarget(project, this.holderId, this.objectId);
+ const ref = requireObjectRef(project, this.holderId, this.objectId);
+ this.target = target;
+ this.memo = captureObject(ref);
+ this.previous = target.template.overrides[this.key];
+ setBagValue(target.template.overrides, this.key, ref.object.overrides[this.key]);
+ // The deviation is gone the moment the template carries it, so the edit must go with it.
+ ref.setOverride(this.key, target.template.overrides[this.key]);
}
undo(): void {
- restoreSlots(this.memos);
+ if (this.target) setBagValue(this.target.template.overrides, this.key, this.previous);
+ if (this.memo) restoreObject(this.memo);
}
}
export class RevertOverrideToTemplateCommand implements Command {
readonly label: string;
- private previous: PropertyValue | undefined;
+ private memo: ObjectMemo | undefined;
constructor(
private readonly holderId: string,
@@ -125,21 +114,23 @@ export class RevertOverrideToTemplateCommand implements Command {
}
execute(project: Project): void {
- const object = requireObject(project, this.holderId, this.objectId);
- const link = requireLink(project, this.holderId, object);
- this.previous = object.overrides[this.key];
- setBagValue(object.overrides, this.key, link.template.overrides[this.key]);
+ const target = requireTarget(project, this.holderId, this.objectId);
+ const ref = requireObjectRef(project, this.holderId, this.objectId);
+ this.memo = captureObject(ref);
+ ref.setOverride(this.key, target.template.overrides[this.key]);
}
- undo(project: Project): void {
- setBagValue(requireObject(project, this.holderId, this.objectId).overrides, this.key, this.previous);
+ undo(): void {
+ if (this.memo) restoreObject(this.memo);
}
}
/** Writes the object's local position – one axis, or all – into its container template. */
export class ApplyPositionToContainerCommand implements Command {
readonly label: string;
- private memos: SlotMemo[] = [];
+ private previous: Position = { x: 0, y: 0, z: 0 };
+ private memo: ObjectMemo | undefined;
+ private target: InstanceTarget | undefined;
constructor(
private readonly holderId: string,
@@ -150,25 +141,25 @@ export class ApplyPositionToContainerCommand implements Command {
}
execute(project: Project): void {
- const object = requireObject(project, this.holderId, this.objectId);
- const link = requireLink(project, this.holderId, object);
- const axes = this.axis ? [this.axis] : ALL_AXES;
- this.memos = applyToTemplate(
- project,
- link,
- axes.map(positionSlot),
- axes.map((a) => object.position[a]),
- );
+ const target = requireTarget(project, this.holderId, this.objectId);
+ const ref = requireObjectRef(project, this.holderId, this.objectId);
+ this.target = target;
+ this.memo = captureObject(ref);
+ this.previous = { ...target.template.position };
+ for (const axis of this.axis ? [this.axis] : ALL_AXES)
+ target.template.position[axis] = ref.object.position[axis];
+ ref.setPosition(ref.object.position);
}
undo(): void {
- restoreSlots(this.memos);
+ if (this.target) this.target.template.position = { ...this.previous };
+ if (this.memo) restoreObject(this.memo);
}
}
export class RevertPositionToTemplateCommand implements Command {
readonly label: string;
- private previous: Position = { x: 0, y: 0, z: 0 };
+ private memo: ObjectMemo | undefined;
constructor(
private readonly holderId: string,
@@ -179,13 +170,15 @@ export class RevertPositionToTemplateCommand implements Command {
}
execute(project: Project): void {
- const object = requireObject(project, this.holderId, this.objectId);
- const link = requireLink(project, this.holderId, object);
- this.previous = { ...object.position };
- for (const a of this.axis ? [this.axis] : ALL_AXES) object.position[a] = link.template.position[a];
+ const target = requireTarget(project, this.holderId, this.objectId);
+ const ref = requireObjectRef(project, this.holderId, this.objectId);
+ this.memo = captureObject(ref);
+ const position = { ...ref.object.position };
+ for (const axis of this.axis ? [this.axis] : ALL_AXES) position[axis] = target.template.position[axis];
+ ref.setPosition(position);
}
- undo(project: Project): void {
- requireObject(project, this.holderId, this.objectId).position = { ...this.previous };
+ undo(): void {
+ if (this.memo) restoreObject(this.memo);
}
}
diff --git a/src/core/commands/colliderCommands.ts b/src/core/commands/colliderCommands.ts
index f601cf24..b75ac5e5 100644
--- a/src/core/commands/colliderCommands.ts
+++ b/src/core/commands/colliderCommands.ts
@@ -1,4 +1,5 @@
import { newId } from '../ids';
+import { isLiveInstance } from '../model/instance';
import type { Project } from '../model/Project';
import { createSceneObject } from '../model/SceneObject';
import { resolveSceneObjectProps } from '../properties/resolve';
@@ -6,7 +7,7 @@ import { removeById } from '../util/list';
import { buildAsset } from './assetCommands';
import { type Command, CommandError } from './Command';
import { nextAssetName } from './naming';
-import { requireAsset, requireObject, requireHolder } from './require';
+import { requireAsset, requireObject, requireHolder, requireStructural } from './require';
/**
* Creates a collider asset sized like the object (sprites: width × height; anything else: schema
@@ -24,9 +25,12 @@ export class AddColliderCommand implements Command {
execute(project: Project): void {
const holder = requireHolder(project, this.holderId);
+ requireStructural(project, holder, this.targetId);
const target = requireObject(project, this.holderId, this.targetId);
const targetAsset = requireAsset(project, target.assetId);
if (targetAsset.type === 'collider') throw new CommandError('A collider cannot have a collider');
+ if (isLiveInstance(project, holder, target))
+ throw new CommandError('A placed container shows its template; add the collider inside it');
const asset = buildAsset(
'collider',
diff --git a/src/core/commands/containerCommands.ts b/src/core/commands/containerCommands.ts
index 5b4c8944..41a463b1 100644
--- a/src/core/commands/containerCommands.ts
+++ b/src/core/commands/containerCommands.ts
@@ -3,31 +3,36 @@ import type { ContainerAsset } from '../model/Asset';
import {
findObject,
localPositionFor,
+ removeSubtrees,
subtreeIds,
subtreeRoots,
treeOrder,
worldPosition,
} from '../model/hierarchy';
+import { type InstanceEdit, isLiveInstance, materializeInstance } from '../model/instance';
import type { Position } from '../model/Properties';
import { findObjectHolder, type Project } from '../model/Project';
-import { createSceneObject } from '../model/SceneObject';
-import { removeById } from '../util/list';
+import { createSceneObject, type SceneObject } from '../model/SceneObject';
+import { removeById, type Removed, restoreAt } from '../util/list';
import { buildAsset } from './assetCommands';
import { type Command, CommandError } from './Command';
import { nextAssetName } from './naming';
-import { requireObject, requireHolder } from './require';
+import { requireObject, requireHolder, requireStructural } from './require';
/**
* Turns the selected objects, with everything parented below them, into a container asset placed
* in their stead. The origin is the centre of the selection's bounding box (z = 0), so the template
- * is centred around (0, 0, 0); the objects keep their ids and world positions. The container goes
- * below the roots' shared parent, or at the top level when they do not share one.
+ * is centred around (0, 0, 0). The objects themselves leave the holder – the placed container
+ * shows them again, resolved from the template. It goes below the roots' shared parent, or at the
+ * top level when they do not share one.
*/
export class CreateContainerCommand implements Command {
readonly label = 'Create container';
readonly assetId = newId();
readonly objectId = newId();
- private previous: { id: string; parentId: string; position: Position; templateId: string }[] = [];
+ private removed: Removed[] = [];
+ /** Kept across a redo: the ids of the resolved content are built from them. */
+ private readonly templateIds = new Map();
constructor(
private readonly holderId: string,
@@ -36,7 +41,9 @@ export class CreateContainerCommand implements Command {
execute(project: Project): void {
const holder = requireHolder(project, this.holderId);
- const selected = [...new Set(this.objectIds)].map((id) => requireObject(project, this.holderId, id));
+ const ids = [...new Set(this.objectIds)];
+ for (const id of ids) requireStructural(project, holder, id);
+ const selected = ids.map((id) => requireObject(project, this.holderId, id));
if (selected.length === 0) throw new CommandError('Select at least one object to create a container');
const worlds = selected.map((o) => worldPosition(holder, o));
@@ -65,45 +72,32 @@ export class CreateContainerCommand implements Command {
nextAssetName(project, 'Container'),
this.assetId,
) as ContainerAsset;
- const templateIds = new Map(members.map((o) => [o.id, newId()]));
+ const templateIds = this.templateIds;
+ for (const object of members) if (!templateIds.has(object.id)) templateIds.set(object.id, newId());
for (const object of members) {
const isRoot = roots.has(object.id);
const world = worldPosition(holder, object);
asset.objects.push({
+ ...object,
id: templateIds.get(object.id)!,
- assetId: object.assetId,
- name: object.name,
position: isRoot
? { x: world.x - centre.x, y: world.y - centre.y, z: world.z }
: { ...object.position },
- // A view-only offset belongs to the placed object, not to the template (see container.ts).
+ // A view-only offset belongs to the placed object, not to the template.
editorOffset: { x: 0, y: 0 },
overrides: { ...object.overrides },
parentId: isRoot ? '' : (templateIds.get(object.parentId) ?? ''),
- templateId: '',
+ instanceEdits: { ...object.instanceEdits },
});
}
- this.previous = members.map((o) => ({
- id: o.id,
- parentId: o.parentId,
- position: { ...o.position },
- templateId: o.templateId,
- }));
const container = createSceneObject(
this.objectId,
this.assetId,
localPositionFor(holder, containerParentId, centre),
containerParentId,
);
- for (const object of members) {
- if (roots.has(object.id)) {
- const world = worldPosition(holder, object);
- object.parentId = this.objectId;
- object.position = { x: world.x - centre.x, y: world.y - centre.y, z: world.z };
- }
- object.templateId = templateIds.get(object.id)!;
- }
+ this.removed = removeSubtrees(holder, [...roots]);
project.assets.push(asset);
holder.objects.push(container);
}
@@ -111,15 +105,48 @@ export class CreateContainerCommand implements Command {
undo(project: Project): void {
const holder = findObjectHolder(project, this.holderId);
if (holder) {
- for (const entry of this.previous) {
- const object = findObject(holder, entry.id);
- if (!object) continue;
- object.parentId = entry.parentId;
- object.position = { ...entry.position };
- object.templateId = entry.templateId;
- }
holder.objects = removeById(holder.objects, this.objectId);
+ restoreAt(holder.objects, this.removed);
}
project.assets = removeById(project.assets, this.assetId);
}
}
+
+/**
+ * Writes the content a placed container shows into the holder as ordinary objects. It stops
+ * following the template from then on – which is what makes its structure editable, the one thing
+ * an instance does not allow.
+ */
+export class UnpackContainerCommand implements Command {
+ readonly label = 'Unpack container';
+ private createdIds: string[] = [];
+ private edits: Record = {};
+
+ constructor(
+ private readonly holderId: string,
+ private readonly objectId: string,
+ ) {}
+
+ execute(project: Project): void {
+ const holder = requireHolder(project, this.holderId);
+ requireStructural(project, holder, this.objectId);
+ const object = requireObject(project, this.holderId, this.objectId);
+ if (!isLiveInstance(project, holder, object))
+ throw new CommandError(`${object.name || 'That object'} is not a placed container`);
+ const created = materializeInstance(project, holder, object);
+ if (created.length === 0) throw new CommandError('The container is empty, there is nothing to unpack');
+ this.createdIds = created.map((o) => o.id);
+ this.edits = object.instanceEdits;
+ object.instanceEdits = {};
+ holder.objects.push(...created);
+ }
+
+ undo(project: Project): void {
+ const holder = findObjectHolder(project, this.holderId);
+ if (!holder) return;
+ const ids = new Set(this.createdIds);
+ holder.objects = holder.objects.filter((o) => !ids.has(o.id));
+ const object = findObject(holder, this.objectId);
+ if (object) object.instanceEdits = this.edits;
+ }
+}
diff --git a/src/core/commands/require.ts b/src/core/commands/require.ts
index b4141ef7..f008b25e 100644
--- a/src/core/commands/require.ts
+++ b/src/core/commands/require.ts
@@ -2,7 +2,8 @@
import { findNode, type GraphNode } from '../graph/Graph';
import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
import type { Asset, BehaviorAsset, SceneAsset } from '../model/Asset';
-import { findObject } from '../model/hierarchy';
+import { findObject, type ObjectTree } from '../model/hierarchy';
+import { isTemplateContent, objectRef, type ObjectRef } from '../model/instance';
import { findAsset, findObjectHolder, findScene, type ObjectHolder, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { propDef, type PropertyDef } from '../properties/PropertySchema';
@@ -32,6 +33,29 @@ export function requireObject(project: Project, holderId: string, objectId: stri
return object;
}
+/**
+ * The object a value edit works on, whether it is stored in the holder or resolved from a
+ * container template – the writes then land in the instance's edits.
+ */
+export function requireObjectRef(project: Project, holderId: string, objectId: string): ObjectRef {
+ const ref = objectRef(project, requireHolder(project, holderId), objectId);
+ if (!ref) throw new CommandError(`Scene object ${objectId} not found`);
+ return ref;
+}
+
+/**
+ * Rejects an object that only exists because a container template says so. Its structure – what
+ * exists, what it is parented to – is the template's business, not the instance's.
+ */
+export function requireStructural(project: Project, holder: ObjectTree, objectId: string): void {
+ if (!isTemplateContent(holder, objectId)) return;
+ // Not resolvable at all is a plain "not found", which the caller's own lookup words better.
+ const object = objectRef(project, holder, objectId)?.object;
+ if (!object) return;
+ const name = object.name || 'That object';
+ throw new CommandError(`${name} comes from a container template; open the container to change it`);
+}
+
export function requireBehavior(project: Project, assetId: string): BehaviorAsset {
const asset = findAsset(project, assetId);
if (!asset || asset.type !== 'behavior') throw new CommandError(`Behavior ${assetId} not found`);
diff --git a/src/core/commands/sceneObjectCommands.ts b/src/core/commands/sceneObjectCommands.ts
index 6da64eda..9a96d64b 100644
--- a/src/core/commands/sceneObjectCommands.ts
+++ b/src/core/commands/sceneObjectCommands.ts
@@ -1,41 +1,31 @@
import { newId } from '../ids';
import { isPlaceableType } from '../model/AssetTypes';
-import { containerReaches, instantiateContainer } from '../model/container';
+import { containerReaches } from '../model/container';
import { isSelfOrAncestor, localPositionFor, removeSubtrees, worldPosition } from '../model/hierarchy';
-import {
- type Position,
- type PropertyBag,
- type PropertyValue,
- toPosition,
- type Vec2,
-} from '../model/Properties';
+import { captureObject, isLiveInstance, type ObjectMemo, restoreObject } from '../model/instance';
+import { type Position, type PropertyValue, toPosition, type Vec2 } from '../model/Properties';
import type { Project } from '../model/Project';
import { createSceneObject, type SceneObject } from '../model/SceneObject';
import { coerceValue } from '../properties/PropertySchema';
import { type Removed, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
-import { requireAsset, requireObject, requirePropDef, requireHolder } from './require';
-import { SetBagValueCommand } from './SetBagValueCommand';
import {
- ALL_AXES,
- nameSlot,
- overrideSlot,
- positionSlot,
- restoreSlots,
- type SlotMemo,
- syncTemplateEdit,
-} from './templateSync';
+ requireAsset,
+ requireObject,
+ requireObjectRef,
+ requirePropDef,
+ requireHolder,
+ requireStructural,
+} from './require';
/**
* The position is a world position; with a parent it is converted to the parent's local space.
- * Placing a container into a scene also creates its content (see `instantiateContainer`); into
- * a container template it does not, because the template spells out one object per placed copy
- * and `instantiateContainer` expands the nested one when the copy is made.
+ * Placing a container adds the container object alone – its content is resolved from the template
+ * (see `model/instance.ts`), so it is never copied into the scene.
*/
export class PlaceAssetCommand implements Command {
readonly label = 'Place asset';
readonly objectId = newId();
- private createdIds: string[] = [];
constructor(
private readonly holderId: string,
@@ -48,25 +38,19 @@ export class PlaceAssetCommand implements Command {
const holder = requireHolder(project, this.holderId);
const asset = requireAsset(project, this.assetId);
if (!isPlaceableType(asset.type)) throw new CommandError('A scene cannot be placed into a scene');
- if (
- holder.type === 'container' &&
- asset.type === 'container' &&
- containerReaches(project, asset, holder.id)
- )
+ if (asset.type === 'container' && containerReaches(project, asset, holder.id))
throw new CommandError(`${asset.name} cannot contain ${holder.name}: that nests forever`);
- if (this.parentId) requireObject(project, this.holderId, this.parentId);
+ if (this.parentId) {
+ requireObject(project, this.holderId, this.parentId);
+ requireStructural(project, holder, this.parentId);
+ }
const local = localPositionFor(holder, this.parentId, toPosition(this.position));
- const created = [createSceneObject(this.objectId, this.assetId, local, this.parentId)];
- if (asset.type === 'container' && holder.type === 'scene')
- created.push(...instantiateContainer(project, asset, this.objectId));
- this.createdIds = created.map((o) => o.id);
- holder.objects.push(...created);
+ holder.objects.push(createSceneObject(this.objectId, this.assetId, local, this.parentId));
}
undo(project: Project): void {
const holder = requireHolder(project, this.holderId);
- const ids = new Set(this.createdIds);
- holder.objects = holder.objects.filter((o) => !ids.has(o.id));
+ holder.objects = holder.objects.filter((o) => o.id !== this.objectId);
}
}
@@ -83,8 +67,12 @@ export class RemoveSceneObjectsCommand implements Command {
}
execute(project: Project): void {
- for (const id of this.objectIds) requireObject(project, this.holderId, id);
- this.removed = removeSubtrees(requireHolder(project, this.holderId), this.objectIds);
+ const holder = requireHolder(project, this.holderId);
+ for (const id of this.objectIds) {
+ requireStructural(project, holder, id);
+ requireObject(project, this.holderId, id);
+ }
+ this.removed = removeSubtrees(holder, this.objectIds);
}
undo(project: Project): void {
@@ -104,8 +92,7 @@ export class RemoveSceneObjectCommand extends RemoveSceneObjectsCommand {
*/
export class MoveSceneObjectCommand implements Command {
readonly label = 'Move object';
- private previous: Position = { x: 0, y: 0, z: 0 };
- private memos: SlotMemo[] = [];
+ private memo: ObjectMemo | undefined;
constructor(
private readonly holderId: string,
@@ -114,28 +101,20 @@ export class MoveSceneObjectCommand implements Command {
) {}
execute(project: Project): void {
- const object = requireObject(project, this.holderId, this.objectId);
- this.previous = { ...object.position };
- object.position = { ...this.position };
- this.memos = syncTemplateEdit(
- project,
- this.holderId,
- object,
- ALL_AXES.map(positionSlot),
- ALL_AXES.map((axis) => this.previous[axis]),
- );
+ const ref = requireObjectRef(project, this.holderId, this.objectId);
+ this.memo = captureObject(ref);
+ ref.setPosition(this.position);
}
- undo(project: Project): void {
- restoreSlots(this.memos);
- requireObject(project, this.holderId, this.objectId).position = { ...this.previous };
+ undo(): void {
+ if (this.memo) restoreObject(this.memo);
}
}
/** Editor-only: undoable like any other edit, but it changes nothing the game can see. */
export class SetEditorOffsetCommand implements Command {
readonly label = 'Editor offset';
- private previous: Vec2 = { x: 0, y: 0 };
+ private memo: ObjectMemo | undefined;
constructor(
private readonly holderId: string,
@@ -144,20 +123,19 @@ export class SetEditorOffsetCommand implements Command {
) {}
execute(project: Project): void {
- const object = requireObject(project, this.holderId, this.objectId);
- this.previous = { ...object.editorOffset };
- object.editorOffset = { x: this.offset.x, y: this.offset.y };
+ const ref = requireObjectRef(project, this.holderId, this.objectId);
+ this.memo = captureObject(ref);
+ ref.setEditorOffset(this.offset);
}
- undo(project: Project): void {
- requireObject(project, this.holderId, this.objectId).editorOffset = { ...this.previous };
+ undo(): void {
+ if (this.memo) restoreObject(this.memo);
}
}
export class RenameSceneObjectCommand implements Command {
readonly label = 'Rename object';
- private previous = '';
- private memos: SlotMemo[] = [];
+ private memo: ObjectMemo | undefined;
constructor(
private readonly holderId: string,
@@ -166,53 +144,36 @@ export class RenameSceneObjectCommand implements Command {
) {}
execute(project: Project): void {
- const object = requireObject(project, this.holderId, this.objectId);
- this.previous = object.name;
- object.name = this.name;
- this.memos = syncTemplateEdit(project, this.holderId, object, [nameSlot], [this.previous]);
+ const ref = requireObjectRef(project, this.holderId, this.objectId);
+ this.memo = captureObject(ref);
+ ref.setName(this.name);
}
- undo(project: Project): void {
- restoreSlots(this.memos);
- requireObject(project, this.holderId, this.objectId).name = this.previous;
+ undo(): void {
+ if (this.memo) restoreObject(this.memo);
}
}
-abstract class OverrideCommand extends SetBagValueCommand {
- private memos: SlotMemo[] = [];
+abstract class OverrideCommand implements Command {
+ private memo: ObjectMemo | undefined;
protected constructor(
private readonly holderId: string,
private readonly objectId: string,
- key: string,
- label: string,
- ) {
- super(key, label);
+ protected readonly key: string,
+ readonly label: string,
+ ) {}
+
+ protected abstract nextValue(object: SceneObject, project: Project): PropertyValue | undefined;
+
+ execute(project: Project): void {
+ const ref = requireObjectRef(project, this.holderId, this.objectId);
+ this.memo = captureObject(ref);
+ ref.setOverride(this.key, this.nextValue(ref.object, project));
}
- protected override resolveTarget(project: Project): SceneObject {
- return requireObject(project, this.holderId, this.objectId);
- }
-
- protected override bagOf(object: SceneObject): PropertyBag {
- return object.overrides;
- }
-
- override execute(project: Project): void {
- const before = this.resolveTarget(project).overrides[this.key];
- super.execute(project);
- this.memos = syncTemplateEdit(
- project,
- this.holderId,
- this.resolveTarget(project),
- [overrideSlot(this.key)],
- [before],
- );
- }
-
- override undo(project: Project): void {
- restoreSlots(this.memos);
- super.undo(project);
+ undo(): void {
+ if (this.memo) restoreObject(this.memo);
}
}
@@ -226,7 +187,7 @@ export class SetOverrideCommand extends OverrideCommand {
super(holderId, objectId, key, `Override ${key}`);
}
- protected override computeNext(object: SceneObject, project: Project): PropertyValue {
+ protected override nextValue(object: SceneObject, project: Project): PropertyValue {
return coerceValue(requirePropDef(requireAsset(project, object.assetId), this.key), this.value);
}
}
@@ -238,7 +199,7 @@ export class ClearOverrideCommand extends OverrideCommand {
// No schema check, unlike setting one: a key the asset no longer declares is exactly what one
// wants to be able to clear.
- protected override computeNext(): undefined {
+ protected override nextValue(): undefined {
return undefined;
}
}
@@ -262,10 +223,13 @@ export class SetParentCommand implements Command {
execute(project: Project): void {
const holder = requireHolder(project, this.holderId);
+ requireStructural(project, holder, this.objectId);
const object = requireObject(project, this.holderId, this.objectId);
if (this.parentId) {
if (this.parentId === this.objectId) throw new CommandError('An object cannot be its own parent');
- requireObject(project, this.holderId, this.parentId);
+ requireStructural(project, holder, this.parentId);
+ if (isLiveInstance(project, holder, requireObject(project, this.holderId, this.parentId)))
+ throw new CommandError('A placed container shows its template; unpack it to put objects inside');
if (isSelfOrAncestor(holder, this.objectId, this.parentId))
throw new CommandError('An object cannot be parented to one of its own children');
}
diff --git a/src/core/commands/templateSync.ts b/src/core/commands/templateSync.ts
deleted file mode 100644
index 3a61e236..00000000
--- a/src/core/commands/templateSync.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-/**
- * Prefab semantics for container templates: a placed copy that still holds the template's old value
- * follows a change made to the template, a copy modified on its own keeps what it has. Used by both
- * directions – an edit in the container editor (see the object commands) and "apply" from a placed
- * object upwards (see `applyCommands`).
- */
-import { instancesOf } from '../model/container';
-import { type Axis, type PropertyValue, setBagValue } from '../model/Properties';
-import { findObjectHolder, type Project } from '../model/Project';
-import type { SceneObject } from '../model/SceneObject';
-
-export const ALL_AXES: readonly Axis[] = ['x', 'y', 'z'];
-
-/** One value that follows on its own: an override key, a single axis of the position, the name. */
-export interface Slot {
- read(object: SceneObject): T;
- write(object: SceneObject, value: T): void;
-}
-
-export interface SlotMemo {
- object: SceneObject;
- slot: Slot;
- previous: T;
-}
-
-export const overrideSlot = (key: string): Slot => ({
- read: (object) => object.overrides[key],
- write: (object, value) => setBagValue(object.overrides, key, value),
-});
-
-export const positionSlot = (axis: Axis): Slot => ({
- read: (object) => object.position[axis],
- write: (object, value) => {
- object.position[axis] = value;
- },
-});
-
-export const nameSlot: Slot = {
- read: (object) => object.name,
- write: (object, value) => {
- object.name = value;
- },
-};
-
-/** Writes `after` into every copy of `template` whose slot still reads `before`. */
-export function syncInstances(
- project: Project,
- template: SceneObject,
- slots: readonly Slot[],
- before: readonly T[],
- after: readonly T[],
-): SlotMemo[] {
- const memos: SlotMemo[] = [];
- for (const { object } of instancesOf(project, template.id)) {
- slots.forEach((slot, i) => {
- const current = slot.read(object);
- if (current !== before[i]) return;
- memos.push({ object, slot, previous: current });
- slot.write(object, after[i]!);
- });
- }
- return memos;
-}
-
-/**
- * Carries an edit of a container template object to the placed copies; nothing to do when the
- * holder is a scene, where an edit is meant for that one object. `before` has to be read off the
- * object before it is written, the new values are read back from it here.
- */
-export function syncTemplateEdit(
- project: Project,
- holderId: string,
- object: SceneObject,
- slots: readonly Slot[],
- before: readonly T[],
-): SlotMemo[] {
- if (findObjectHolder(project, holderId)?.type !== 'container') return [];
- return syncInstances(
- project,
- object,
- slots,
- before,
- slots.map((slot) => slot.read(object)),
- );
-}
-
-export function restoreSlots(memos: readonly SlotMemo[]): void {
- for (const memo of [...memos].reverse()) memo.slot.write(memo.object, memo.previous);
-}
diff --git a/src/core/commands/variableCommands.ts b/src/core/commands/variableCommands.ts
index 9ee5e3b4..0f2a1ddd 100644
--- a/src/core/commands/variableCommands.ts
+++ b/src/core/commands/variableCommands.ts
@@ -1,5 +1,6 @@
import type { PropertyValue } from '../model/Properties';
-import { findUsages, type Project } from '../model/Project';
+import { overrideSites } from '../model/instance';
+import type { Project } from '../model/Project';
import { isCommonProp, type PropertyDef, type VariableKind, variableDef } from '../properties/PropertySchema';
import { type Removed, removeFirst, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
@@ -39,7 +40,7 @@ export class AddVariableCommand implements Command {
export class RemoveVariableCommand implements Command {
readonly label: string;
private removed?: { def: Removed; value: PropertyValue | undefined };
- private overrides: { holderId: string; objectId: string; value: PropertyValue }[] = [];
+ private overrides: { holderId: string; objectId: string; path: string; value: PropertyValue }[] = [];
constructor(
private readonly assetId: string,
@@ -55,11 +56,11 @@ export class RemoveVariableCommand implements Command {
this.removed = { def, value: asset.props[this.key] };
delete asset.props[this.key];
this.overrides = [];
- for (const { holder, object } of findUsages(project, this.assetId)) {
- const value = object.overrides[this.key];
+ for (const site of overrideSites(project, (id) => id === this.assetId)) {
+ const value = site.bag[this.key];
if (value === undefined) continue;
- this.overrides.push({ holderId: holder.id, objectId: object.id, value });
- delete object.overrides[this.key];
+ this.overrides.push({ holderId: site.holder.id, objectId: site.object.id, path: site.path, value });
+ delete site.bag[this.key];
}
}
@@ -68,10 +69,12 @@ export class RemoveVariableCommand implements Command {
const asset = requireBehavior(project, this.assetId);
restoreAt(asset.variables, [this.removed.def]);
if (this.removed.value !== undefined) asset.props[this.key] = this.removed.value;
- const usages = findUsages(project, this.assetId);
+ const sites = overrideSites(project, (id) => id === this.assetId);
for (const o of this.overrides) {
- const object = usages.find((u) => u.holder.id === o.holderId && u.object.id === o.objectId)?.object;
- if (object) object.overrides[this.key] = o.value;
+ const site = sites.find(
+ (s) => s.holder.id === o.holderId && s.object.id === o.objectId && s.path === o.path,
+ );
+ if (site) site.bag[this.key] = o.value;
}
}
}
@@ -109,10 +112,10 @@ export class RenameVariableCommand implements Command {
asset.props[to] = asset.props[from]!;
delete asset.props[from];
}
- for (const { object } of findUsages(project, this.assetId)) {
- if (object.overrides[from] === undefined) continue;
- object.overrides[to] = object.overrides[from]!;
- delete object.overrides[from];
+ for (const { bag } of overrideSites(project, (id) => id === this.assetId)) {
+ if (bag[from] === undefined) continue;
+ bag[to] = bag[from]!;
+ delete bag[from];
}
// Every node that picks a variable stores it under the param key `variable`.
for (const node of asset.graph.nodes) if (node.params.variable === from) node.params.variable = to;
diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts
index 4d216093..3ea1cf02 100644
--- a/src/core/model/Asset.ts
+++ b/src/core/model/Asset.ts
@@ -41,9 +41,9 @@ export interface ColliderAsset extends AssetBase {
}
/**
- * `objects` is a template instantiated below the container whenever it is placed. Template
- * positions are local to the container's origin, and every placed copy gets fresh ids and
- * remembers the template object it came from (`SceneObject.templateId`).
+ * `objects` is a template, not content: a placed container shows it without copying it, so an
+ * edit here reaches every instance (see `model/instance.ts`). Positions are local to the
+ * container's origin.
*/
export interface ContainerAsset extends AssetBase {
type: 'container';
diff --git a/src/core/model/Project.ts b/src/core/model/Project.ts
index 4962733e..b453fb3a 100644
--- a/src/core/model/Project.ts
+++ b/src/core/model/Project.ts
@@ -1,9 +1,13 @@
import { schemaForAsset } from '../properties/PropertySchema';
import { type Asset, type ContainerAsset, isSceneAsset, type SceneAsset } from './Asset';
+import { overrideSites } from './instance';
import type { SceneObject } from './SceneObject';
-/** Reserved for format migrations – the loader does not branch on it yet. */
-export const PROJECT_FORMAT_VERSION = 1;
+/**
+ * 2 since a placed container stopped copying its template into the scene. Old files are not
+ * migrated: the loader simply reads no container content from them any more.
+ */
+export const PROJECT_FORMAT_VERSION = 2;
export interface Project {
formatVersion: number;
@@ -82,7 +86,8 @@ export function findUsages(project: Project, assetId: string): SceneObjectRef[]
export type PropertyUsage =
| { on: 'asset'; asset: Asset; key: string }
- | { on: 'object'; holder: ObjectHolder; object: SceneObject; key: string };
+ /** `path` is empty for a placed object's own override, set for one a container instance keeps. */
+ | { on: 'object'; holder: ObjectHolder; object: SceneObject; path: string; key: string };
/**
* The other half of "where is this asset used": a texture is never placed, it is worn through a
@@ -102,11 +107,10 @@ export function findPropertyUsages(project: Project, ids: string | readonly stri
refKeys.set(asset.id, keys);
for (const key of keys) if (matches(asset.props[key])) result.push({ on: 'asset', asset, key });
}
- for (const holder of objectHolders(project)) {
- for (const object of holder.objects) {
- for (const key of refKeys.get(object.assetId) ?? [])
- if (matches(object.overrides[key])) result.push({ on: 'object', holder, object, key });
- }
+ for (const site of overrideSites(project, () => true)) {
+ for (const key of refKeys.get(site.assetId) ?? [])
+ if (matches(site.bag[key]))
+ result.push({ on: 'object', holder: site.holder, object: site.object, path: site.path, key });
}
return result;
}
diff --git a/src/core/model/SceneObject.ts b/src/core/model/SceneObject.ts
index 99fd95f1..f098c36d 100644
--- a/src/core/model/SceneObject.ts
+++ b/src/core/model/SceneObject.ts
@@ -1,3 +1,4 @@
+import type { InstanceEdit } from './instance';
import { type Position, type PropertyBag, toPosition, type Vec2 } from './Properties';
/**
@@ -23,10 +24,11 @@ export interface SceneObject {
*/
parentId: string;
/**
- * The template object inside the container asset this one was instantiated from, empty for
- * everything else – the link that lets template changes reach the instances later on.
+ * Only on a placed container: what the user changed about its content, keyed by the path of
+ * template ids leading to the object (see `model/instance.ts`). The content itself is not
+ * stored – it follows the template.
*/
- templateId: string;
+ instanceEdits: Record;
}
export function createSceneObject(
@@ -43,6 +45,6 @@ export function createSceneObject(
editorOffset: { x: 0, y: 0 },
overrides: {},
parentId,
- templateId: '',
+ instanceEdits: {},
};
}
diff --git a/src/core/model/container.ts b/src/core/model/container.ts
index 8f301b07..b686c927 100644
--- a/src/core/model/container.ts
+++ b/src/core/model/container.ts
@@ -1,46 +1,10 @@
-import { newId } from '../ids';
import type { ContainerAsset } from './Asset';
-import { chainOf, type ObjectTree } from './hierarchy';
-import { findAsset, objectsWhere, type Project, type SceneObjectRef } from './Project';
-import type { SceneObject } from './SceneObject';
-
-export interface TemplateLink {
- containerObject: SceneObject;
- asset: ContainerAsset;
- template: SceneObject;
-}
-
-/**
- * The nearest container ancestor whose asset holds the template `object` was instantiated from;
- * undefined when it has no template id or the link is stale.
- */
-export function templateLinkOf(
- project: Project,
- tree: ObjectTree,
- object: SceneObject,
-): TemplateLink | undefined {
- if (!object.templateId) return undefined;
- for (const containerObject of chainOf(tree, object).slice(1)) {
- const asset = findAsset(project, containerObject.assetId);
- if (asset?.type !== 'container') continue;
- const template = asset.objects.find((t) => t.id === object.templateId);
- if (template) return { containerObject, asset, template };
- }
- return undefined;
-}
-
-/**
- * Every placed copy of a template object. Template objects themselves never carry a template id, so
- * one is never mistaken for a copy of itself even though the search covers container assets too.
- */
-export function instancesOf(project: Project, templateId: string): SceneObjectRef[] {
- return objectsWhere(project, (o) => o.templateId === templateId);
-}
+import { findAsset, type Project } from './Project';
/**
* Whether `wantedId` is that container or sits somewhere in its content. Editing a container's
- * template can place containers into it, and a cycle would expand into thousands of objects the
- * moment one of them is placed.
+ * template can place containers into it, and a cycle would expand forever the moment one of them
+ * is resolved.
*/
export function containerReaches(project: Project, asset: ContainerAsset, wantedId: string): boolean {
if (asset.id === wantedId) return true;
@@ -57,43 +21,3 @@ export function containerReaches(project: Project, asset: ContainerAsset, wanted
}
return false;
}
-
-const MAX_NESTING = 8;
-
-/**
- * One fresh object per template object, parented below `containerObjectId` or below the copy of its
- * template parent. A template object that is itself a container is expanded in turn, unless the
- * template already spells its content out.
- */
-export function instantiateContainer(
- project: Project,
- asset: ContainerAsset,
- containerObjectId: string,
- depth = 0,
-): SceneObject[] {
- const ids = new Map(asset.objects.map((t) => [t.id, newId()]));
- const result: SceneObject[] = [];
- for (const template of asset.objects) {
- const id = ids.get(template.id)!;
- const parentId = template.parentId
- ? (ids.get(template.parentId) ?? containerObjectId)
- : containerObjectId;
- result.push({
- id,
- assetId: template.assetId,
- name: template.name,
- position: { ...template.position },
- // The editor offset is a tweak on one placed object, not something copies should inherit.
- editorOffset: { x: 0, y: 0 },
- overrides: { ...template.overrides },
- parentId,
- templateId: template.id,
- });
- const nested = findAsset(project, template.assetId);
- const hasExplicitContent = asset.objects.some((o) => o.parentId === template.id);
- if (nested?.type === 'container' && !hasExplicitContent && depth < MAX_NESTING) {
- result.push(...instantiateContainer(project, nested, id, depth + 1));
- }
- }
- return result;
-}
diff --git a/src/core/model/instance.ts b/src/core/model/instance.ts
new file mode 100644
index 00000000..45ac17af
--- /dev/null
+++ b/src/core/model/instance.ts
@@ -0,0 +1,429 @@
+/**
+ * A container placed in a scene is not copied into it. The scene stores the container object
+ * alone; its content is resolved from the template on every read, so a template edit reaches
+ * every instance at once. What the user changes on one of those resolved objects is kept as a
+ * sparse `InstanceEdit` on the instance, keyed by the path of template ids leading to it – and
+ * only where it actually deviates, which is what makes an untouched value keep following.
+ *
+ * Ids of resolved content are derived (`~`), never stored, and stable as long
+ * as the template is: the selection, the hierarchy and the viewport address them like any other.
+ */
+import type { Asset, ContainerAsset } from './Asset';
+import type { ObjectTree } from './hierarchy';
+import {
+ type Axis,
+ type Position,
+ type PropertyBag,
+ type PropertyValue,
+ setBagValue,
+ type Vec2,
+} from './Properties';
+import { findAsset, 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 },
+ };
+}
+
+export function containerAssetOf(project: Project, object: SceneObject): ContainerAsset | undefined {
+ const asset: Asset | undefined = findAsset(project, object.assetId);
+ return asset?.type === 'container' ? asset : undefined;
+}
+
+/**
+ * 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) };
+}
+
+/**
+ * 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 {
+ for (const template of 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;
+}
+
+/** 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 {
+ const split = splitInstanceId(objectId);
+ const instance = split && holder.objects.find((o) => o.id === split.instanceId);
+ if (!split || !instance) return undefined;
+ let asset = containerAssetOf(project, instance);
+ let template: SceneObject | undefined;
+ for (const segment of split.path.split(PATH_SEP)) {
+ 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;
+ }
+ return asset && template ? { instance, path: split.path, asset, template } : undefined;
+}
+
+// --- writing -----------------------------------------------------------------------------------
+
+/**
+ * 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.template.name) delete edit.name;
+ else edit.name = name;
+ }),
+ setPosition: (position) =>
+ write((edit) => {
+ for (const axis of AXES) {
+ if (position[axis] === target.template.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 (value === target.template.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 bag of property overrides in the project, with the asset whose schema governs it:
+ * a placed object's own, or the deviations a container instance keeps for its content. Anything
+ * rewriting overrides across the project (removing or renaming a behavior variable, counting the
+ * uses of a texture) has to cover both, or it silently misses everything inside a container.
+ */
+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;
+}
+
+// --- unpacking ---------------------------------------------------------------------------------
+
+/**
+ * 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: {},
+ }));
+}
+
+/**
+ * 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;
+}
diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts
index 8221ea7b..071495d9 100644
--- a/src/core/store/serialize.ts
+++ b/src/core/store/serialize.ts
@@ -2,6 +2,7 @@
import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode, nodeDef } from '../graph';
import type { Asset, TextureFrame } from '../model/Asset';
import { isAssetType } from '../model/AssetTypes';
+import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
@@ -108,7 +109,7 @@ function normalizeObjects(raw: unknown, schemas: Map): S
overrides: normalizeBag(o.overrides, schema, true),
// `ownerId` is the pre-hierarchy name of the same link.
parentId: typeof o.parentId === 'string' ? o.parentId : typeof o.ownerId === 'string' ? o.ownerId : '',
- templateId: typeof o.templateId === 'string' ? o.templateId : '',
+ instanceEdits: normalizeInstanceEdits(o.instanceEdits),
});
}
// Parents must exist in the same scene, differ from the object itself and not form cycles.
@@ -120,6 +121,32 @@ function normalizeObjects(raw: unknown, schemas: Map): S
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)))
+ if (typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean') edit.overrides[key] = v;
+ if (!isEmptyEdit(edit)) edits[path] = edit;
+ }
+ return edits;
+}
+
function normalizeVariables(raw: unknown): PropertyDef[] {
const defs: PropertyDef[] = [];
const seen = new Set();
diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts
index d6204bde..f20dc215 100644
--- a/src/editor/debugHook.ts
+++ b/src/editor/debugHook.ts
@@ -1,4 +1,6 @@
import { type Application, type Container, Rectangle, Text } from 'pixi.js';
+import { resolveObjects } from '../core/model/instance';
+import { findObjectHolder } from '../core/model/Project';
import { Button, ui, Widget } from '../ui';
import type { CentreTab, Editor } from './Editor';
import type { EditorState } from './state';
@@ -103,8 +105,18 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
const p = popup.getGlobalPosition();
return { x: p.x, y: p.y, w: popup.w, h: popup.h };
};
+ /**
+ * A holder's objects the way the editor sees them: the content of placed containers is resolved
+ * from the template, so it is nowhere in `holder.objects` (see `model/instance.ts`).
+ */
+ const objectsOf = (holderId: string) => {
+ const holder = findObjectHolder(state.project, holderId);
+ return holder ? resolveObjects(state.project, holder) : [];
+ };
(window as unknown as { __gm: unknown }).__gm = {
state,
+ objectsOf,
+ sceneObjects: () => (state.activeScene ? objectsOf(state.activeScene.id) : []),
locate,
overlayTexts,
overlayRect,
diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts
index 63a40acd..b84da69e 100644
--- a/src/editor/inspector/objectSection.ts
+++ b/src/editor/inspector/objectSection.ts
@@ -6,6 +6,7 @@ import {
RevertPositionToTemplateCommand,
} from '../../core/commands/applyCommands';
import type { Command } from '../../core/commands/Command';
+import { UnpackContainerCommand } from '../../core/commands/containerCommands';
import {
ClearOverrideCommand,
MoveSceneObjectCommand,
@@ -15,8 +16,16 @@ import {
SetParentCommand,
} from '../../core/commands/sceneObjectCommands';
import type { Asset } from '../../core/model/Asset';
-import { type TemplateLink, templateLinkOf } from '../../core/model/container';
import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
+import {
+ type InstanceTarget,
+ instanceTargetOf,
+ isLiveInstance,
+ isTemplateContent,
+ objectRef,
+ type ObjectRef,
+ resolvedHolder,
+} from '../../core/model/instance';
import type { Axis, PropertyBag, PropertyValue } from '../../core/model/Properties';
import { findAsset, findObjectHolder, type ObjectHolder, type Project } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject';
@@ -31,7 +40,7 @@ import {
deviatesFromTemplate,
resolveSceneObjectProps,
} from '../../core/properties/resolve';
-import { Button, Dropdown, Menu, type MenuItem, TextField } from '../../ui';
+import { Button, Dropdown, Label, Menu, type MenuItem, TextField } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions';
import { aspectActions } from './aspectAction';
import { createFieldRow, type FieldActions, type FieldRow, labelledRow } from './fieldFactory';
@@ -43,6 +52,17 @@ const OFFSET_AXES = ['x', 'y'] as const;
const offsetKey = (axis: 'x' | 'y') => `editorOffset${axis.toUpperCase()}`;
+/** The holder with the content of placed containers resolved in – what the inspector addresses. */
+function viewOf(project: Project, holderId: string): ObjectHolder | undefined {
+ const holder = findObjectHolder(project, holderId);
+ return holder && resolvedHolder(project, holder);
+}
+
+function targetOf(project: Project, holderId: string, objectId: string): InstanceTarget | undefined {
+ const holder = findObjectHolder(project, holderId);
+ return holder && instanceTargetOf(project, holder, objectId);
+}
+
/** Structure-defining facts of an object selection: parent, template link and schema. */
export function objectSignature(
project: Project,
@@ -52,11 +72,17 @@ export function objectSignature(
): string {
if (objectIds.length > 1) return `multi|${objectIds.join(',')}`;
const holder = findObjectHolder(project, holderId);
- const object = holder && findObject(holder, objectId);
+ const view = holder && resolvedHolder(project, holder);
+ const object = view && findObject(view, objectId);
const asset = object && findAsset(project, object.assetId);
- if (!object || !asset) return 'missing';
+ if (!holder || !object || !asset) return 'missing';
+ const kind = isTemplateContent(holder, objectId)
+ ? 'template'
+ : isLiveInstance(project, holder, object)
+ ? 'instance'
+ : '';
return (
- `${object.parentId}|${object.templateId}|` +
+ `${object.parentId}|${kind}|` +
schemaForAsset(asset)
.map((d) => `${d.key}:${d.kind}`)
.join(',')
@@ -65,11 +91,11 @@ export function objectSignature(
/** Several objects: what they are, and the actions that apply to all of them at once. */
export function buildObjectsSection(host: InspectorHost, holderId: string, objectIds: string[]): void {
- const holder = findObjectHolder(host.state.project, holderId);
- if (!holder) return;
+ const view = viewOf(host.state.project, holderId);
+ if (!view) return;
addTitle(host, `${objectIds.length} OBJECTS SELECTED`);
for (const id of objectIds) {
- const object = findObject(holder, id);
+ const object = findObject(view, id);
const asset = object && findAsset(host.state.project, object.assetId);
if (object && asset) addNote(host, `${object.name || asset.name} · ${asset.type}`);
}
@@ -77,6 +103,12 @@ export function buildObjectsSection(host: InspectorHost, holderId: string, objec
addNote(host, 'Ctrl-click adds or removes objects.', 'small');
addNote(host, 'Dragging one in the viewport moves all of them.', 'small');
addGap(host);
+ const holder = findObjectHolder(host.state.project, holderId)!;
+ if (objectIds.some((id) => isTemplateContent(holder, id))) {
+ addNote(host, 'Part of the selection comes from a container', 'small');
+ addNote(host, 'template, so grouping and removing are off.', 'small');
+ return;
+ }
host.body.add(
new Button('Create container', { onClick: () => createContainer(host.state, holderId, objectIds) }),
);
@@ -93,10 +125,12 @@ export function buildObjectsSection(host: InspectorHost, holderId: string, objec
/** One object: name, asset, parent, container link, the switch, position, editor offset, properties, collider button. */
export function buildObjectSection(host: InspectorHost, holderId: string, objectId: string): void {
- const holder = findObjectHolder(host.state.project, holderId);
- const object = holder && findObject(holder, objectId);
- const asset = object && findAsset(host.state.project, object.assetId);
- if (!holder || !object || !asset) return;
+ const { project } = host.state;
+ const view = viewOf(project, holderId);
+ const object = view && findObject(view, objectId);
+ const asset = object && findAsset(project, object.assetId);
+ if (!view || !object || !asset) return;
+ const target = targetOf(project, holderId, objectId);
addTitle(host, `${asset.type.toUpperCase()} OBJECT`);
host.nameField = new TextField(object.name, {
@@ -117,16 +151,15 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
);
host.body.add(assetRow.row);
- host.body.add(parentRow(host, holder, object));
+ host.body.add(parentRow(host, view, object, target !== undefined));
- // Part of a placed container: values compare against the template, not just the asset.
- const link = templateLinkOf(host.state.project, holder, object);
- if (link) {
+ // Content of a placed container: values compare against the template, not just the asset.
+ if (target) {
const linkRow = labelledRow('Part of');
linkRow.row.add(
- new Button(link.asset.name, {
+ new Button(target.asset.name, {
variant: 'ghost',
- onClick: () => host.state.select({ kind: 'asset', assetId: link.asset.id }),
+ onClick: () => host.state.select({ kind: 'asset', assetId: target.asset.id }),
}),
1,
);
@@ -144,8 +177,8 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
key: axis,
label: axis.toUpperCase(),
axis,
- read: (o) => o.position,
- write: (o, v) => (o.position = v),
+ read: (ref) => ref.object.position,
+ write: (ref, value) => ref.setPosition(value),
command: (v) => new MoveSceneObjectCommand(holderId, objectId, v),
reset: () =>
moved()
@@ -177,12 +210,12 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
key: offsetKey(axis),
label: `Offset ${axis.toUpperCase()}`,
axis,
- read: (o) => o.editorOffset,
- write: (o, v) => (o.editorOffset = v),
+ read: (ref) => ref.object.editorOffset,
+ write: (ref, value) => ref.setEditorOffset(value),
command: (v) => new SetEditorOffsetCommand(holderId, objectId, v),
// No apply: the offset never leaves the placed object.
reset: () => {
- const current = currentObject(host.state, holderId, objectId);
+ const current = currentRef(host.state, holderId, objectId)?.object;
if (!current || current.editorOffset[axis] === 0) return [];
return [
{
@@ -205,16 +238,11 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.');
for (const def of own) propertyRow(host, holderId, objectId, asset, def, values);
- markObjectRows(host, object, asset, link);
+ markObjectRows(host, object, asset, target);
- if (asset.type === 'container') {
- const count = asset.objects.length;
- addTitle(host, 'CONTENT');
- addNote(host, count === 0 ? 'The container is empty.' : `Instance with ${count} template object(s).`);
- if (count > 0) addNote(host, 'Its content is listed below it in the hierarchy.', 'small');
- }
+ if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length);
- if (asset.type !== 'collider') {
+ if (asset.type !== 'collider' && !target) {
addGap(host);
host.body.add(
new Button('+ Add collider', {
@@ -225,18 +253,53 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
}
}
+/**
+ * What a placed container holds. While it follows its template the content cannot be changed here –
+ * unpacking writes it into the scene as ordinary objects, which cuts the link for good.
+ */
+function contentSection(
+ host: InspectorHost,
+ holderId: string,
+ object: SceneObject,
+ templateCount: number,
+): void {
+ const holder = findObjectHolder(host.state.project, holderId);
+ const live = Boolean(holder && isLiveInstance(host.state.project, holder, object));
+ addTitle(host, 'CONTENT');
+ if (!live) {
+ addNote(host, 'Unpacked: its content is stored in the scene.');
+ return;
+ }
+ addNote(
+ host,
+ templateCount === 0 ? 'The container is empty.' : `Follows a template of ${templateCount} object(s).`,
+ );
+ if (templateCount === 0) return;
+ addNote(host, 'Its content is listed below it in the hierarchy.', 'small');
+ addGap(host);
+ host.body.add(
+ new Button('Unpack', {
+ variant: 'ghost',
+ onClick: () => host.state.execute(new UnpackContainerCommand(holderId, object.id)),
+ }),
+ );
+ addNote(host, 'Copies the content into the scene and stops', 'small');
+ addNote(host, 'following the template.', 'small');
+}
+
/** Pushes the current model values into the rows. False when the object is gone (rebuild). */
export function refreshObjectSection(host: InspectorHost, holderId: string, objectId: string): boolean {
- const holder = findObjectHolder(host.state.project, holderId);
- const object = holder && findObject(holder, objectId);
- const asset = object && findAsset(host.state.project, object.assetId);
- if (!holder || !object || !asset) return false;
+ const { project } = host.state;
+ const view = viewOf(project, holderId);
+ const object = view && findObject(view, objectId);
+ const asset = object && findAsset(project, object.assetId);
+ if (!view || !object || !asset) return false;
host.nameField?.setValue(object.name);
for (const axis of POSITION_AXES) host.fields.get(axis)?.setValue(object.position[axis]);
for (const axis of OFFSET_AXES) host.fields.get(offsetKey(axis))?.setValue(object.editorOffset[axis]);
const values = resolveSceneObjectProps(asset, object);
for (const def of schemaForAsset(asset)) host.fields.get(def.key)?.setValue(values[def.key] ?? def.default);
- markObjectRows(host, object, asset, templateLinkOf(host.state.project, holder, object));
+ markObjectRows(host, object, asset, targetOf(project, holderId, objectId));
return true;
}
@@ -244,8 +307,8 @@ interface ComponentSpec {
key: string;
label: string;
axis: keyof V & string;
- read: (object: SceneObject) => V;
- write: (object: SceneObject, value: V) => void;
+ read: (ref: ObjectRef) => V;
+ write: (ref: ObjectRef, value: V) => void;
command: (value: V) => Command;
reset?: FieldActions;
apply?: FieldActions;
@@ -268,12 +331,12 @@ function componentRow(
host,
{ key: spec.key, label: spec.label, kind: 'number', default: 0, step: 1 },
value,
- previewingHandlers(host, {
- target: () => currentObject(host.state, holderId, objectId),
+ previewingHandlers(host, {
+ target: () => currentRef(host.state, holderId, objectId),
// Copied: the snapshot outlives the scrub, the vector in the model does not.
- read: (object) => ({ ...spec.read(object) }),
+ read: (ref) => ({ ...spec.read(ref) }),
write: spec.write,
- toModel: (next, object) => withAxis(spec.read(object), next),
+ toModel: (next, ref) => withAxis(spec.read(ref), next),
commit: spec.command,
}),
{ reset: spec.reset, apply: spec.apply },
@@ -286,10 +349,9 @@ function componentRow(
* One editable property of a placed object: writes an override, offers ↺ (back to the asset
* or, inside a container, to the template) and ↑ (apply upwards).
*
- * Inside a container a value has two bases – the template it was instantiated from and, one
- * step further up, the asset – so ↺/↑ can become a menu. Dropping the override is what makes
- * the object follow the asset again: the template is only read when the container is placed,
- * never at draw time.
+ * Inside a container a value has two bases – the template it follows and, one step further up,
+ * the asset. Reset always goes to the nearest one, so content of a placed container offers only
+ * "Reset to container"; "Apply to asset" still reaches past it, dropping the template's override.
*/
function propertyRow(
host: InspectorHost,
@@ -304,11 +366,11 @@ function propertyRow(
host,
def,
values[def.key] ?? def.default,
- previewingHandlers(host, {
- target: () => currentObject(host.state, holderId, objectId),
- read: (object) => object.overrides[def.key],
- write: (object, value) => (object.overrides[def.key] = value),
- remove: (object) => delete object.overrides[def.key],
+ previewingHandlers(host, {
+ target: () => currentRef(host.state, holderId, objectId),
+ read: (ref) => ref.object.overrides[def.key],
+ write: (ref, value) => ref.setOverride(def.key, value),
+ remove: (ref) => ref.setOverride(def.key, undefined),
commit: (value) => new SetOverrideCommand(holderId, objectId, def.key, value),
}),
{
@@ -342,7 +404,7 @@ function propertyRow(
];
},
apply: () => {
- const { template, asset } = bases();
+ const { template, applyToAsset } = bases();
return [
...(template
? [
@@ -353,7 +415,7 @@ function propertyRow(
},
]
: []),
- ...(asset
+ ...(applyToAsset
? [
{
label: 'Apply to asset',
@@ -373,22 +435,28 @@ function propertyRow(
/**
* "Parent: ▾" – picks another object of the same scene or container, or none. The object's own
- * subtree is excluded (that would be a cycle). For behaviors the parent is the owner.
+ * subtree is excluded (that would be a cycle). For behaviors the parent is the owner. Content of a
+ * placed container only shows its parent: where it hangs is the template's business.
*/
-function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObject) {
+function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObject, fixed: boolean) {
const { row } = labelledRow('Parent');
const nameOf = (o: SceneObject) => o.name || findAsset(host.state.project, o.assetId)?.name || '?';
const parent = findObject(holder, object.parentId);
- const button = new Dropdown(parent ? nameOf(parent) : 'None');
+ const name = parent ? nameOf(parent) : 'None';
+ if (fixed) {
+ row.add(new Label(name, { style: 'muted' }), 1);
+ return row;
+ }
+ const button = new Dropdown(name);
button.onClick = () => {
const items: MenuItem[] = [
{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(holder.id, object.id, '')) },
];
for (const { object: candidate, depth } of treeOrder(holder)) {
if (isSelfOrAncestor(holder, object.id, candidate.id)) continue;
- const name = `${' '.repeat(depth)}${nameOf(candidate)}`;
+ const label = `${' '.repeat(depth)}${nameOf(candidate)}`;
items.push({
- label: candidate.id === object.parentId ? `● ${name}` : name,
+ label: candidate.id === object.parentId ? `● ${label}` : label,
onSelect: () => host.state.execute(new SetParentCommand(holder.id, object.id, candidate.id)),
});
}
@@ -400,49 +468,51 @@ function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObjec
}
/**
- * Bold marks exactly what the ↺/↑ buttons offer: for a container instance a deviation from
- * the template (position per axis, overrides), for any other object one from the asset. The
+ * Bold marks exactly what the ↺/↑ buttons offer: for content of a placed container a deviation
+ * from the template (position per axis, overrides), for any other object one from the asset. The
* editor offset has one base only – no offset at all.
*/
function markObjectRows(
host: InspectorHost,
object: SceneObject,
asset: Asset,
- link: TemplateLink | undefined,
+ target: InstanceTarget | undefined,
): void {
for (const axis of POSITION_AXES)
host.fields
.get(axis)
- ?.setModified(Boolean(link) && object.position[axis] !== link!.template.position[axis]);
+ ?.setModified(Boolean(target) && object.position[axis] !== target!.template.position[axis]);
for (const axis of OFFSET_AXES)
host.fields.get(offsetKey(axis))?.setModified(object.editorOffset[axis] !== 0);
for (const def of schemaForAsset(asset)) {
- const deviates = link
- ? deviatesFromTemplate(object, link.template, def.key)
+ const deviates = target
+ ? deviatesFromTemplate(object, target.template, def.key)
: deviatesFromAsset(object, asset, def.key);
host.fields.get(def.key)?.setModified(deviates);
}
}
/**
- * The two bases a property can be reset to or applied upwards, each only when it actually
- * differs: the template of the container the object belongs to, and the asset behind it.
- * Evaluated on demand so a menu never acts on a stale snapshot.
+ * What the ↺/↑ menus may offer, each only when it actually differs. Reset goes to the nearest base
+ * only: dropping a deviation inside a container lands on the template, never past it. Evaluated on
+ * demand so a menu never acts on a stale snapshot.
*/
function deviations(
state: InspectorHost['state'],
holderId: string,
objectId: string,
key: string,
-): { template: boolean; asset: boolean } {
- const holder = findObjectHolder(state.project, holderId);
- const object = holder && findObject(holder, objectId);
+): { template: boolean; asset: boolean; applyToAsset: boolean } {
+ const view = viewOf(state.project, holderId);
+ const object = view && findObject(view, objectId);
const asset = object && findAsset(state.project, object.assetId);
- if (!holder || !object || !asset) return { template: false, asset: false };
- const link = templateLinkOf(state.project, holder, object);
+ if (!view || !object || !asset) return { template: false, asset: false, applyToAsset: false };
+ const target = targetOf(state.project, holderId, objectId);
+ const fromAsset = deviatesFromAsset(object, asset, key);
return {
- template: Boolean(link) && deviatesFromTemplate(object, link!.template, key),
- asset: deviatesFromAsset(object, asset, key),
+ template: Boolean(target) && deviatesFromTemplate(object, target!.template, key),
+ asset: !target && fromAsset,
+ applyToAsset: fromAsset,
};
}
@@ -453,24 +523,23 @@ function positionMoved(
objectId: string,
axis: Axis,
): boolean {
- const holder = findObjectHolder(state.project, holderId);
- const object = holder && findObject(holder, objectId);
- const link = holder && object && templateLinkOf(state.project, holder, object);
- return Boolean(link) && object!.position[axis] !== link!.template.position[axis];
+ const target = targetOf(state.project, holderId, objectId);
+ const object = currentRef(state, holderId, objectId)?.object;
+ return Boolean(target && object) && object!.position[axis] !== target!.template.position[axis];
}
-function currentObject(
+function currentRef(
state: InspectorHost['state'],
holderId: string,
objectId: string,
-): SceneObject | undefined {
+): ObjectRef | undefined {
const holder = findObjectHolder(state.project, holderId);
- return holder && findObject(holder, objectId);
+ return holder && objectRef(state.project, holder, objectId);
}
/** The values the object shows right now, asset defaults and overrides merged. */
function currentProps(state: InspectorHost['state'], holderId: string, objectId: string): PropertyBag | null {
- const object = currentObject(state, holderId, objectId);
+ const object = currentRef(state, holderId, objectId)?.object;
const asset = object && findAsset(state.project, object.assetId);
return object && asset ? resolveSceneObjectProps(asset, object) : null;
}
diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts
index b15fddc7..3836e280 100644
--- a/src/editor/panels/HierarchyPanel.ts
+++ b/src/editor/panels/HierarchyPanel.ts
@@ -1,6 +1,8 @@
+import { UnpackContainerCommand } from '../../core/commands/containerCommands';
import { SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { hasColorSwatch } from '../../core/model/AssetTypes';
import { ancestorIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy';
+import { isLiveInstance, isTemplateContent, resolvedHolder } from '../../core/model/instance';
import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { resolveSceneObjectProps } from '../../core/properties/resolve';
import { ListView, Menu, Panel, ScrollContainer } from '../../ui';
@@ -79,8 +81,10 @@ export class HierarchyPanel extends Panel {
this.refresh();
}
+ /** With the content of placed containers resolved in – the rows show it like any other object. */
private holder(): ObjectHolder | undefined {
- return this.source();
+ const holder = this.source();
+ return holder && resolvedHolder(this.state.project, holder);
}
/** Ids of the rows currently shown – subtrees of collapsed objects are not among them (test hook). */
@@ -113,6 +117,11 @@ export class HierarchyPanel extends Panel {
const inScene = holder.type === 'scene';
const object = findObject(holder, id);
const type = object && findAsset(state.project, object.assetId)?.type;
+ // Content of a placed container belongs to its template: values can be changed here, the
+ // structure cannot (see `model/instance.ts`).
+ const source = this.source();
+ const fromTemplate = Boolean(source && isTemplateContent(source, id));
+ const instance = Boolean(object && source && isLiveInstance(state.project, source, object));
// Inside a multi-selection the menu acts on all selected objects.
const selected = state.selectedObjectIds.includes(id) ? state.selectedObjectIds : [id];
const many = selected.length > 1;
@@ -123,16 +132,22 @@ export class HierarchyPanel extends Panel {
},
{
label: 'Add collider',
- disabled: !type || type === 'collider' || many,
+ disabled: !type || type === 'collider' || many || fromTemplate || instance,
onSelect: () => addCollider(state, holder.id, id),
},
{
label: many ? `Create container from ${selected.length} objects` : 'Create container',
+ disabled: !source || selected.some((oid) => isTemplateContent(source, oid)),
onSelect: () => createContainer(state, holder.id, selected),
},
+ {
+ label: 'Unpack container',
+ disabled: !instance,
+ onSelect: () => state.execute(new UnpackContainerCommand(holder.id, id)),
+ },
{
label: 'Unparent (move to top level)',
- disabled: !object?.parentId || many,
+ disabled: !object?.parentId || many || fromTemplate,
onSelect: () => state.execute(new SetParentCommand(holder.id, id, '')),
},
{ label: 'Expand all', onSelect: () => this.expandAll() },
@@ -142,6 +157,7 @@ export class HierarchyPanel extends Panel {
? `Remove ${selected.length} objects from ${inScene ? 'scene' : 'container'}`
: `Remove from ${inScene ? 'scene' : 'container'}`,
danger: true,
+ disabled: !source || selected.some((oid) => isTemplateContent(source, oid)),
onSelect: () => removeObjects(state, holder.id, selected),
},
]);
diff --git a/src/editor/state.ts b/src/editor/state.ts
index 47486daa..f2363aa6 100644
--- a/src/editor/state.ts
+++ b/src/editor/state.ts
@@ -2,6 +2,7 @@ import { type Command, CommandError } from '../core/commands/Command';
import { CommandStack } from '../core/commands/CommandStack';
import type { Asset, SceneAsset } from '../core/model/Asset';
import { findObject } from '../core/model/hierarchy';
+import { resolvedHolder } from '../core/model/instance';
import {
createEmptyProject,
findAsset,
@@ -232,7 +233,8 @@ export class EditorState {
if (s.kind === 'asset' && !findAsset(this.project, s.assetId)) this.select(null);
if (s.kind === 'object') {
const holder = findObjectHolder(this.project, s.holderId);
- const alive = holder ? s.objectIds.filter((id) => findObject(holder, id)) : [];
+ const view = holder && resolvedHolder(this.project, holder);
+ const alive = view ? s.objectIds.filter((id) => findObject(view, id)) : [];
if (alive.length !== s.objectIds.length) this.select(objectSelection(s.holderId, alive, s.objectId));
}
if (s.kind === 'node') {
@@ -287,7 +289,8 @@ export class EditorState {
if (s.kind === 'asset') return findAsset(this.project, s.assetId);
if (s.kind !== 'object') return undefined;
const holder = findObjectHolder(this.project, s.holderId);
- const assetId = holder && findObject(holder, s.objectId)?.assetId;
+ const view = holder && resolvedHolder(this.project, holder);
+ const assetId = view && findObject(view, s.objectId)?.assetId;
return assetId ? findAsset(this.project, assetId) : undefined;
}
diff --git a/src/runtime/World.ts b/src/runtime/World.ts
index d5893213..098ac2d3 100644
--- a/src/runtime/World.ts
+++ b/src/runtime/World.ts
@@ -2,7 +2,7 @@ import { isHandleEnabled, type PointerState, worldPositionOf } from '../core/gra
import { newId } from '../core/ids';
import type { Asset } from '../core/model/Asset';
import { isPlaceableType } from '../core/model/AssetTypes';
-import { instantiateContainer } from '../core/model/container';
+import { bakeInstances, materializeInstance } from '../core/model/instance';
import { findObject } from '../core/model/hierarchy';
import { descendantsBelow } from '../core/model/walk';
import { findAsset, findScene, type Project } from '../core/model/Project';
@@ -77,6 +77,10 @@ export class World {
syncFromModel(): void {
const scene = findScene(this.project, this.sceneId);
if (!scene) return;
+ // Entities are built from stored objects, and a behavior may move or destroy one of them –
+ // which content resolved from a container template could not remember. So it is written out
+ // once, into the play-mode copy of the model that is thrown away afterwards.
+ if (bakeInstances(this.project, scene)) this.structureChanged = true;
const background = scene.props.backgroundColor;
this.backgroundColor = typeof background === 'number' ? background : 0x000000;
const keep = new Set();
@@ -141,15 +145,13 @@ export class World {
if (!scene || !asset || !isPlaceableType(asset.type)) return null;
const parentEntity = parent && this.entities.includes(parent) ? parent : null;
const object = createSceneObject(newId(), asset.id, position, parentEntity?.objectId ?? '');
+ scene.objects.push(object);
// A container brings its content along (parented below it, in template order).
- const created = [
- object,
- ...(asset.type === 'container' ? instantiateContainer(this.project, asset, object.id) : []),
- ];
+ const created = asset.type === 'container' ? materializeInstance(this.project, scene, object) : [];
scene.objects.push(...created);
const entity = this.materialize(object, asset);
entity.parent = parentEntity;
- for (const child of created.slice(1)) {
+ for (const child of created) {
const childAsset = findAsset(this.project, child.assetId);
if (!childAsset || !isPlaceableType(childAsset.type)) continue;
this.materialize(child, childAsset).parent = this.findByObjectId(child.parentId) ?? null;
diff --git a/src/viewport/SceneViewport.ts b/src/viewport/SceneViewport.ts
index b48ba345..2f78eb21 100644
--- a/src/viewport/SceneViewport.ts
+++ b/src/viewport/SceneViewport.ts
@@ -8,6 +8,7 @@ import { CompoundCommand } from '../core/commands/CompoundCommand';
import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
import { isSymbolType } from '../core/model/AssetTypes';
import { findObject, subtreeRoots } from '../core/model/hierarchy';
+import { objectRef, resolvedHolder } from '../core/model/instance';
import type { Position, Vec2 } from '../core/model/Properties';
import type { ObjectHolder } from '../core/model/Project';
import { footprintOf } from '../core/properties/bounds';
@@ -79,6 +80,12 @@ export class SceneViewport extends WorldViewport {
return this.state.activeScene;
}
+ /** The holder with the content of placed containers resolved in – what the view shows. */
+ private resolved(): ObjectHolder | undefined {
+ const holder = this.holder();
+ return holder && resolvedHolder(this.state.project, holder);
+ }
+
/** World coordinates at the centre of the view. */
worldCenter(): Vec2 {
return { x: Math.round(this.view.x), y: Math.round(this.view.y) };
@@ -173,13 +180,11 @@ export class SceneViewport extends WorldViewport {
if (!holder) return;
// A world-space delta is a local-space delta as well (no rotation/scale in the hierarchy).
for (const item of this.dragging.items) {
- const object = findObject(holder, item.objectId);
- if (object)
- object.position = {
- x: Math.round(item.start.x + dx),
- y: Math.round(item.start.y + dy),
- z: item.start.z,
- };
+ objectRef(this.state.project, holder, item.objectId)?.setPosition({
+ x: Math.round(item.start.x + dx),
+ y: Math.round(item.start.y + dy),
+ z: item.start.z,
+ });
}
this.state.preview();
} else if (this.marquee) {
@@ -212,10 +217,10 @@ export class SceneViewport extends WorldViewport {
// The preview moved the objects already; put them back so the command records the start.
const moves: MoveSceneObjectCommand[] = [];
for (const item of drag.items) {
- const object = findObject(holder, item.objectId);
- if (!object) continue;
- const next = { ...object.position };
- object.position = { ...item.start };
+ const ref = objectRef(this.state.project, holder, item.objectId);
+ if (!ref) continue;
+ const next = { ...ref.object.position };
+ ref.setPosition(item.start);
moves.push(new MoveSceneObjectCommand(drag.holderId, item.objectId, next));
}
if (moves.length === 1) this.state.execute(moves[0]!);
@@ -235,7 +240,7 @@ export class SceneViewport extends WorldViewport {
protected override onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
if (e.button !== 0) return;
- const holder = this.holder();
+ const holder = this.resolved();
const object = holder && findObject(holder, objectId);
if (!holder || !object) return;
e.stopPropagation();
diff --git a/src/viewport/items.ts b/src/viewport/items.ts
index 7d22315b..63754dba 100644
--- a/src/viewport/items.ts
+++ b/src/viewport/items.ts
@@ -1,6 +1,7 @@
import { isHandleEnabled, worldPositionOf } from '../core/graph';
import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
import { editorPosition, treeOrder } from '../core/model/hierarchy';
+import { resolvedHolder } from '../core/model/instance';
import type { PropertyBag } from '../core/model/Properties';
import { findAsset, type ObjectHolder, type Project } from '../core/model/Project';
import { resolveTexture, type TextureRef } from '../core/model/texture';
@@ -33,7 +34,8 @@ interface DrawList {
/**
* A scene or a container template, in tree order – the tie-break for equal `z` – at the editor
- * positions. A container has no background of its own, so it keeps the fallback.
+ * positions, with the content of placed containers resolved in. A container has no background of
+ * its own, so it keeps the fallback.
*/
export function collectHolderItems(
project: Project,
@@ -41,8 +43,9 @@ export function collectHolderItems(
fallbackBackground: number,
): DrawList {
if (!holder) return { items: [], background: fallbackBackground };
+ const view = resolvedHolder(project, holder);
const items: DrawItem[] = [];
- for (const { object } of treeOrder(holder)) {
+ for (const { object } of treeOrder(view)) {
const asset = findAsset(project, object.assetId);
if (!asset) continue;
const props = resolveSceneObjectProps(asset, object);
@@ -50,7 +53,7 @@ export function collectHolderItems(
id: object.id,
type: asset.type,
name: object.name || asset.name,
- ...editorPosition(holder, object),
+ ...editorPosition(view, object),
props,
parentId: object.parentId,
enabled: true,
diff --git a/tests/core/apply.test.ts b/tests/core/apply.test.ts
index ed3d3945..1e31b492 100644
--- a/tests/core/apply.test.ts
+++ b/tests/core/apply.test.ts
@@ -9,39 +9,46 @@ import {
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
import { MoveSceneObjectCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import type { ContainerAsset } from '../../src/core/model/Asset';
-import { templateLinkOf } from '../../src/core/model/container';
+import { instanceChildId, instanceTargetOf } from '../../src/core/model/instance';
import { findAsset } from '../../src/core/model/Project';
import { makeSpriteScene, place } from '../helpers';
/**
- * A container made from two boxes (a at (100,100), b at (10,0)) and a second placed instance
- * of it. `a` is the original, `a2` its copy in the instance.
+ * A container made from two boxes (a at (100,100), b at (10,0)) and a second placed instance of it.
+ * `a` is the content of the first instance, `a2` the same template object in the second – neither
+ * is stored in the scene, both are resolved from the template.
*/
function setup() {
const f = makeSpriteScene();
- const a = place(f, f.spriteId, { x: 100, y: 100 });
- const b = place(f, f.spriteId, { x: 10, y: 0 });
- const create = new CreateContainerCommand(f.sceneId, [a, b]);
+ const create = new CreateContainerCommand(f.sceneId, [
+ place(f, f.spriteId, { x: 100, y: 100 }),
+ place(f, f.spriteId, { x: 10, y: 0 }),
+ ]);
f.stack.execute(create);
const instance = place(f, create.assetId, { x: 1000, y: 0 });
const container = () => findAsset(f.project, create.assetId) as ContainerAsset;
- const templateOfA = () => container().objects.find((t) => t.id === f.object(a).templateId)!;
- const a2 = f
- .sceneOf()
- .objects.find((o) => o.parentId === instance && o.templateId === f.object(a).templateId)!.id;
- return { ...f, a, b, a2, container, templateOfA };
+ const templateA = container().objects[0]!.id;
+ return {
+ ...f,
+ instanceId: create.objectId,
+ instance,
+ container,
+ templateA,
+ a: instanceChildId(create.objectId, templateA),
+ a2: instanceChildId(instance, templateA),
+ templateOfA: () => container().objects.find((t) => t.id === templateA)!,
+ };
}
describe('template link', () => {
- it('resolves the container and template for instance objects only', () => {
- const { project, a, a2, sceneOf, object, container, templateOfA } = setup();
+ it('resolves the container and template for resolved content only', () => {
+ const { project, a, a2, sceneOf, instanceId, container, templateOfA } = setup();
const scene = sceneOf();
- const link = templateLinkOf(project, scene, object(a2));
- expect(link?.asset.id).toBe(container().id);
- expect(link?.template).toBe(templateOfA());
- expect(templateLinkOf(project, scene, object(a))?.template).toBe(templateOfA());
- const containerObject = scene.objects.find((o) => o.assetId === container().id)!;
- expect(templateLinkOf(project, scene, containerObject)).toBeUndefined();
+ expect(instanceTargetOf(project, scene, a2)?.asset.id).toBe(container().id);
+ expect(instanceTargetOf(project, scene, a2)?.template).toBe(templateOfA());
+ expect(instanceTargetOf(project, scene, a)?.template).toBe(templateOfA());
+ // The container object itself is stored, so it has no template behind it.
+ expect(instanceTargetOf(project, scene, instanceId)).toBeUndefined();
});
});
diff --git a/tests/core/container.test.ts b/tests/core/container.test.ts
index 3df9334b..9373b327 100644
--- a/tests/core/container.test.ts
+++ b/tests/core/container.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { DeleteAssetCommand } from '../../src/core/commands/assetCommands';
import { CommandError } from '../../src/core/commands/Command';
import { CompoundCommand } from '../../src/core/commands/CompoundCommand';
-import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
+import { CreateContainerCommand, UnpackContainerCommand } from '../../src/core/commands/containerCommands';
import {
MoveSceneObjectCommand,
PlaceAssetCommand,
@@ -17,6 +17,7 @@ import { worldPositionOf } from '../../src/core/graph';
import type { ContainerAsset } from '../../src/core/model/Asset';
import { containerReaches } from '../../src/core/model/container';
import { descendantsOf, subtreeRoots, treeOrder } from '../../src/core/model/hierarchy';
+import { instanceChildId } from '../../src/core/model/instance';
import { findAsset, findScene } from '../../src/core/model/Project';
import { createSceneObject } from '../../src/core/model/SceneObject';
import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
@@ -31,13 +32,28 @@ function setup() {
return { ...f, a, b, c };
}
-/** The container asset built from a and b (c comes along below b), plus lookups into its template. */
+/**
+ * The container asset built from a, b and c – which leaves the scene holding the container object
+ * alone. `child` addresses the content the instance resolves; `tA`/`tB`/`tC` are the template
+ * objects it comes from, in the order `CreateContainerCommand` built them (tree order).
+ */
function templateSetup() {
const f = setup();
const create = new CreateContainerCommand(f.sceneId, [f.a, f.b]);
f.stack.execute(create);
const asset = () => findAsset(f.project, create.assetId) as ContainerAsset;
- return { ...f, containerId: create.assetId, asset, templateIdOf: (id: string) => f.object(id).templateId };
+ const [tA, tB, tC] = asset().objects.map((t) => t.id) as [string, string, string];
+ return {
+ ...f,
+ containerId: create.assetId,
+ instanceId: create.objectId,
+ asset,
+ tA,
+ tB,
+ tC,
+ template: (id: string) => asset().objects.find((t) => t.id === id)!,
+ child: (templateId: string, instanceId = create.objectId) => instanceChildId(instanceId, templateId),
+ };
}
/** Three container assets holding one box each, none of them nested yet. */
@@ -61,59 +77,65 @@ describe('subtree roots', () => {
});
describe('CreateContainerCommand', () => {
- it('centres the template on the selection and re-parents the roots, keeping world positions', () => {
- const { project, stack, sceneId, spriteId, a, b, c, sceneOf, object, world } = setup();
+ it('centres the template on the selection and shows it again through the placed container', () => {
+ const { project, stack, sceneId, spriteId, a, b, c, sceneOf, viewOf, object, world } = setup();
const before = { a: world(a), b: world(b), c: world(c) };
const command = new CreateContainerCommand(sceneId, [a, b]);
stack.execute(command);
// Bounding box of (100,100) and (10,0) → centre (55,50); z is always 0.
const container = object(command.objectId);
- expect(container.assetId).toBe(command.assetId);
- expect(container.parentId).toBe('');
expect(container.position).toEqual({ x: 55, y: 50, z: 0 });
- expect(object(a).parentId).toBe(command.objectId);
- expect(object(a).position).toEqual({ x: 45, y: 50, z: 1 });
- expect(object(b).position).toEqual({ x: -45, y: -50, z: 0 });
- expect(object(c).parentId).toBe(b); // descendants keep their local frame
- expect(object(c).position).toEqual({ x: -10, y: 5, z: 0 });
- expect({ a: world(a), b: world(b), c: world(c) }).toEqual(before);
+ // The members are gone from the scene: the container object is all that is stored.
+ expect(sceneOf().objects.map((o) => o.id)).toEqual([command.objectId]);
- // The asset holds the same structure with private ids; the members point at it.
const asset = findAsset(project, command.assetId) as ContainerAsset;
expect(asset.type).toBe('container');
expect(asset.objects).toHaveLength(3);
- const template = (id: string) => asset.objects.find((t) => t.id === object(id).templateId)!;
- expect(template(a).position).toEqual({ x: 45, y: 50, z: 1 });
- expect(template(a).parentId).toBe('');
- expect(template(c).parentId).toBe(template(b).id);
- expect(template(c).assetId).toBe(spriteId);
+ const [tA, tB, tC] = asset.objects;
+ expect(tA!.position).toEqual({ x: 45, y: 50, z: 1 });
+ expect(tA!.parentId).toBe('');
+ expect(tC!.parentId).toBe(tB!.id);
+ expect(tC!.assetId).toBe(spriteId);
expect(asset.objects.map((t) => t.id)).not.toContain(a);
- expect(treeOrder(sceneOf()).map((e) => e.object.id)).toEqual([command.objectId, a, b, c]);
+
+ // What the editor lists: the container plus the content resolved from the template, and the
+ // three objects still stand exactly where they did.
+ const ids = treeOrder(viewOf()).map((e) => e.object.id);
+ expect(ids).toEqual([
+ command.objectId,
+ instanceChildId(command.objectId, tA!.id),
+ instanceChildId(command.objectId, tB!.id),
+ instanceChildId(command.objectId, tC!.id),
+ ]);
+ expect(world(ids[1]!)).toEqual(before.a);
+ expect(world(ids[2]!)).toEqual(before.b);
+ expect(world(ids[3]!)).toEqual(before.c);
stack.undo();
expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c]);
expect(object(a).parentId).toBe('');
expect(object(a).position).toEqual({ x: 100, y: 100, z: 1 });
- expect(object(a).templateId).toBe('');
expect(project.assets.some((x) => x.id === command.assetId)).toBe(false);
stack.redo();
- expect(object(a).parentId).toBe(command.objectId);
- expect(world(a)).toEqual(before.a);
+ expect(world(instanceChildId(command.objectId, tA!.id))).toEqual(before.a);
});
it('places the container below the parent all roots share', () => {
const f = setup();
- const { stack, sceneId, spriteId, b, c, object, world } = f;
+ const { project, stack, sceneId, spriteId, b, c, object, world } = f;
const d = place(f, spriteId, { x: 30, y: 25 }, b);
const command = new CreateContainerCommand(sceneId, [c, d]);
stack.execute(command);
+
const container = object(command.objectId);
expect(container.parentId).toBe(b);
// World centre of c (0,5) and d (30,25) is (15,15); b is at (10,0) → local (5,15).
expect(container.position).toEqual({ x: 5, y: 15, z: 0 });
- expect(world(c)).toEqual({ x: 0, y: 5, z: 0 });
- expect(world(d)).toEqual({ x: 30, y: 25, z: 0 });
+ const asset = findAsset(project, command.assetId) as ContainerAsset;
+ const [tc, td] = asset.objects.map((t) => instanceChildId(command.objectId, t.id));
+ expect(world(tc!)).toEqual({ x: 0, y: 5, z: 0 });
+ expect(world(td!)).toEqual({ x: 30, y: 25, z: 0 });
});
it('rejects an empty or unknown selection', () => {
@@ -124,64 +146,55 @@ describe('CreateContainerCommand', () => {
});
describe('placing a container', () => {
- it('instantiates the template with fresh ids below the placed object', () => {
- const f = setup();
- const { project, stack, sceneId, a, b, c, sceneOf, object, world } = f;
- const create = new CreateContainerCommand(sceneId, [a, b]);
- stack.execute(create);
- const placed = place(f, create.assetId, { x: 1000, y: 0 });
+ it('stores one object and resolves the template below it', () => {
+ const f = templateSetup();
+ const { stack, sceneOf, viewOf, world, containerId, tA, tB, tC } = f;
+ const stored = sceneOf().objects.length;
+ const placed = place(f, containerId, { x: 1000, y: 0 });
- const copies = sceneOf().objects.filter((o) => ![a, b, c, create.objectId].includes(o.id));
- expect(copies).toHaveLength(4); // container + 3 template objects
- const root = object(placed);
- expect(root.assetId).toBe(create.assetId);
- const asset = findAsset(project, create.assetId) as ContainerAsset;
- const copyOf = (id: string) => copies.find((o) => o.templateId === object(id).templateId)!;
- expect(copyOf(a).parentId).toBe(placed);
- expect(world(copyOf(a).id)).toEqual({ x: 1045, y: 50, z: 1 });
- expect(copyOf(c).parentId).toBe(copyOf(b).id);
- expect(world(copyOf(c).id)).toEqual({ x: 945, y: -45, z: 0 });
- expect(new Set(copies.map((o) => o.id)).size).toBe(4);
- expect(asset.objects.map((t) => t.id)).not.toContain(copyOf(a).id);
+ expect(sceneOf().objects).toHaveLength(stored + 1);
+ expect(descendantsOf(viewOf(), placed).map((o) => o.id)).toEqual([
+ instanceChildId(placed, tA),
+ instanceChildId(placed, tB),
+ instanceChildId(placed, tC),
+ ]);
+ expect(world(instanceChildId(placed, tA))).toEqual({ x: 1045, y: 50, z: 1 });
+ expect(world(instanceChildId(placed, tC))).toEqual({ x: 945, y: -45, z: 0 });
stack.undo();
- expect(sceneOf().objects).toHaveLength(4);
- expect(sceneOf().objects.some((o) => o.id === placed)).toBe(false);
+ expect(sceneOf().objects).toHaveLength(stored);
+ expect(viewOf().objects.some((o) => o.id === placed)).toBe(false);
});
- it('survives a save/load round trip', () => {
- const { project, stack, sceneId, a, b } = setup();
- const create = new CreateContainerCommand(sceneId, [a, b]);
- stack.execute(create);
+ it('survives a save/load round trip, instance edits included', () => {
+ const f = templateSetup();
+ const { project, stack, sceneId, containerId, instanceId, tA } = f;
+ stack.execute(new SetOverrideCommand(sceneId, f.child(tA), 'width', 12));
const loaded = roundTrip(project);
- const asset = findAsset(loaded, create.assetId) as ContainerAsset;
- expect(asset.type).toBe('container');
+ const asset = findAsset(loaded, containerId) as ContainerAsset;
expect(asset.objects).toHaveLength(3);
const scene = findScene(loaded, sceneId)!;
- expect(scene.objects.find((o) => o.id === a)?.templateId).toBe(asset.objects[0]!.id);
- expect(scene.objects.find((o) => o.id === create.objectId)?.assetId).toBe(create.assetId);
+ const instance = scene.objects.find((o) => o.id === instanceId)!;
+ expect(instance.assetId).toBe(containerId);
+ expect(instance.instanceEdits[tA]?.overrides.width).toBe(12);
});
- it('deleting an asset removes its template objects from containers too', () => {
- const { project, stack, sceneId, spriteId, a, b, sceneOf } = setup();
- const create = new CreateContainerCommand(sceneId, [a, b]);
- stack.execute(create);
- const asset = () => findAsset(project, create.assetId) as ContainerAsset;
+ it('deleting an asset removes its template objects, and the content with them', () => {
+ const f = templateSetup();
+ const { project, stack, spriteId, containerId, asset, viewOf } = f;
stack.execute(new DeleteAssetCommand(spriteId));
expect(asset().objects).toHaveLength(0);
- expect(sceneOf().objects.map((o) => o.id)).toEqual([create.objectId]);
+ expect(viewOf().objects).toHaveLength(1); // the container object, now empty
stack.undo();
expect(asset().objects).toHaveLength(3);
- expect(sceneOf().objects).toHaveLength(4);
+ expect(viewOf().objects).toHaveLength(4);
+ expect(findAsset(project, containerId)).toBeDefined();
});
it('spawns a container with its content at runtime', () => {
- const f = setup();
- const { stack, sceneId, a, b, sceneOf } = f;
- const create = new CreateContainerCommand(sceneId, [a, b]);
- stack.execute(create);
+ const f = templateSetup();
const { world } = startWorld(f);
- const spawned = world.spawn(create.assetId, { x: 500, y: 500, z: 0 }, null)!;
+ const spawned = world.spawn(f.containerId, { x: 500, y: 500, z: 0 }, null)!;
expect(spawned.type).toBe('container');
const children = world.entities.filter((e) => e.parent === spawned);
expect(children).toHaveLength(2);
@@ -189,138 +202,204 @@ describe('placing a container', () => {
expect(worldPositionOf(children.find((e) => e.z === 1)!)).toEqual({ x: 545, y: 550, z: 1 });
expect(worldPositionOf(grandchild)).toEqual({ x: 445, y: 455, z: 0 });
expect(world.syncToModel()).toBe(true);
- expect(sceneOf().objects).toHaveLength(8);
+ // Play bakes the placed container into stored objects, and the spawn adds a second set.
+ expect(f.sceneOf().objects).toHaveLength(8);
});
});
describe('editing a container template', () => {
- it('moves, renames, overrides and removes template objects addressed by the container id', () => {
- const { stack, containerId, a, asset, object, templateIdOf } = templateSetup();
- const templateA = templateIdOf(a);
- const template = () => asset().objects.find((t) => t.id === templateA);
+ it('reaches the content of every placed instance', () => {
+ const f = templateSetup();
+ const { stack, containerId, tA, template, object, child } = f;
+ const second = place(f, containerId, { x: 1000, y: 0 });
- stack.execute(new MoveSceneObjectCommand(containerId, templateA, { x: 7, y: 8, z: 9 }));
- stack.execute(new RenameSceneObjectCommand(containerId, templateA, 'Hero'));
- stack.execute(new SetOverrideCommand(containerId, templateA, 'width', 55));
- expect(template()!.position).toEqual({ x: 7, y: 8, z: 9 });
- expect(template()!.name).toBe('Hero');
- expect(template()!.overrides.width).toBe(55);
- // a is a copy of that template and held its old values, so it follows all three.
- expect(object(a).position).toEqual({ x: 7, y: 8, z: 9 });
- expect(object(a).name).toBe('Hero');
- expect(object(a).overrides.width).toBe(55);
+ stack.execute(new MoveSceneObjectCommand(containerId, tA, { x: 7, y: 8, z: 9 }));
+ stack.execute(new RenameSceneObjectCommand(containerId, tA, 'Hero'));
+ stack.execute(new SetOverrideCommand(containerId, tA, 'width', 55));
+ expect(template(tA).position).toEqual({ x: 7, y: 8, z: 9 });
- stack.execute(new RemoveSceneObjectCommand(containerId, templateA));
- expect(template()).toBeUndefined();
- expect(asset().objects).toHaveLength(2);
+ for (const instance of [f.instanceId, second]) {
+ expect(object(child(tA, instance)).position).toEqual({ x: 7, y: 8, z: 9 });
+ expect(object(child(tA, instance)).name).toBe('Hero');
+ expect(object(child(tA, instance)).overrides.width).toBe(55);
+ }
+
+ stack.execute(new RemoveSceneObjectCommand(containerId, tA));
+ expect(template(tA)).toBeUndefined();
+ expect(f.viewOf().objects.some((o) => o.id === child(tA, second))).toBe(false);
stack.undo();
- expect(template()!.overrides.width).toBe(55);
+ expect(object(child(tA, second)).overrides.width).toBe(55);
stack.undo();
- expect(template()!.overrides.width).toBeUndefined();
+ expect(object(child(tA, second)).overrides.width).toBeUndefined();
stack.undo();
- expect(template()!.name).toBe('');
+ expect(object(child(tA, second)).name).toBe('');
stack.undo();
- expect(template()!.position).toEqual({ x: 45, y: 50, z: 1 });
+ expect(object(child(tA, second)).position).toEqual({ x: 45, y: 50, z: 1 });
});
it('restores the template order when a removal is undone', () => {
- const { stack, containerId, a, b, c, asset, templateIdOf } = templateSetup();
- const ids = [templateIdOf(a), templateIdOf(b), templateIdOf(c)];
- expect(asset().objects.map((t) => t.id)).toEqual(ids);
+ const { stack, containerId, asset, tA, tB, tC } = templateSetup();
+ expect(asset().objects.map((t) => t.id)).toEqual([tA, tB, tC]);
- stack.execute(new RemoveSceneObjectsCommand(containerId, [ids[1]!]));
- expect(asset().objects.map((t) => t.id)).toEqual([ids[0]]);
+ stack.execute(new RemoveSceneObjectsCommand(containerId, [tB]));
+ expect(asset().objects.map((t) => t.id)).toEqual([tA]);
stack.undo();
- expect(asset().objects.map((t) => t.id)).toEqual(ids);
- expect(asset().objects[2]!.parentId).toBe(ids[1]);
+ expect(asset().objects.map((t) => t.id)).toEqual([tA, tB, tC]);
+ expect(asset().objects[2]!.parentId).toBe(tB);
});
it('rejects an object id that belongs to the scene rather than to the template', () => {
- const { stack, containerId, a } = templateSetup();
- expect(() => stack.execute(new RenameSceneObjectCommand(containerId, a, 'Hero'))).toThrow(/not found/);
+ const { stack, containerId, instanceId } = templateSetup();
+ expect(() => stack.execute(new RenameSceneObjectCommand(containerId, instanceId, 'Hero'))).toThrow(
+ /not found/,
+ );
});
});
-describe('a template edit reaching the placed copies', () => {
- /** The container placed a second time, with a lookup from a template object to that fresh copy. */
- function placedSetup() {
+describe('a value changed on one instance', () => {
+ it('is kept as a sparse edit and stops the value from following', () => {
const f = templateSetup();
- const before = new Set(f.sceneOf().objects.map((o) => o.id));
- place(f, f.containerId, { x: 1000, y: 0 });
- return {
- ...f,
- copyOf: (id: string) =>
- f.sceneOf().objects.find((o) => !before.has(o.id) && o.templateId === f.templateIdOf(id))!,
- };
- }
+ const { stack, sceneId, containerId, instanceId, tA, object, child, sceneOf } = f;
+ const second = place(f, containerId, { x: 1000, y: 0 });
+ stack.execute(new SetOverrideCommand(sceneId, child(tA), 'width', 12));
+ stack.execute(new SetOverrideCommand(containerId, tA, 'width', 55));
- it('carries an override to the copies that agreed and leaves a modified one alone', () => {
- const { stack, sceneId, containerId, a, object, templateIdOf, copyOf } = placedSetup();
- const modified = copyOf(a).id;
- stack.execute(new SetOverrideCommand(sceneId, modified, 'width', 12));
- stack.execute(new SetOverrideCommand(containerId, templateIdOf(a), 'width', 55));
-
- expect(object(a).overrides.width).toBe(55);
- expect(object(modified).overrides.width).toBe(12);
+ expect(object(child(tA, second)).overrides.width).toBe(55);
+ expect(object(child(tA)).overrides.width).toBe(12);
+ const instance = sceneOf().objects.find((o) => o.id === instanceId)!;
+ expect(instance.instanceEdits[tA]?.overrides.width).toBe(12);
stack.undo();
- expect(object(a).overrides.width).toBeUndefined();
- expect(object(modified).overrides.width).toBe(12);
+ expect(object(child(tA, second)).overrides.width).toBeUndefined();
+ expect(object(child(tA)).overrides.width).toBe(12);
});
- it('clears an override on the copies that shared it', () => {
- const { stack, containerId, a, object, templateIdOf } = placedSetup();
- const templateA = templateIdOf(a);
- stack.execute(new SetOverrideCommand(containerId, templateA, 'width', 55));
- expect(object(a).overrides.width).toBe(55);
- stack.execute(new ClearOverrideCommand(containerId, templateA, 'width'));
- expect(object(a).overrides.width).toBeUndefined();
- stack.undo();
- expect(object(a).overrides.width).toBe(55);
+ it('forgets the edit again once the value is put back to the template’s', () => {
+ const { stack, sceneId, instanceId, tA, child, sceneOf, object } = templateSetup();
+ const edits = () => sceneOf().objects.find((o) => o.id === instanceId)!.instanceEdits;
+ stack.execute(new MoveSceneObjectCommand(sceneId, child(tA), { x: 1, y: 50, z: 1 }));
+ expect(edits()[tA]?.position).toEqual({ x: 1 });
+
+ stack.execute(new MoveSceneObjectCommand(sceneId, child(tA), { x: 45, y: 50, z: 1 }));
+ expect(edits()[tA]).toBeUndefined();
+ expect(object(child(tA)).position).toEqual({ x: 45, y: 50, z: 1 });
});
- it('follows per axis, so a copy dragged sideways keeps only its x', () => {
- const { stack, sceneId, containerId, a, object, templateIdOf, copyOf } = placedSetup();
- const moved = copyOf(a).id;
- stack.execute(new MoveSceneObjectCommand(sceneId, moved, { x: 999, y: 50, z: 1 }));
- stack.execute(new MoveSceneObjectCommand(containerId, templateIdOf(a), { x: 7, y: 8, z: 9 }));
+ it('follows per axis, so an instance dragged sideways keeps only its x', () => {
+ const f = templateSetup();
+ const { stack, sceneId, containerId, tA, object, child } = f;
+ const second = place(f, containerId, { x: 1000, y: 0 });
+ stack.execute(new MoveSceneObjectCommand(sceneId, child(tA, second), { x: 999, y: 50, z: 1 }));
+ stack.execute(new MoveSceneObjectCommand(containerId, tA, { x: 7, y: 8, z: 9 }));
- expect(object(a).position).toEqual({ x: 7, y: 8, z: 9 });
- expect(object(moved).position).toEqual({ x: 999, y: 8, z: 9 });
+ expect(object(child(tA)).position).toEqual({ x: 7, y: 8, z: 9 });
+ expect(object(child(tA, second)).position).toEqual({ x: 999, y: 8, z: 9 });
stack.undo();
- expect(object(moved).position).toEqual({ x: 999, y: 50, z: 1 });
+ expect(object(child(tA, second)).position).toEqual({ x: 999, y: 50, z: 1 });
});
- it('does not leak an edit made in the scene to the other copies', () => {
- const { stack, sceneId, a, object, copyOf } = placedSetup();
- const copy = copyOf(a).id;
- stack.execute(new SetOverrideCommand(sceneId, copy, 'width', 12));
- stack.execute(new RenameSceneObjectCommand(sceneId, copy, 'Only me'));
- expect(object(a).overrides.width).toBeUndefined();
- expect(object(a).name).toBe('');
+ it('clears an override on every instance that shared it', () => {
+ const { stack, containerId, tA, object, child } = templateSetup();
+ stack.execute(new SetOverrideCommand(containerId, tA, 'width', 55));
+ expect(object(child(tA)).overrides.width).toBe(55);
+ stack.execute(new ClearOverrideCommand(containerId, tA, 'width'));
+ expect(object(child(tA)).overrides.width).toBeUndefined();
+ stack.undo();
+ expect(object(child(tA)).overrides.width).toBe(55);
});
- it('reaches the content of a container nested in another one', () => {
+ it('does not leak to the other instances', () => {
+ const f = templateSetup();
+ const { stack, sceneId, tA, object, child } = f;
+ const second = place(f, f.containerId, { x: 1000, y: 0 });
+ stack.execute(new SetOverrideCommand(sceneId, child(tA, second), 'width', 12));
+ stack.execute(new RenameSceneObjectCommand(sceneId, child(tA, second), 'Only me'));
+ expect(object(child(tA)).overrides.width).toBeUndefined();
+ expect(object(child(tA)).name).toBe('');
+ });
+});
+
+describe('the structure of a placed container', () => {
+ it('cannot be changed from the scene', () => {
+ const { stack, sceneId, tA, tB, child } = templateSetup();
+ expect(() => stack.execute(new RemoveSceneObjectCommand(sceneId, child(tA)))).toThrow(
+ /comes from a container template/,
+ );
+ expect(() => stack.execute(new SetParentCommand(sceneId, child(tA), child(tB)))).toThrow(
+ /comes from a container template/,
+ );
+ expect(() => stack.execute(new CreateContainerCommand(sceneId, [child(tA)]))).toThrow(
+ /comes from a container template/,
+ );
+ });
+
+ it('refuses to take an object of the scene as a child', () => {
+ const f = templateSetup();
+ const loose = place(f, f.spriteId, { x: 0, y: 0 });
+ expect(() => f.stack.execute(new SetParentCommand(f.sceneId, loose, f.instanceId))).toThrow(/unpack it/);
+ });
+
+ it('opens up once it is unpacked, and stops following the template', () => {
+ const f = templateSetup();
+ const { stack, sceneId, containerId, instanceId, tA, child, sceneOf, object } = f;
+ stack.execute(new SetOverrideCommand(sceneId, child(tA), 'width', 12));
+ stack.execute(new UnpackContainerCommand(sceneId, instanceId));
+
+ // The ids survive the unpack, so anything pointing at the content keeps working.
+ expect(sceneOf().objects.map((o) => o.id)).toContain(child(tA));
+ expect(object(child(tA)).overrides.width).toBe(12);
+
+ stack.execute(new SetOverrideCommand(containerId, tA, 'width', 55));
+ expect(object(child(tA)).overrides.width).toBe(12);
+ stack.execute(new RemoveSceneObjectCommand(sceneId, child(tA)));
+ expect(sceneOf().objects.map((o) => o.id)).not.toContain(child(tA));
+
+ stack.undo();
+ stack.undo();
+ stack.undo();
+ expect(sceneOf().objects.map((o) => o.id)).toEqual([instanceId]);
+ expect(object(child(tA)).overrides.width).toBe(12);
+ });
+});
+
+describe('a container nested in another one', () => {
+ it('resolves through both levels, and a template edit reaches the deep content', () => {
const f = makeContainers();
- const { stack, outerId, innerId, sceneOf, assetOf } = f;
- stack.execute(new PlaceAssetCommand(outerId, innerId, { x: 20, y: 0 }));
+ const { stack, outerId, innerId, viewOf, assetOf, world } = f;
+ const nest = new PlaceAssetCommand(outerId, innerId, { x: 20, y: 0 });
+ stack.execute(nest);
const placed = place(f, outerId, { x: 500, y: 0 });
const innerTemplate = assetOf(innerId).objects[0]!;
- const deepCopy = sceneOf().objects.find((o) => o.templateId === innerTemplate.id)!;
+ const deep = instanceChildId(placed, `${nest.objectId}/${innerTemplate.id}`);
+
+ expect(descendantsOf(viewOf(), placed)).toHaveLength(3);
+ expect(world(deep)).toEqual({ x: 520, y: 0, z: 0 });
stack.execute(new RenameSceneObjectCommand(innerId, innerTemplate.id, 'Deep'));
- expect(deepCopy.name).toBe('Deep');
- expect(descendantsOf(sceneOf(), placed)).toHaveLength(3);
-
+ expect(f.object(deep).name).toBe('Deep');
stack.undo();
- expect(deepCopy.name).toBe('');
+ expect(f.object(deep).name).toBe('');
+ });
+
+ it('keeps a value changed on the deep content out of the template', () => {
+ const f = makeContainers();
+ const { stack, sceneId, outerId, innerId, assetOf } = f;
+ const nest = new PlaceAssetCommand(outerId, innerId, { x: 20, y: 0 });
+ stack.execute(nest);
+ const placed = place(f, outerId, { x: 500, y: 0 });
+ const path = `${nest.objectId}/${assetOf(innerId).objects[0]!.id}`;
+ stack.execute(new SetOverrideCommand(sceneId, instanceChildId(placed, path), 'width', 7));
+
+ expect(f.object(instanceChildId(placed, path)).overrides.width).toBe(7);
+ expect(assetOf(innerId).objects[0]!.overrides.width).toBeUndefined();
+ expect(f.sceneOf().objects.find((o) => o.id === placed)!.instanceEdits[path]?.overrides.width).toBe(7);
});
});
describe('placing into a container template', () => {
- it('creates a single object, without expanding a placed container', () => {
+ it('creates a single object, without spelling the nested container out', () => {
const { stack, outerId, innerId, assetOf } = makeContainers();
const command = new PlaceAssetCommand(outerId, innerId, { x: 20, y: 0 });
stack.execute(command);
@@ -336,33 +415,6 @@ describe('placing into a container template', () => {
expect(assetOf(outerId).objects).toHaveLength(1);
});
- it('expands the same container when it goes into a scene instead', () => {
- const f = makeContainers();
- const before = f.sceneOf().objects.length;
- const placed = place(f, f.innerId, { x: 20, y: 0 });
- expect(f.sceneOf().objects).toHaveLength(before + 2);
- expect(descendantsOf(f.sceneOf(), placed)).toHaveLength(1);
- });
-
- it('expands the nested container once the outer one is placed into a scene', () => {
- const f = makeContainers();
- const { stack, outerId, innerId, spriteId, sceneOf, world } = f;
- const nest = new PlaceAssetCommand(outerId, innerId, { x: 20, y: 0 });
- stack.execute(nest);
- const placed = place(f, outerId, { x: 500, y: 0 });
-
- const children = sceneOf().objects.filter((o) => o.parentId === placed);
- expect(children.map((o) => o.assetId).sort()).toEqual([innerId, spriteId].sort());
- const nested = children.find((o) => o.assetId === innerId)!;
- expect(nested.templateId).toBe(nest.objectId);
- expect(world(nested.id)).toEqual({ x: 520, y: 0, z: 0 });
-
- const content = sceneOf().objects.filter((o) => o.parentId === nested.id);
- expect(content).toHaveLength(1);
- expect(content[0]!.assetId).toBe(spriteId);
- expect(world(content[0]!.id)).toEqual({ x: 520, y: 0, z: 0 });
- });
-
it('refuses a placement that would nest a container inside itself', () => {
const { stack, outerId, innerId, deepId, assetOf } = makeContainers();
expect(() => stack.execute(new PlaceAssetCommand(outerId, outerId, { x: 0, y: 0 }))).toThrow(
diff --git a/tests/core/holders.test.ts b/tests/core/holders.test.ts
index a42e28c6..db28b25b 100644
--- a/tests/core/holders.test.ts
+++ b/tests/core/holders.test.ts
@@ -16,6 +16,7 @@ import {
listContainers,
objectHolders,
} from '../../src/core/model/Project';
+import { instanceChildId } from '../../src/core/model/instance';
import { makeBehavior, makeSpriteScene, place } from '../helpers';
/** A behavior with a `speed` variable, placed and overridden to 42, then wrapped in a container. */
@@ -27,7 +28,9 @@ function containerWithOverride() {
const container = new CreateContainerCommand(f.sceneId, [objectId]);
f.stack.execute(container);
const template = () => (findAsset(f.project, container.assetId) as ContainerAsset).objects[0]!;
- return { ...f, behaviorId: behavior.id, containerId: container.assetId, objectId, template };
+ // The object moved into the template; what the scene shows is resolved from it.
+ const placed = () => f.object(instanceChildId(container.objectId, template().id));
+ return { ...f, behaviorId: behavior.id, containerId: container.assetId, objectId, template, placed };
}
describe('object holders', () => {
@@ -55,17 +58,18 @@ describe('object holders', () => {
});
it('finds usages inside container templates, not just in scenes', () => {
- const { project, behaviorId, template } = containerWithOverride();
- // The placed object and the template copy the container was built from.
- expect(findUsages(project, behaviorId)).toHaveLength(2);
+ const { project, behaviorId, containerId, template } = containerWithOverride();
+ // Only the template: what the scene shows is resolved from it, never stored.
+ const usages = findUsages(project, behaviorId);
+ expect(usages).toHaveLength(1);
+ expect(usages[0]!.holder.id).toBe(containerId);
expect(template().overrides.speed).toBe(42);
});
});
describe('RemoveVariableCommand', () => {
it('clears the override on container templates too, and puts it back on undo', () => {
- const { stack, behaviorId, objectId, sceneOf, template } = containerWithOverride();
- const placed = () => sceneOf().objects.find((o) => o.id === objectId)!;
+ const { stack, behaviorId, template, placed } = containerWithOverride();
stack.execute(new RemoveVariableCommand(behaviorId, 'speed'));
expect(placed().overrides.speed).toBeUndefined();
@@ -77,6 +81,40 @@ describe('RemoveVariableCommand', () => {
});
});
+/**
+ * A behavior inside a placed container is not stored in the scene, and a value changed on it lives
+ * in the instance's `instanceEdits`. Anything rewriting overrides project-wide has to reach in
+ * there, or renaming a variable silently drops the value.
+ */
+describe('variables overridden on container content', () => {
+ function withInstanceEdit() {
+ const f = containerWithOverride();
+ f.stack.execute(new SetOverrideCommand(f.sceneId, f.placed().id, 'speed', 7));
+ const edits = () =>
+ f.sceneOf().objects.find((o) => o.id !== f.objectId)!.instanceEdits[f.template().id]!.overrides;
+ return { ...f, edits };
+ }
+
+ it('renames the key kept on the instance', () => {
+ const { stack, behaviorId, edits, placed } = withInstanceEdit();
+ expect(edits().speed).toBe(7);
+ stack.execute(new RenameVariableCommand(behaviorId, 'speed', 'velocity'));
+ expect(edits().speed).toBeUndefined();
+ expect(edits().velocity).toBe(7);
+ expect(placed().overrides.velocity).toBe(7);
+ stack.undo();
+ expect(edits().speed).toBe(7);
+ });
+
+ it('removes the value kept on the instance, and puts it back on undo', () => {
+ const { stack, behaviorId, edits } = withInstanceEdit();
+ stack.execute(new RemoveVariableCommand(behaviorId, 'speed'));
+ expect(edits().speed).toBeUndefined();
+ stack.undo();
+ expect(edits().speed).toBe(7);
+ });
+});
+
describe('RenameVariableCommand', () => {
it('renames the override on container templates too', () => {
const { stack, behaviorId, template } = containerWithOverride();
diff --git a/tests/core/serialize.test.ts b/tests/core/serialize.test.ts
index 9a228df7..d249b0c0 100644
--- a/tests/core/serialize.test.ts
+++ b/tests/core/serialize.test.ts
@@ -56,7 +56,7 @@ describe('serialize', () => {
editorOffset: { x: 0, y: 0 },
overrides: { color: 0x00ff00, width: 1 },
parentId: '',
- templateId: '',
+ instanceEdits: {},
});
expect(project.assets[1]!.name).toBe('sprite');
expect(project.assets[1]!.props).toEqual({
diff --git a/tests/helpers.ts b/tests/helpers.ts
index 539f82cc..ecb1546f 100644
--- a/tests/helpers.ts
+++ b/tests/helpers.ts
@@ -13,6 +13,7 @@ import {
} from '../src/core/graph';
import type { BehaviorAsset, SceneAsset } from '../src/core/model/Asset';
import { findObject, worldPosition } from '../src/core/model/hierarchy';
+import { resolvedHolder } from '../src/core/model/instance';
import type { Position, PropertyBag, Vec2 } from '../src/core/model/Properties';
import { createEmptyProject, findScene, type Project } from '../src/core/model/Project';
import type { SceneObject } from '../src/core/model/SceneObject';
@@ -32,6 +33,8 @@ export interface SceneFixture extends ProjectFixture {
spriteId: string;
/** The scene asset, looked up fresh (undo may replace it). */
sceneOf(): SceneAsset;
+ /** The scene with the content of placed containers resolved in – what the editor shows. */
+ viewOf(): SceneAsset;
object(id: string): SceneObject;
/** World position of a scene object, ancestors included. */
world(id: string): Position;
@@ -51,14 +54,16 @@ export function makeSpriteScene(spriteName = 'Box'): SceneFixture {
fixture.stack.execute(scene);
fixture.stack.execute(sprite);
const sceneOf = () => findScene(fixture.project, scene.asset.id)!;
- const object = (id: string) => findObject(sceneOf(), id)!;
+ const viewOf = () => resolvedHolder(fixture.project, sceneOf()) as SceneAsset;
+ const object = (id: string) => findObject(viewOf(), id)!;
return {
...fixture,
sceneId: scene.asset.id,
spriteId: sprite.asset.id,
sceneOf,
+ viewOf,
object,
- world: (id) => worldPosition(sceneOf(), object(id)),
+ world: (id) => worldPosition(viewOf(), object(id)),
};
}
diff --git a/tools/e2e/08-colliders.mjs b/tools/e2e/08-colliders.mjs
index bb6ed04c..3ec788f7 100644
--- a/tools/e2e/08-colliders.mjs
+++ b/tools/e2e/08-colliders.mjs
@@ -79,7 +79,7 @@ export async function colliders(t) {
JSON.stringify(collisionWorld.log),
);
const spriteCount = await evaluate(
- `__gm.state.activeScene.objects.filter(o => __gm.state.project.assets.find(a => a.id === o.assetId)?.type === 'sprite').length`,
+ `__gm.sceneObjects().filter(o => __gm.state.project.assets.find(a => a.id === o.assetId)?.type === 'sprite').length`,
);
const info = await json('__gm.gameInfo()');
check(
diff --git a/tools/e2e/09-containers.mjs b/tools/e2e/09-containers.mjs
index 93791307..55e421ef 100644
--- a/tools/e2e/09-containers.mjs
+++ b/tools/e2e/09-containers.mjs
@@ -1,9 +1,14 @@
+import { instanceChild } from '../lib/builders.mjs';
import { same } from '../lib/harness.mjs';
/**
- * Multi-selection and containers: Ctrl-click, Create container, dragging the group, rubber
- * band, group move/delete, placing and spawning the container asset, and apply/revert of
- * instance values against the template and the asset.
+ * Multi-selection and placed containers.
+ *
+ * A container placed in a scene is not copied into it: the scene stores the container object
+ * alone and its content is resolved from the template on every read, so a template edit reaches
+ * every instance at once (see `src/core/model/instance.ts`). That is what the checks below are
+ * about – what the scene *holds* against what the editor *lists*, deviations kept per instance,
+ * and the structure of resolved content being the template's business and nothing else's.
*/
export async function containers(t) {
const {
@@ -17,19 +22,42 @@ export async function containers(t) {
drag,
locate,
press,
+ typeText,
undo,
mutate,
select,
state,
object,
objects,
+ storedObjects,
asset,
world,
play,
stop,
+ api,
+ sceneOf,
+ navigate,
+ projectId,
} = t;
const center = await json('__gm.viewportCenter()');
const has = (text) => evaluate(`Boolean(__gm.locate(${JSON.stringify(text)}))`);
+ /** Types a value into an inspector field, which runs the real command behind that row. */
+ const setField = async (label, value) => {
+ const row = await locate(label);
+ await click(row.x + 160, row.y);
+ await press('a', { ctrl: true });
+ await typeText(String(value));
+ await press('Enter');
+ await sleep(200);
+ };
+ const notice = async () => {
+ await evaluate("__gm.state.notice = ''");
+ return async () => (await state()).notice;
+ };
+ const selectObject = async (id) => {
+ await select({ kind: 'object', holderId: 'scene1', objectId: id, objectIds: [id] });
+ await sleep(250);
+ };
// 16. Reset both crates to known spots, then Ctrl-click the second row in the hierarchy:
// both are selected, the inspector shows the multi view.
@@ -60,79 +88,8 @@ export async function containers(t) {
s.selection?.kind === 'object' && same(s.selection.objectIds, ['o1']),
JSON.stringify(s.selection),
);
- await click(blueRow.x, blueRow.y, 'left', { ctrl: true });
- // 16a. Create container: both crates (with their colliders and the Mover) become the content
- // of a new container asset; the scene gets one container object at the selection's
- // centre (-15,-40), the crates hang below it with recomputed local positions, world
- // positions are unchanged, and the container is selected.
- const assetsBefore = (await json('__gm.state.project.assets')).length;
- const objectsBefore = (await objects()).length;
- await clickText('Create container');
- await sleep(100);
- s = await state();
- const containerObj = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null;
- const containerAsset = containerObj ? await asset(containerObj.assetId) : null;
- check(
- 'Create container adds one container asset and one placed container, selected',
- containerAsset?.type === 'container' &&
- s.selection.objectIds.length === 1 &&
- (await json('__gm.state.project.assets')).length === assetsBefore + 1 &&
- (await objects()).length === objectsBefore + 1,
- JSON.stringify({ containerObj, type: containerAsset?.type }),
- );
- check(
- 'the container sits at the centre of the selection',
- containerObj && containerObj.parentId === '' && same(containerObj.position, { x: -15, y: -40, z: 0 }),
- JSON.stringify(containerObj?.position),
- );
- let o1c = await object('o1');
- const o2c = await object('o2');
- check(
- 'the crates are re-parented below the container with local positions relative to its centre',
- o1c.parentId === containerObj?.id &&
- o1c.position.x === -135 &&
- o1c.position.y === 40 &&
- o2c.parentId === containerObj?.id &&
- o2c.position.x === 135 &&
- o2c.position.y === -40,
- JSON.stringify({ o1: o1c.position, o2: o2c.position }),
- );
- check(
- 'the template holds the whole subtree (2 crates, 2 colliders, the Mover)',
- containerAsset?.objects.length === 5 &&
- containerAsset.objects.filter((o) => o.parentId === '').length === 2 &&
- o1c.templateId &&
- containerAsset.objects.some((o) => o.id === o1c.templateId),
- JSON.stringify(containerAsset?.objects.map((o) => [o.assetId, o.parentId === '' ? 'root' : 'child'])),
- );
- const orderC = await json('(__gm.hierarchyExpandAll(), __gm.hierarchyOrder())');
- const cAt = orderC.indexOf(containerObj?.id);
- check(
- 'hierarchy lists the container with its content indented below',
- cAt >= 0 && same(orderC.slice(cAt), [containerObj.id, 'o1', 'o4', 'oc1', 'o2', 'oc2']),
- JSON.stringify(orderC),
- );
- check(
- 'container symbol is drawn in the overlay layer',
- containerObj && (await evaluate(`__gm.layerOf('${containerObj.id}')`)) === 'overlay',
- );
- await shot('24-container-created');
-
- // 16b. Dragging the container symbol in the viewport moves the whole group; undo restores it.
- const cScreen = await json(`__gm.objectScreen('${containerObj.id}')`);
- await drag(cScreen.x, cScreen.y, cScreen.x + 100, cScreen.y);
- const containerMoved = await object(containerObj.id);
- o1c = await object('o1');
- check(
- 'dragging the container moves the group (children keep their local position)',
- containerMoved.position.x === 85 && o1c.position.x === -135,
- JSON.stringify({ container: containerMoved.position, o1: o1c.position }),
- );
- await undo();
- check('undo moves the container back', (await object(containerObj.id)).position.x === -15);
-
- // 16c. Rubber band on empty space around the red crate (world x −230…−90, y −50…50): picks
+ // 16a. Rubber band on empty space around the red crate (world x −230…−90, y −50…50): picks
// the objects whose position lies inside – the crate and its collider, not the Mover
// (at y = 110), not the camera (at the origin).
await drag(center.x - 230, center.y - 50, center.x - 90, center.y + 50);
@@ -144,55 +101,328 @@ export async function containers(t) {
);
await shot('25-marquee');
- // 16d. Dragging one of several selected objects moves all of them as one undo step.
+ // 16b. Dragging one of several selected objects moves all of them as one undo step.
const redScreen = await json(`__gm.objectScreen('o1')`);
await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
await drag(redScreen.x, redScreen.y + 20, redScreen.x + 50, redScreen.y + 20);
- o1c = await object('o1');
- const o2d = await object('o2');
check(
'dragging a multi-selection moves every selected object',
- o1c.position.x === -85 && o2d.position.x === 185,
- JSON.stringify({ o1: o1c.position, o2: o2d.position }),
+ (await object('o1')).position.x === -100 && (await object('o2')).position.x === 170,
+ JSON.stringify({ o1: (await object('o1')).position, o2: (await object('o2')).position }),
);
await undo();
- o1c = await object('o1');
- check('one undo restores both', o1c.position.x === -135 && (await object('o2')).position.x === 135);
+ check(
+ 'one undo restores both',
+ (await object('o1')).position.x === -150 && (await object('o2')).position.x === 120,
+ );
- // 16e. Delete with a multi-selection removes all of them (with subtrees) as one step.
- const objectsBeforeDelete = (await objects()).length;
+ // 16c. Delete with a multi-selection removes all of them (with subtrees) as one step.
+ const listedBeforeDelete = (await objects()).length;
await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
await press('Delete');
await sleep(150);
check(
'Delete removes the whole multi-selection with its subtrees',
- (await objects()).length === objectsBeforeDelete - 5,
+ (await objects()).length === listedBeforeDelete - 5,
);
await undo();
- check('undo restores all of them', (await objects()).length === objectsBeforeDelete);
+ check('undo restores all of them', (await objects()).length === listedBeforeDelete);
- // 16f. Placing the container asset again from the asset panel creates a second instance:
- // the root plus 5 content objects with fresh ids, the crate copy at the same offset.
+ // 16d. Create container: both crates, with their colliders and the Mover, become the content
+ // of a new container asset and *leave the scene*. The scene stores one object in their
+ // stead; the five members are only resolved from the template from now on, which is the
+ // whole difference to copying them in – see `src/core/model/instance.ts`.
+ const assetsBefore = (await json('__gm.state.project.assets')).length;
+ const storedBefore = (await storedObjects()).length;
+ const listedBefore = (await objects()).length;
+ await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
+ await clickText('Create container');
+ await sleep(150);
+ s = await state();
+ const instanceA = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null;
+ const containerAsset = instanceA ? await asset(instanceA.assetId) : null;
+ const storedAfter = (await storedObjects()).length;
+ const listedAfter = (await objects()).length;
+ check(
+ 'Create container adds one container asset and one placed container, selected',
+ containerAsset?.type === 'container' &&
+ s.selection.objectIds.length === 1 &&
+ (await json('__gm.state.project.assets')).length === assetsBefore + 1,
+ JSON.stringify({ instanceA, type: containerAsset?.type }),
+ );
+ check(
+ 'the container sits at the centre of the selection',
+ instanceA && instanceA.parentId === '' && same(instanceA.position, { x: -15, y: -40, z: 0 }),
+ JSON.stringify(instanceA?.position),
+ );
+ const swallowed = ['o1', 'o2', 'o4', 'oc1', 'oc2'];
+ const storedIds = (await storedObjects()).map((o) => o.id);
+ check(
+ 'the members left the scene: five objects out, one container object in',
+ storedAfter === storedBefore - 4 && !swallowed.some((id) => storedIds.includes(id)),
+ JSON.stringify({ storedBefore, storedAfter, storedIds }),
+ );
+ check(
+ 'the template holds the whole subtree (2 crates, 2 colliders, the Mover)',
+ containerAsset?.objects.length === 5 &&
+ containerAsset.objects.filter((o) => o.parentId === '').length === 2,
+ JSON.stringify(containerAsset?.objects.map((o) => [o.assetId, o.parentId === '' ? 'root' : 'child'])),
+ );
+
+ // The two crate templates, told apart by where the centring put them.
+ const roots = containerAsset.objects.filter((o) => o.parentId === '');
+ const redTemplate = roots.find((o) => o.position.x === -135);
+ const contentOf = (instanceId) => containerAsset.objects.map((o) => instanceChild(instanceId, o.id));
+ const redA = instanceChild(instanceA.id, redTemplate.id);
+ const redAPath = redTemplate.id;
+ check(
+ 'the editor lists the content again, under ids derived from the template',
+ listedAfter === listedBefore + 1 &&
+ same(
+ (await objects()).filter((o) => contentOf(instanceA.id).includes(o.id)).map((o) => o.id),
+ contentOf(instanceA.id),
+ ),
+ JSON.stringify({ listedBefore, listedAfter, derived: contentOf(instanceA.id) }),
+ );
+ const redResolved = await object(redA);
+ check(
+ 'a resolved object carries the template values and hangs below the container',
+ redResolved &&
+ redResolved.parentId === instanceA.id &&
+ redResolved.name === 'Red crate' &&
+ redResolved.position.x === -135 &&
+ redResolved.position.y === 40,
+ JSON.stringify(redResolved),
+ );
+ const orderC = await json('(__gm.hierarchyExpandAll(), __gm.hierarchyOrder())');
+ const cAt = orderC.indexOf(instanceA.id);
+ check(
+ 'hierarchy lists the container with its resolved content indented below',
+ cAt >= 0 && same(orderC.slice(cAt), [instanceA.id, ...contentOf(instanceA.id)]),
+ JSON.stringify(orderC),
+ );
+ check(
+ 'container symbol is drawn in the overlay layer',
+ (await evaluate(`__gm.layerOf('${instanceA.id}')`)) === 'overlay',
+ );
+ await shot('24-container-created');
+
+ // 16e. Dragging the container symbol in the viewport moves the whole group; undo restores it.
+ const cScreen = await json(`__gm.objectScreen('${instanceA.id}')`);
+ await drag(cScreen.x, cScreen.y, cScreen.x + 100, cScreen.y);
+ check(
+ 'dragging the container moves the group (its content keeps the template positions)',
+ (await object(instanceA.id)).position.x === 85 && (await object(redA)).position.x === -135,
+ JSON.stringify({ container: (await object(instanceA.id)).position }),
+ );
+ await undo();
+ check('undo moves the container back', (await object(instanceA.id)).position.x === -15);
+
+ // 16f. Placing the container asset again adds exactly ONE stored object – and five more that
+ // the editor lists. Against the old copying model the stored count would jump by six.
await clickText(containerAsset.name, 'right');
await clickText('Place in scene');
- await sleep(100);
- const afterPlace = await objects();
- const secondRoot = afterPlace.find((o) => o.assetId === containerAsset.id && o.id !== containerObj.id);
- const secondCrate =
- secondRoot && afterPlace.find((o) => o.parentId === secondRoot.id && o.templateId === o1c.templateId);
+ await sleep(150);
+ const instanceB = (await storedObjects()).find(
+ (o) => o.assetId === containerAsset.id && o.id !== instanceA.id,
+ );
+ const redB = instanceB && instanceChild(instanceB.id, redTemplate.id);
check(
- 'placing the container asset instantiates its content below a new container object',
- afterPlace.length === objectsBeforeDelete + 6 &&
- secondCrate &&
- secondCrate.position.x === -135 &&
- secondCrate.position.y === 40,
- JSON.stringify({ count: afterPlace.length, secondCrate }),
+ 'placing a container stores one object and lists its whole content',
+ Boolean(instanceB) &&
+ (await storedObjects()).length === storedAfter + 1 &&
+ (await objects()).length === listedAfter + 1 + containerAsset.objects.length,
+ JSON.stringify({
+ stored: (await storedObjects()).length,
+ listed: (await objects()).length,
+ expected: listedAfter + 6,
+ }),
+ );
+ check(
+ 'the second instance shows the same content at the same template positions',
+ (await object(redB)).position.x === -135 && (await object(redB)).position.y === 40,
+ JSON.stringify((await object(redB))?.position),
+ );
+ check(
+ 'a fresh instance carries no deviations at all',
+ same(instanceB.instanceEdits, {}),
+ JSON.stringify(instanceB.instanceEdits),
);
await shot('26-container-placed');
- // 16f-ii. Spawning the container at runtime instantiates its content the same way: point the
- // Spawner at the container asset, press Space while playing – a whole instance appears
- // in the runtime copy and in the world, and Stop throws it away with the rest.
+ // 16g. A value edited on one instance is kept on that instance and nowhere else: the scene
+ // grows by no object, the deviation lands in `instanceEdits` under the template path.
+ await selectObject(redB);
+ check('the inspector names the container the object comes from', await has(containerAsset.name));
+ await setField('X', -100);
+ const editedB = (await storedObjects()).find((o) => o.id === instanceB.id);
+ check(
+ 'an edit on resolved content is stored as an instance deviation, not as a new object',
+ (await storedObjects()).length === storedAfter + 1 &&
+ editedB.instanceEdits[redAPath]?.position.x === -100 &&
+ (await object(redB)).position.x === -100,
+ JSON.stringify(editedB.instanceEdits),
+ );
+ check(
+ 'the other instance is untouched by it',
+ (await object(redA)).position.x === -135,
+ JSON.stringify((await object(redA)).position),
+ );
+
+ // 16h. Editing the template reaches every instance that has not been touched there – and only
+ // those. The untouched axis of the edited object follows along with the rest.
+ const setTemplate = (body) =>
+ mutate(
+ `const tpl = __gm.state.project.assets.find(a => a.id === '${containerAsset.id}')
+ .objects.find(o => o.id === '${redAPath}');
+ ${body}`,
+ );
+ await setTemplate(`tpl.position.y = 55; tpl.name = 'Template crate'; tpl.overrides.color = 0x00ff00`);
+ check(
+ 'a template edit reaches every instance at once',
+ (await object(redA)).position.y === 55 &&
+ (await object(redB)).position.y === 55 &&
+ (await object(redA)).name === 'Template crate' &&
+ (await object(redB)).name === 'Template crate' &&
+ (await object(redA)).overrides.color === 0x00ff00,
+ JSON.stringify({ a: await object(redA), b: await object(redB) }),
+ );
+ await setTemplate(`tpl.position.x = -200`);
+ check(
+ 'but it does not overwrite the instance that was moved on its own',
+ (await object(redA)).position.x === -200 && (await object(redB)).position.x === -100,
+ JSON.stringify({ a: (await object(redA)).position.x, b: (await object(redB)).position.x }),
+ );
+ await setTemplate(
+ `tpl.position.x = -135; tpl.position.y = 40; tpl.name = 'Red crate'; delete tpl.overrides.color`,
+ );
+ check(
+ 'putting the template back takes the untouched instance back with it',
+ (await object(redA)).position.x === -135 && (await object(redA)).name === 'Red crate',
+ );
+
+ // 16i. Structure is the template's business: resolved content cannot be removed, re-parented,
+ // given a collider or turned into a container of its own.
+ await selectObject(redA);
+ check('the inspector offers no collider for resolved content', !(await has('+ Add collider')));
+ await selectObject(instanceA.id);
+ check('but it does for the stored container object', await has('+ Add collider'));
+ await selectObject(redA);
+ let noticeAfter = await notice();
+ await press('Delete');
+ await sleep(200);
+ check(
+ 'removing resolved content is refused with a notice',
+ (await noticeAfter()).includes('container template') && Boolean(await object(redA)),
+ await noticeAfter(),
+ );
+ noticeAfter = await notice();
+ await press('g', { ctrl: true });
+ await sleep(200);
+ check(
+ 'making a container out of resolved content is refused too',
+ (await noticeAfter()).includes('container template'),
+ await noticeAfter(),
+ );
+ const parentRow = await locate('Parent');
+ await click(parentRow.x + 55, parentRow.y);
+ await sleep(150);
+ check(
+ 'the Parent picker does not even open for resolved content',
+ same(await json('__gm.overlayTexts()'), []),
+ JSON.stringify(await json('__gm.overlayTexts()')),
+ );
+ await press('Escape');
+ noticeAfter = await notice();
+ // Rows are addressed by id, not by their text: both instances read "Red crate" by now.
+ const rowAt = async (id) => {
+ const p = await json(`__gm.hierarchyChevron(${JSON.stringify(id)})`);
+ if (!p) throw new Error(`no hierarchy row for ${id}`);
+ return { x: p.x + 60, y: p.y };
+ };
+ const redARow = await rowAt(redA);
+ const otherRow = await rowAt(instanceB.id);
+ await drag(redARow.x, redARow.y, otherRow.x, otherRow.y);
+ await sleep(250);
+ check(
+ 're-parenting resolved content by dragging its row is refused',
+ (await noticeAfter()).includes('container template') && (await object(redA)).parentId === instanceA.id,
+ `${await noticeAfter()} / parent ${(await object(redA)).parentId}`,
+ );
+ await shot('26b-structure-refused');
+
+ // 16j. Unpack: the content is written into the scene as ordinary objects under the very same
+ // ids, the instance stops following the template – and now its structure *can* be changed.
+ await clickText(containerAsset.name, 'right');
+ await clickText('Place in scene');
+ await sleep(150);
+ const instanceC = (await storedObjects()).find(
+ (o) => o.assetId === containerAsset.id && o.id !== instanceA.id && o.id !== instanceB.id,
+ );
+ const redC = instanceChild(instanceC.id, redTemplate.id);
+ const listedBeforeUnpack = (await objects()).length;
+ const storedBeforeUnpack = (await storedObjects()).length;
+ await selectObject(instanceC.id);
+ check(
+ 'the inspector shows what the placed container follows',
+ await has('Follows a template of 5 object(s).'),
+ );
+ await clickText('Unpack');
+ await sleep(250);
+ check(
+ 'Unpack writes the content into the scene, keeping the ids and the listing',
+ (await storedObjects()).length === storedBeforeUnpack + 5 &&
+ (await objects()).length === listedBeforeUnpack &&
+ (await storedObjects()).some((o) => o.id === redC),
+ JSON.stringify({
+ stored: (await storedObjects()).length,
+ listed: (await objects()).length,
+ before: { stored: storedBeforeUnpack, listed: listedBeforeUnpack },
+ }),
+ );
+ await selectObject(instanceC.id);
+ check(
+ 'and the inspector stops offering the unpack, there is nothing left to follow',
+ !(await has('Unpack')) && !(await has('Follows a template of 5 object(s).')),
+ );
+ await setTemplate(`tpl.position.x = -300`);
+ check(
+ 'an unpacked instance no longer follows the template',
+ (await object(redC)).position.x === -135 && (await object(redA)).position.x === -300,
+ JSON.stringify({ c: (await object(redC)).position.x, a: (await object(redA)).position.x }),
+ );
+ await setTemplate(`tpl.position.x = -135`);
+ await selectObject(redC);
+ noticeAfter = await notice();
+ const redCRow = await rowAt(redC);
+ await click(redCRow.x, redCRow.y, 'right');
+ await clickText('Unparent (move to top level)');
+ await sleep(250);
+ check(
+ 'unpacked content can be re-parented, which was refused while it followed the template',
+ (await object(redC)).parentId === '' && (await noticeAfter()) === '',
+ `${await noticeAfter()} / parent "${(await object(redC)).parentId}"`,
+ );
+ noticeAfter = await notice();
+ await selectObject(redC);
+ await press('Delete');
+ await sleep(250);
+ check(
+ 'and it can be removed – the refusal was about following a template, not about the id',
+ !(await object(redC)) && (await noticeAfter()) === '',
+ `${await noticeAfter()}`,
+ );
+ await selectObject(instanceC.id);
+ await press('Delete');
+ await sleep(250);
+ check(
+ 'removing the unpacked container takes the rest of its content with it',
+ (await storedObjects()).length === storedBeforeUnpack - 1,
+ JSON.stringify({ now: (await storedObjects()).length, was: storedBeforeUnpack }),
+ );
+
+ // 16k. Spawning a container at runtime spells it out the same way Unpack does, under the same
+ // derived ids – and Stop throws the whole run away with it.
const spawnParam = (assetId) =>
mutate(
`__gm.state.project.assets.find(a => a.id === 'spawner').graph.nodes.find(n => n.id === 's-spawn').params.asset = ${JSON.stringify(assetId)}`,
@@ -208,8 +438,8 @@ export async function containers(t) {
'Spawn of a container asset creates the container object plus its content',
fresh.length === 6 &&
spawnedRoot?.assetId === containerAsset.id &&
- fresh.filter((o) => o.templateId).length === 5,
- JSON.stringify(fresh.map((o) => ({ id: o.id, assetId: o.assetId, templateId: o.templateId }))),
+ fresh.filter((o) => o.id.startsWith(`${spawnedRoot.id}~`)).length === 5,
+ JSON.stringify(fresh.map((o) => ({ id: o.id, assetId: o.assetId }))),
);
const spawnWorld = await world();
check(
@@ -221,41 +451,58 @@ export async function containers(t) {
check('stop discards the spawned instance', (await objects()).length === beforeSpawn.length);
await spawnParam('crate');
- // 16g. Apply / revert on a container instance. The red crate (o1) is part of the first
- // container; the second instance has an unmodified copy of it. Moving o1 on x shows
- // ↑ and ↺ on the X row; ↑ writes x into the template and the copy follows; undo takes
- // both back; ↺ puts o1 back to the template value.
- const templateA = async () => (await asset(containerAsset.id)).objects.find((o) => o.id === o1c.templateId);
- await select({ kind: 'object', holderId: 'scene1', objectId: 'o1' });
- await sleep(200);
- check('inspector names the container the object is part of', await has(containerAsset.name));
+ // 16l. Apply / revert on resolved content. The red crate of instance B was moved on x, the one
+ // of instance A still matches the template; ↑ writes the value into the template and A
+ // follows, ↺ drops the deviation so B follows again.
+ const templateRed = async () => (await asset(containerAsset.id)).objects.find((o) => o.id === redAPath);
+ await selectObject(redA);
check('no apply/revert while the instance matches its template', !(await has('↑')) && !(await has('↺')));
- await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').position.x = -100`);
+ await selectObject(redB);
const applyButton = await evaluate(`__gm.locate('↑')`);
check('a moved instance shows apply and revert on its X row', Boolean(applyButton) && (await has('↺')));
await shot('27-apply-buttons');
await click(applyButton.x, applyButton.y);
- await sleep(100);
+ await sleep(150);
check(
- 'apply writes x into the template and the unmodified copy follows',
- (await templateA()).position.x === -100 && (await object(secondCrate.id)).position.x === -100,
- JSON.stringify({ template: (await templateA()).position, copy: (await object(secondCrate.id)).position }),
+ 'apply writes x into the template and the untouched instance follows',
+ (await templateRed()).position.x === -100 && (await object(redA)).position.x === -100,
+ JSON.stringify({ template: (await templateRed()).position, a: (await object(redA)).position }),
);
check('apply hides the buttons again', !(await has('↑')));
await undo();
check(
- 'undo restores template and copy',
- (await templateA()).position.x === -135 && (await object(secondCrate.id)).position.x === -135,
+ 'undo restores template and instances',
+ (await templateRed()).position.x === -135 && (await object(redA)).position.x === -135,
);
const revertButton = await evaluate(`__gm.locate('↺')`);
await click(revertButton.x, revertButton.y);
- await sleep(100);
- check('revert puts the instance back to the template position', (await object('o1')).position.x === -135);
+ await sleep(150);
+ const revertedB = (await storedObjects()).find((o) => o.id === instanceB.id);
+ check(
+ 'revert drops the deviation, so the instance follows the template again',
+ (await object(redB)).position.x === -135 && revertedB.instanceEdits[redAPath] === undefined,
+ JSON.stringify(revertedB.instanceEdits),
+ );
- // 16h. Overrides on an instance offer a menu: "Apply to container" or "Apply to asset".
- await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').overrides.width = 77`);
+ // 16m. An override on resolved content deviates from two bases at once – the template it
+ // follows and, one step further up, the asset – so apply offers a menu. Reset never goes
+ // past the nearest base, so it stays a single button.
+ await selectObject(redB);
+ await setField('Width', 77);
+ const editedWidth = (await storedObjects()).find((o) => o.id === instanceB.id);
+ check(
+ 'an override on resolved content is kept as an instance deviation',
+ editedWidth.instanceEdits[redAPath]?.overrides.width === 77 &&
+ (await object(redB)).overrides.width === 77 &&
+ (await object(redA)).overrides.width === undefined,
+ JSON.stringify(editedWidth.instanceEdits),
+ );
const applyMenu = await evaluate(`__gm.locate('↑ ▾')`);
check('an overridden property on an instance shows the apply menu button', Boolean(applyMenu));
+ check(
+ 'reset stays a single button – inside a container it only ever goes to the template',
+ !(await has('↺ ▾')),
+ );
await click(applyMenu.x, applyMenu.y);
check(
'the menu offers container and asset',
@@ -263,66 +510,71 @@ export async function containers(t) {
);
await shot('28-apply-menu');
await clickText('Apply to asset');
- await sleep(100);
- const crateAfterApply = (await asset('crate')).props;
+ await sleep(150);
check(
- 'Apply to asset writes the value into the asset and drops the override',
- crateAfterApply.width === 77 && (await object('o1')).overrides.width === undefined,
- JSON.stringify(crateAfterApply),
+ 'Apply to asset writes the value into the asset and drops the deviation',
+ (await asset('crate')).props.width === 77 && (await object(redB)).overrides.width === undefined,
+ JSON.stringify((await asset('crate')).props),
);
await undo();
check(
- 'undo restores asset and override',
- (await asset('crate')).props.width === 100 && (await object('o1')).overrides.width === 77,
+ 'undo restores asset and deviation',
+ (await asset('crate')).props.width === 100 && (await object(redB)).overrides.width === 77,
);
await click(applyMenu.x, applyMenu.y);
await clickText('Apply to container');
- await sleep(100);
+ await sleep(150);
check(
- 'Apply to container writes the override into the template and the copy',
- (await templateA()).overrides.width === 77 && (await object(secondCrate.id)).overrides.width === 77,
+ 'Apply to container writes the override into the template, so every instance shows it',
+ (await templateRed()).overrides.width === 77 && (await object(redA)).overrides.width === 77,
+ JSON.stringify({ template: (await templateRed()).overrides, a: (await object(redA)).overrides }),
);
- // 16i. o1 now matches its template (both 77) but still overrides the asset (100). Only bases
- // the value really differs from are offered, so "Reset to container" is left out and the
- // menu collapses to a direct ↺. Deviating from both brings the menu back.
+ // 16n. redB matches its template again (both 77) and has nothing of its own left to reset –
+ // but the value still differs from the asset (100), which apply can still reach past it.
await sleep(200);
check(
- 'a reset that would change nothing is left out – a direct button instead of a menu',
- (await has('↺')) && !(await has('↺ ▾')) && !(await has('↑ ▾')),
+ 'with the template carrying the value there is nothing left to reset, only an apply upwards',
+ (await has('↑')) && !(await has('↑ ▾')) && !(await has('↺')),
);
- await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').overrides.width = 55`);
- const resetMenu = await evaluate(`__gm.locate('↺ ▾')`);
- check('deviating from template and asset brings the reset menu back', Boolean(resetMenu));
- await click(resetMenu.x, resetMenu.y);
+ await shot('28b-single-apply');
+ const revertedEdits = (await storedObjects()).find((o) => o.id === instanceB.id);
check(
- 'the reset menu offers container and asset',
- (await has('Reset to container')) && (await has('Reset to asset')),
+ 'and the instance stores no deviation for it any more',
+ revertedEdits.instanceEdits[redAPath] === undefined,
+ JSON.stringify(revertedEdits.instanceEdits),
);
- await shot('28b-reset-menu');
- await clickText('Reset to container');
- await sleep(100);
+ await mutate(
+ `delete __gm.state.project.assets.find(a => a.id === '${containerAsset.id}')
+ .objects.find(o => o.id === '${redAPath}').overrides.width`,
+ );
+
+ // 16o. Save and load: an instance is a container object plus its deviations, nothing else.
+ // The content must be nowhere in the saved scene, and reloading must resolve it back.
+ await selectObject(redB);
+ await setField('Y', 12);
+ for (let i = 0; i < 30 && (await state()).save !== 'saved'; i++) await sleep(300);
+ const savedScene = sceneOf(await api(`projects/${projectId}`));
+ const savedInstance = savedScene.objects.find((o) => o.id === instanceB.id);
check(
- 'Reset to container puts the override back on the template value',
- (await object('o1')).overrides.width === 77,
+ 'the saved scene holds the container object and its deviations, not its content',
+ Boolean(savedInstance) &&
+ savedInstance.instanceEdits[redAPath]?.position.y === 12 &&
+ !savedScene.objects.some((o) => o.id === redB),
+ JSON.stringify({ edits: savedInstance?.instanceEdits, stored: savedScene.objects.length }),
);
- const singleReset = await evaluate(`__gm.locate('↺')`);
+ const listedBeforeReload = (await objects()).length;
+ await navigate();
+ await sleep(500);
check(
- 'with only the asset left to differ from, the menu is a single button again',
- Boolean(singleReset) && !(await has('↺ ▾')),
+ 'a reloaded project resolves the same content again, deviations included',
+ (await objects()).length === listedBeforeReload &&
+ (await object(redB)).position.y === 12 &&
+ (await object(redA)).position.y === 40,
+ JSON.stringify({
+ listed: (await objects()).length,
+ expected: listedBeforeReload,
+ b: (await object(redB))?.position,
+ }),
);
- await click(singleReset.x, singleReset.y);
- await sleep(100);
- check(
- '↺ drops the override so the object follows the asset again',
- (await object('o1')).overrides.width === undefined && (await templateA()).overrides.width === 77,
- );
- // Dropping the override left the object on the asset value (100) while the template still
- // says 77 – that is a real deviation, so exactly one entry remains: "Reset to container".
- check(
- 'the remaining deviation from the template keeps a single button, not a menu',
- (await has('↺')) && !(await has('↺ ▾')),
- );
- await undo();
- check('undo restores the override', (await object('o1')).overrides.width === 77);
}
diff --git a/tools/e2e/10-text-and-enabled.mjs b/tools/e2e/10-text-and-enabled.mjs
index 5d83d82c..ceec2d90 100644
--- a/tools/e2e/10-text-and-enabled.mjs
+++ b/tools/e2e/10-text-and-enabled.mjs
@@ -14,7 +14,7 @@ export async function textAndEnabled(t) {
__gm.state.project.assets.find(a => a.id === id).props.text = 'Hello world';
// On the camera so the game view really renders it (outside the frame it would be culled).
const cam = __gm.state.activeScene.objects.find(o => o.id === 'o3').position;
- __gm.state.activeScene.objects.push({ id: 'otext', assetId: id, name: '', position: { x: cam.x, y: cam.y, z: 5 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', templateId: '' });
+ __gm.state.activeScene.objects.push({ id: 'otext', assetId: id, name: '', position: { x: cam.x, y: cam.y, z: 5 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '' });
__gm.state.select({ kind: 'object', holderId: 'scene1', objectIds: ['otext'], objectId: 'otext' })`);
await sleep(100);
check('a placed text object draws its content in the scene', await has('Hello world'));
diff --git a/tools/e2e/11-menus.mjs b/tools/e2e/11-menus.mjs
index b8bf5697..c1ceed79 100644
--- a/tools/e2e/11-menus.mjs
+++ b/tools/e2e/11-menus.mjs
@@ -3,16 +3,22 @@
* to make the parent picker overflow, which the earlier steps' object counts would not survive.
*/
export async function menus(t) {
- const { check, sleep, json, shot, click, locate, mouse, mutate } = t;
+ const { check, sleep, evaluate, json, shot, click, locate, mouse, mutate } = t;
+ /** Like locate(), but a row scrolled out of view answers with null instead of throwing. */
+ const rowAt = async (text) =>
+ (await evaluate(`__gm.locate(${JSON.stringify(text)}, true)`)) ?? { x: -1, y: -1 };
await mutate(`
const scene = __gm.state.activeScene;
- const source = scene.objects.find(o => o.id === 'o1');
+ // Any plain stored object will do – not a placed container, whose content the picker
+ // would list forty times over as well.
+ const source = scene.objects.find(o => o.id === 'otext');
for (let i = 1; i <= 40; i++) {
const copy = structuredClone(source);
copy.id = 'filler' + i;
copy.name = 'Filler ' + i;
copy.parentId = '';
+ copy.overrides = {};
scene.objects.push(copy);
}
__gm.state.select({ kind: 'object', holderId: 'scene1', objectIds: ['otext'], objectId: 'otext' })`);
@@ -39,8 +45,13 @@ export async function menus(t) {
// 18a. The entries scroll under the wheel; the search box above them does not move.
// Anchored on a filler row: "None" is also what an empty dropdown reads, so locating it could
- // find an inspector field behind the menu instead – one that never moves.
- const firstBefore = await locate('Filler 1', true);
+ // find an inspector field behind the menu instead – one that never moves. Which filler is asked
+ // for is read off the open menu: the capped list only draws the rows it shows, and how many
+ // objects come before them depends on what the earlier steps left in the scene.
+ const visibleFillers = (await json('__gm.overlayTexts()')).filter((e) => e.startsWith('Filler '));
+ const anchor = visibleFillers[visibleFillers.length - 1];
+ check('the picker lists the filler objects', Boolean(anchor), JSON.stringify(visibleFillers));
+ const firstBefore = await rowAt(anchor);
const searchBefore = await locate('Search…', true);
// Move onto the list first: a wheel event goes to whatever is hovered, and the pointer last
// touched the field above the menu.
@@ -50,13 +61,13 @@ export async function menus(t) {
// Measured here: the list had not moved 40 ms after the wheel but had at 340 ms. Both
// readings are reported so an early one cannot look like a mystery failure again.
await sleep(40);
- const rightAfter = await locate('Filler 1', true);
+ const rightAfter = await rowAt(anchor);
await sleep(300);
- const firstAfter = await locate('Filler 1', true);
+ const firstAfter = await rowAt(anchor);
const searchAfter = await locate('Search…', true);
check(
'the wheel scrolls the entries',
- firstAfter.y < firstBefore.y - 100,
+ firstAfter.y >= 0 && firstAfter.y < firstBefore.y - 100,
JSON.stringify({ firstBefore, rightAfter, firstAfter }),
);
check('the search box stays put while the entries scroll', searchAfter.y === searchBefore.y);
diff --git a/tools/e2e/12-textures.mjs b/tools/e2e/12-textures.mjs
index 6f45cbb4..d1a9d6ec 100644
--- a/tools/e2e/12-textures.mjs
+++ b/tools/e2e/12-textures.mjs
@@ -86,11 +86,10 @@ export async function textures(t) {
project.assets.push(sprite);
const scene = __gm.state.activeScene;
- const object = structuredClone(scene.objects.find((o) => o.id === 'o1'));
+ const object = structuredClone(scene.objects.find((o) => o.id === 'otext'));
object.id = 'otex';
object.name = 'Textured';
object.assetId = 'tex-sprite';
- object.templateId = '';
object.overrides = {};
object.position = { ...object.position, z: 900 };
scene.objects.push(object);
diff --git a/tools/e2e/14-overflow.mjs b/tools/e2e/14-overflow.mjs
index ed38a57d..fc4de9f7 100644
--- a/tools/e2e/14-overflow.mjs
+++ b/tools/e2e/14-overflow.mjs
@@ -69,7 +69,7 @@ export async function overflow(t) {
// whose asset name is now overlong too.
await evaluate("__gm.showTab('scene')");
await sleep(300);
- await evaluate("__gm.state.select({ kind: 'object', objectId: 'o1' })");
+ await evaluate("__gm.state.select({ kind: 'object', objectId: 'otex' })");
await sleep(500);
const inspector = await json('__gm.textOverflows()');
check('no label spills in the inspector', inspector.length === 0, JSON.stringify(inspector));
diff --git a/tools/e2e/16-container.mjs b/tools/e2e/16-container.mjs
index 1fe115d2..74a54ae8 100644
--- a/tools/e2e/16-container.mjs
+++ b/tools/e2e/16-container.mjs
@@ -1,8 +1,11 @@
+import { instanceChild } from '../lib/builders.mjs';
+
/**
* The Container tab: editing a container's template with the scene view's own tools.
*
* Runs after the container step, which created a container asset from two crates and left two
- * placed copies of it in the scene – which is what makes "which copies follow a template edit"
+ * instances of it in the scene. Neither of them holds a copy: what they show is resolved from
+ * the template on every read, which is what makes "which instance follows a template edit"
* a question worth asking.
*/
@@ -24,6 +27,9 @@ export async function containerTab(t) {
undo,
state,
asset,
+ object,
+ objects,
+ storedObjects,
} = t;
await press('Escape');
@@ -79,52 +85,64 @@ export async function containerTab(t) {
JSON.stringify(s.selection),
);
- // 22c. The point of the tab: a drag writes the template, and the placed copies follow it –
- // except one that was moved on its own beforehand, which keeps what it has.
+ // 22c. The point of the tab: a drag writes the template, and every instance follows it – except
+ // one that was moved there on its own, whose deviation the instance keeps.
const templateX = () =>
json(
`__gm.state.project.assets.find(a => a.id === '${containerId}').objects.find(o => o.id === '${root.id}').position.x`,
);
- const copyXs = () =>
+ // The instances resolve this template object under `~${root.id}`; nothing of it is
+ // stored, so the resolved list is the only place to read it.
+ const instanceXs = () =>
json(
- `Object.fromEntries(__gm.state.activeScene.objects.filter(o => o.templateId === '${root.id}').map(o => [o.id, o.position.x]))`,
+ `Object.fromEntries(__gm.sceneObjects().filter(o => o.id.endsWith('~${root.id}')).map(o => [o.id, o.position.x]))`,
);
- const copies = await copyXs();
- const [dirtyId, ...cleanIds] = Object.keys(copies);
- await mutate(`__gm.state.activeScene.objects.find(o => o.id === '${dirtyId}').position.x += 7`);
+ const shown = await instanceXs();
+ const [dirtyId, ...cleanIds] = Object.keys(shown);
+ const dirtyInstance = dirtyId.slice(0, dirtyId.indexOf('~'));
+ const setDeviation = (body) =>
+ mutate(
+ `const edits = __gm.state.activeScene.objects.find(o => o.id === '${dirtyInstance}').instanceEdits;
+ ${body}`,
+ );
+ await setDeviation(`edits['${root.id}'] = { position: { x: ${shown[dirtyId] + 7} }, overrides: {} }`);
const before = await templateX();
await drag(at.x, at.y, at.x + 90, at.y);
const after = await templateX();
- const copiesAfter = await copyXs();
+ const afterDrag = await instanceXs();
check(
'dragging in the Container tab moves the template',
after === before + 90,
JSON.stringify({ before, after }),
);
check(
- 'the placed copies that still matched the template follow it',
- cleanIds.length > 0 && cleanIds.every((id) => copiesAfter[id] === copies[id] + 90),
- JSON.stringify({ copies, copiesAfter, cleanIds }),
+ 'the instances that still matched the template follow it',
+ cleanIds.length > 0 && cleanIds.every((id) => afterDrag[id] === shown[id] + 90),
+ JSON.stringify({ shown, afterDrag, cleanIds }),
);
check(
- 'a copy that was moved on its own keeps its own position',
- copiesAfter[dirtyId] === copies[dirtyId] + 7,
- JSON.stringify({ was: copies[dirtyId], now: copiesAfter[dirtyId] }),
+ 'the instance that was moved on its own keeps its own position',
+ afterDrag[dirtyId] === shown[dirtyId] + 7,
+ JSON.stringify({ was: shown[dirtyId], now: afterDrag[dirtyId] }),
);
await undo();
- const copiesUndone = await copyXs();
+ const undone = await instanceXs();
check('undo takes the template move back', (await templateX()) === before);
check(
- 'and takes the copies back with it',
- cleanIds.every((id) => copiesUndone[id] === copies[id]),
- JSON.stringify({ copies, copiesUndone }),
+ 'and takes the instances back with it',
+ cleanIds.every((id) => undone[id] === shown[id]),
+ JSON.stringify({ shown, undone }),
);
- await mutate(`__gm.state.activeScene.objects.find(o => o.id === '${dirtyId}').position.x -= 7`);
+ await setDeviation(`delete edits['${root.id}']`);
// 22d. Placing goes through the Assets panel, whose entry follows the visible tab: it adds one
// object to the template, not to the scene.
const crateName = (await asset('crate')).name;
- const sceneCount = (await json('__gm.state.activeScene.objects')).length;
+ // What the scene *stores* – that must not move. What it *lists* has to grow by one per placed
+ // instance instead, since every one of them resolves the new template object too.
+ const sceneStored = (await storedObjects()).length;
+ const sceneListed = (await objects()).length;
+ const instanceCount = Object.keys(await instanceXs()).length;
await clickText(crateName, 'right');
check(
'the asset menu offers Place in container while the tab is open',
@@ -142,7 +160,12 @@ export async function containerTab(t) {
grown.some((o) => o.id === s.selection.objectId),
JSON.stringify({ before: count, after: grown.length, selection: s.selection }),
);
- check('the scene is untouched by it', (await json('__gm.state.activeScene.objects')).length === sceneCount);
+ check('the scene stores nothing more for it', (await storedObjects()).length === sceneStored);
+ check(
+ 'but every placed instance shows the new template object at once',
+ (await objects()).length === sceneListed + instanceCount,
+ JSON.stringify({ listed: (await objects()).length, was: sceneListed, instances: instanceCount }),
+ );
await shot('31-container-object-added');
// 22e. Delete works on a template object like on any other, as one undo step.
@@ -199,12 +222,12 @@ export async function containerTab(t) {
);
await evaluate("__gm.showTab('scene')");
await sleep(300);
- await evaluate("__gm.state.select({ kind: 'object', holderId: 'scene1', objectId: 'o1' })");
+ await evaluate("__gm.state.select({ kind: 'object', holderId: 'scene1', objectId: 'otex' })");
await sleep(300);
check('selecting a scene object leaves the Scene tab alone', (await json('__gm.tab()')) === 'scene');
check(
'the hierarchy is back on the scene with it',
- (await json('__gm.hierarchyOrder()')).includes('o1'),
+ (await json('__gm.hierarchyOrder()')).includes('otex'),
JSON.stringify(await json('__gm.hierarchyOrder()')),
);
await clickText(crateName, 'right');
@@ -257,6 +280,39 @@ export async function containerTab(t) {
(await json('__gm.container.current()')) === 'c2',
JSON.stringify({ before: nested.length, after: nestedAfter.length }),
);
- await undo();
+
+ // 22j. Nesting resolves all the way down: placing the outer container in the scene stores one
+ // object, and the inner container's content appears under `~/`.
+ const innerObject = nestedAfter.find((o) => o.assetId === containerId);
+ const innerTemplate = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`);
+ await evaluate("__gm.showTab('scene')");
+ await sleep(300);
+ const storedBefore = (await storedObjects()).length;
+ const listedBefore = (await objects()).length;
+ await clickText('Spare box', 'right');
+ await clickText('Place in scene');
+ await sleep(300);
+ const outer = (await storedObjects()).find((o) => o.assetId === 'c2');
+ const deep = innerTemplate.map((o) => instanceChild(outer.id, innerObject.id, o.id));
+ const listedIds = (await objects()).map((o) => o.id);
+ check(
+ 'a container nested in a container template resolves through both levels',
+ (await storedObjects()).length === storedBefore + 1 &&
+ (await objects()).length === listedBefore + 1 + nestedAfter.length + innerTemplate.length &&
+ deep.every((id) => listedIds.includes(id)),
+ JSON.stringify({
+ stored: (await storedObjects()).length - storedBefore,
+ listed: (await objects()).length - listedBefore,
+ deep: deep.filter((id) => !listedIds.includes(id)),
+ }),
+ );
+ check(
+ 'and the nested content hangs below the nested container object',
+ (await object(deep[0])).parentId === instanceChild(outer.id, innerObject.id),
+ JSON.stringify(await object(deep[0])),
+ );
+ await shot('34-container-nested');
+ await undo(); // the placement
+ await undo(); // the nesting
await sleep(200);
}
diff --git a/tools/lib/builders.mjs b/tools/lib/builders.mjs
index 2e1d842e..3c64dcf9 100644
--- a/tools/lib/builders.mjs
+++ b/tools/lib/builders.mjs
@@ -7,7 +7,7 @@
export const sceneObject = (
id,
assetId,
- { name = '', x = 0, y = 0, z = 0, overrides = {}, parentId = '', templateId = '', editorOffset } = {},
+ { name = '', x = 0, y = 0, z = 0, overrides = {}, parentId = '', instanceEdits, editorOffset } = {},
) => ({
id,
assetId,
@@ -15,10 +15,17 @@ export const sceneObject = (
position: { x, y, z },
overrides,
parentId,
- templateId,
+ ...(instanceEdits ? { instanceEdits } : {}),
...(editorOffset ? { editorOffset } : {}),
});
+/**
+ * The id an object of a placed container resolves to: `~`. A placed
+ * container stores nothing but itself, so this is how its content is addressed – in the editor,
+ * in a running game and in the check scripts alike (see `src/core/model/instance.ts`).
+ */
+export const instanceChild = (instanceId, ...templatePath) => `${instanceId}~${templatePath.join('/')}`;
+
/** A graph edge from `from.fromPort` to `to.toPort`. */
export const edge = (id, from, fromPort, to, toPort) => ({
id,
@@ -31,32 +38,3 @@ export const node = (id, type, x, y, params = {}) => ({ id, type, x, y, params }
/** A behavior variable declaration. */
export const variable = (key, kind, value) => ({ key, label: key, kind, default: value });
-
-/**
- * Instantiation of container templates the way the editor does it (`instantiateContainer`):
- * `templates` maps container asset ids to their template objects.
- *
- * expand(assetId, hostId) copies of the template, parented below `hostId`
- * instantiate(id, assetId, options) the container object plus its expanded content
- *
- * A template object that is itself a container is expanded in turn, unless the template already
- * spells its content out. Copies remember their template id.
- */
-export function containerFactory(templates) {
- const expand = (assetId, hostId) => {
- const template = templates[assetId];
- const objects = [];
- for (const t of template) {
- const id = `${hostId}-${t.id}`;
- objects.push({ ...t, id, parentId: t.parentId ? `${hostId}-${t.parentId}` : hostId, templateId: t.id });
- if (templates[t.assetId] && !template.some((o) => o.parentId === t.id))
- objects.push(...expand(t.assetId, id));
- }
- return objects;
- };
- const instantiate = (id, assetId, options = {}) => [
- sceneObject(id, assetId, options),
- ...expand(assetId, id),
- ];
- return { expand, instantiate };
-}
diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs
index a6382fc9..5e8f1e08 100644
--- a/tools/lib/harness.mjs
+++ b/tools/lib/harness.mjs
@@ -27,6 +27,7 @@ const KEYS = {
ArrowRight: { key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 },
Delete: { key: 'Delete', code: 'Delete', windowsVirtualKeyCode: 46 },
a: { key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65 },
+ g: { key: 'g', code: 'KeyG', windowsVirtualKeyCode: 71 },
z: { key: 'z', code: 'KeyZ', windowsVirtualKeyCode: 90 },
};
@@ -164,8 +165,15 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
json(
'{ selection: __gm.state.selection, mode: __gm.state.mode, save: __gm.state.saveStatus, notice: __gm.state.notice }',
);
- const objects = () => json('__gm.state.activeScene.objects');
- const object = (id) => json(`__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(id)})`);
+ /**
+ * The scene's objects the way the editor lists them: the content of a placed container is
+ * resolved from its template and stored nowhere (see `src/core/model/instance.ts`), so it is in
+ * here but not in `state.activeScene.objects`. Use `storedObjects()` for the latter.
+ */
+ const objects = () => json('__gm.sceneObjects()');
+ const object = (id) => json(`__gm.sceneObjects().find((o) => o.id === ${JSON.stringify(id)})`);
+ /** Only what the scene really holds – a placed container counts as one object, not as its content. */
+ const storedObjects = () => json('__gm.state.activeScene.objects');
const asset = (id) => json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)})`);
const world = () => json('__gm.world()');
const entity = async (objectId) => (await world()).entities.find((e) => e.objectId === objectId);
@@ -224,6 +232,7 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
state,
objects,
object,
+ storedObjects,
asset,
world,
entity,
diff --git a/tools/seed-jumprun.mjs b/tools/seed-jumprun.mjs
index 4a199174..bb9510c4 100644
--- a/tools/seed-jumprun.mjs
+++ b/tools/seed-jumprun.mjs
@@ -14,7 +14,7 @@
*
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-jumprun.mjs [projectId] [baseUrl]
*/
-import { containerFactory, edge, node, sceneObject, variable } from './lib/builders.mjs';
+import { edge, node, sceneObject, variable } from './lib/builders.mjs';
import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs';
const { projectId, baseUrl } = cliArgs('seed-jumprun.mjs', PROJECTS.jumpRun);
@@ -121,7 +121,6 @@ const templates = {
win: screenTemplate('win', 'YOU WIN!', 'All ten coins collected in time.', 'PLAY AGAIN', 'restart-button'),
lose: screenTemplate('lose', 'TIME UP', 'The time is over - try again.', 'PLAY AGAIN', 'restart-button'),
};
-const { instantiate } = containerFactory(templates);
// --- graphs ----------------------------------------------------------------------------------
@@ -357,7 +356,7 @@ const restartButtonGraph = {
// --- project ---------------------------------------------------------------------------------
const project = {
- formatVersion: 1,
+ formatVersion: 2,
id: projectId,
name: 'Jump and Run',
activeSceneId: 'scene1',
@@ -368,22 +367,22 @@ const project = {
name: 'Level 1',
props: { backgroundColor: SKY },
objects: [
- ...instantiate('L', 'level', { name: 'Level' }),
+ sceneObject('L', 'level', { name: 'Level' }),
// All three screens sit on the camera frame; the Game behavior decides on `On start` which
// one is up. Editor offsets pull them apart in the scene view so they do not cover the level.
- ...instantiate('T', 'title', {
+ sceneObject('T', 'title', {
name: 'Title',
z: 10,
overrides: { enabled: true },
editorOffset: { x: -750, y: -500 },
}),
- ...instantiate('W', 'win', {
+ sceneObject('W', 'win', {
name: 'Win',
z: 10,
overrides: { enabled: true },
editorOffset: { x: 0, y: -500 },
}),
- ...instantiate('B', 'lose', {
+ sceneObject('B', 'lose', {
name: 'Lose',
z: 10,
overrides: { enabled: true },
@@ -463,8 +462,8 @@ const project = {
variables: [],
graph: restartButtonGraph,
},
- { id: 'coin', type: 'container', name: 'Coin', props: {}, objects: coinTemplate },
- { id: 'level', type: 'container', name: 'Level', props: {}, objects: levelTemplate },
+ { id: 'coin', type: 'container', name: 'Coin', props: {}, objects: templates.coin },
+ { id: 'level', type: 'container', name: 'Level', props: {}, objects: templates.level },
{ id: 'title', type: 'container', name: 'Title screen', props: {}, objects: templates.title },
{ id: 'win', type: 'container', name: 'Win screen', props: {}, objects: templates.win },
{ id: 'lose', type: 'container', name: 'Lose screen', props: {}, objects: templates.lose },
diff --git a/tools/smoke-jumprun.mjs b/tools/smoke-jumprun.mjs
index e9d58fa4..02eeb30c 100644
--- a/tools/smoke-jumprun.mjs
+++ b/tools/smoke-jumprun.mjs
@@ -7,13 +7,26 @@
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
* Screenshots land in /tmp/gm-jr-*.png.
*/
+import { instanceChild } from './lib/builders.mjs';
import { runChecks } from './lib/harness.mjs';
import { PROJECTS, cliArgs } from './lib/projects.mjs';
const { projectId, baseUrl } = cliArgs('smoke-jumprun.mjs', PROJECTS.jumpRun);
-/** What the seeder writes; a mismatch means the loader dropped something. */
+/**
+ * What the editor lists: the six stored objects plus everything the placed containers resolve to
+ * (the level with its ten nested coins, and the three screens). A mismatch means the loader
+ * dropped something.
+ */
const OBJECT_COUNT = 77;
+
+/** The scene stores the container objects alone – the rest is resolved from their templates. */
+const STORED_COUNT = 6;
+
+// Ids inside a placed container are derived from the template path, never stored.
+const PLAYER = instanceChild('L', 'player');
+const START_BUTTON = instanceChild('T', 'title-button');
+const AGAIN_BUTTON = instanceChild('W', 'win-button');
const GRAPHS = { player: [35, 38], game: [38, 43], 'start-button': [9, 10], 'restart-button': [2, 1] };
const GROUND_Y = 130;
@@ -46,7 +59,11 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
if (!p) throw new Error(`object ${objectId} is not on screen in the game view`);
await click(p.x, p.y);
};
- /** Edits the play copy (never the saved project) and pushes it into the running world. */
+ /**
+ * Edits the play copy (never the saved project) and pushes it into the running world. The
+ * stored list is the right one here: play bakes every placed container into it, and only a
+ * stored object can be written to.
+ */
const setVariable = (objectId, key, value) =>
mutate(
`__gm.state.activeScene.objects.find((x) => x.id === ${JSON.stringify(objectId)}).overrides[${JSON.stringify(key)}] = ${JSON.stringify(value)}`,
@@ -64,8 +81,14 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
graphs.every((g) => GRAPHS[g.id] && g.n === GRAPHS[g.id][0] && g.e === GRAPHS[g.id][1]),
JSON.stringify(graphs),
);
- const objectCount = await evaluate('__gm.state.activeScene.objects.length');
+ const objectCount = await evaluate('__gm.sceneObjects().length');
+ const storedCount = await evaluate('__gm.state.activeScene.objects.length');
check('the scene holds the seeded objects', objectCount === OBJECT_COUNT, `${objectCount} objects`);
+ check(
+ 'the placed containers are stored as one object each, their content only resolved',
+ storedCount === STORED_COUNT,
+ `${storedCount} stored, ${objectCount} listed`,
+ );
await shot('01-editor');
// 2. Play: the Game behavior shows the title screen and switches the level off.
@@ -83,7 +106,7 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
await shot('02-title');
// 3. The Start button is a click target in the running game (On click + hit test).
- await clickObject('T-title-button');
+ await clickObject(START_BUTTON);
await sleep(400);
check(
'clicking Start hides the title and shows the level',
@@ -97,8 +120,17 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
);
await shot('03-running');
+ // 3a. Play bakes the placed containers into stored objects, keeping the ids the editor showed –
+ // which is why the checks below can address the player and the buttons by the same id.
+ const baked = await json('__gm.state.activeScene.objects.map((o) => o.id)');
+ check(
+ 'play spells the containers out under the ids the editor listed',
+ baked.length === OBJECT_COUNT && [PLAYER, START_BUTTON, AGAIN_BUTTON].every((id) => baked.includes(id)),
+ `${baked.length} objects, player ${baked.includes(PLAYER)}`,
+ );
+
// 4. Gravity: the player rests on the ground (its 40-high box on the ground's top edge, y = 150).
- const grounded = await entity('L-player');
+ const grounded = await entity(PLAYER);
check(
'gravity dropped the player onto the ground (top edge at y = 150)',
Math.abs(grounded.worldY - GROUND_Y) < 1.5,
@@ -108,14 +140,14 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
// 5. Jumping: Space lifts the player (y gets smaller) and gravity brings it back down.
await press('Space');
await sleep(200);
- const airborne = await entity('L-player');
+ const airborne = await entity(PLAYER);
check(
'Space makes the player jump',
airborne.worldY < grounded.worldY - 25,
`y ${grounded.worldY.toFixed(1)} → ${airborne.worldY.toFixed(1)}`,
);
await sleep(1200);
- const landed = await entity('L-player');
+ const landed = await entity(PLAYER);
check(
'the player falls back onto the ground',
Math.abs(landed.worldY - GROUND_Y) < 1.5,
@@ -126,7 +158,7 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
// 6. Walking and collecting: running into a coin removes it and counts up.
const coinsBefore = await coins();
await holdKey('ArrowRight', 1500);
- const walked = await entity('L-player');
+ const walked = await entity(PLAYER);
check(
'arrow keys walk the player',
walked.worldX > landed.worldX + 40,
@@ -155,7 +187,7 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
await shot('06-won');
// 8. Restart game: back to a fresh run at the title screen, coins and clock reset.
- await clickObject('W-win-button');
+ await clickObject(AGAIN_BUTTON);
await sleep(1200);
check(
'Play again restarts the scene at the title screen',
@@ -164,7 +196,7 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
check('the restarted run has all ten coins back', (await coins()) === 10, `${await coins()} coins`);
// 9. Losing: start again, run the clock out.
- await clickObject('T-title-button');
+ await clickObject(START_BUTTON);
await sleep(300);
await setVariable('ogame', 'timeLeft', 0.2);
await sleep(600);
@@ -178,11 +210,14 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
await stop();
await sleep(200);
const saved = await json(
- '{ objects: __gm.state.activeScene.objects.length, mode: __gm.state.mode, tab: __gm.tab() }',
+ '{ objects: __gm.sceneObjects().length, stored: __gm.state.activeScene.objects.length, mode: __gm.state.mode, tab: __gm.tab() }',
);
check(
'stop restores the edited scene',
- saved.objects === OBJECT_COUNT && saved.mode === 'edit' && saved.tab === 'scene',
+ saved.objects === OBJECT_COUNT &&
+ saved.stored === STORED_COUNT &&
+ saved.mode === 'edit' &&
+ saved.tab === 'scene',
JSON.stringify(saved),
);
check('no exceptions during the run', exceptions().length === 0, exceptions().slice(0, 3).join(' | '));