Resolve container content from the template instead of copying it

A scene now stores only the container object; its content is resolved from
the template on every read, with deviations kept as sparse instanceEdits on
the container object. This drops the propagation bookkeeping entirely --
templateSync.ts is gone and the three structural gaps (add, remove, reparent)
cannot diverge any more.

Values stay editable on an instance, structure does not; Unpack writes the
content into the scene, keeping ids. Play mode bakes containers into the
throwaway copy before entities are built. No migration: formatVersion is 2
and the seeders write the new format.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 21:09:53 +02:00
1 parent 9d93020bd8
commit ead649eec3
37 files changed
+1878 -1006

No files matched your search

+26 -17
View File
@@ -15,13 +15,14 @@ Live: <https://nockal.com/game-maker/> · Widget playground: <https://nockal.com
| **Text** | An asset that draws a line of text (`text`, `fontSize`, `color`, `align`) – titles, HUD readouts, button labels. Its content is an ordinary property, so `Set text` (or `Set property`) changes it while playing. |
| **Enabled** | Every placeable asset has the switch `enabled` (default on). A disabled object is not drawn, does not collide and its behavior does not run – **and neither does anything parented below it**. Switching one instance off is a normal override, which makes a container a *screen*: switch it on to show it, off to hide it. `Set enabled` / `Is enabled` do it from a graph. |
| **Scene object** | An asset placed into a scene: a link to the asset + a **position** (`x`, `y`, `z`) + sparse **overrides** of the asset's properties + an optional **parent** (another object of the scene). Scenes cannot be placed. |
| **Container** | A reusable group ("prefab"): the asset holds a **template** of objects (asset link, local position, overrides, parent links) centred on its origin. Placing a container creates a container object plus fresh copies of the template below it – ordinary objects, each remembering its template id. **Create container** turns the current selection into such an asset and replaces the selected objects by one placed container at their centre (world positions unchanged). The template is edited in the **Container** tab (see *Editing a container*) or from an instance through **Apply to container**. |
| **Container** | A reusable group ("prefab"): the asset holds a **template** of objects (asset link, local position, overrides, parent links) centred on its origin. Placing a container stores the container object **and nothing else** – its content is resolved from the template on every read, so a template edit reaches every instance at once. **Create container** turns the current selection into such an asset: the selected objects move into the template and the scene keeps one placed container at their centre (world positions unchanged). The template is edited in the **Container** tab (see *Editing a container*) or from an instance through **Apply to container**. |
| **Selection** | One or more scene objects. Click selects, `Ctrl`-click adds/removes, dragging on empty scene space draws a rubber band (objects whose position lies inside are selected). Dragging a selected object moves the whole selection; `Delete` removes it; the inspector offers **Create container** and **Remove** for several objects. |
| **Behavior** | Logic as an asset: a **node graph** plus the **variables** it declares. Placed like anything else (a diamond symbol in the scene); its parent is the **owner** the graph acts on. |
| **Collider** | A shape (box or circle) for collision detection, placed as a **child** of the object it belongs to (its *body*). Drawn as a green outline in edit mode only. |
| **Override** | A per-object value that replaces the asset's value for one property. Only changed keys are stored; the inspector marks them bold and offers a reset (↺) and an **apply** (↑) that writes the value into the asset instead. Both appear only where the value really differs from its base – an override carrying the asset's own value is none. |
| **Default** | Every property has a schema default. Asset values that differ from it are marked bold in the inspector and can be reset (↺) the same way an override can. |
| **Instance** | An object that is part of a placed container. It has two bases: the **template** it was instantiated from and the asset behind it. A row lists only the bases its value really differs from – ↺ ▾ offers **Reset to container** (template value) and/or **Reset to asset** (drop the override – the template is only read when the container is placed, never at draw time), ↑ ▾ **Apply to container** (template; copies still at the old value follow, copies changed on their own keep theirs) and/or **Apply to asset**. With one base left the menu collapses to a plain ↺ / ↑, with none the buttons disappear. Bold marks the same thing the buttons offer. Position rows compare against the template only. Right after *Create container* an instance matches its template (the overrides were copied into it) but may still override the asset – then only the asset entry shows. |
| **Instance** | An object resolved from a placed container's template. Its **values** can be edited – position, name, property overrides – and only what actually deviates is stored, as a sparse `instanceEdits` entry on the container object keyed by the template path. Everything else keeps following the template. Its **structure** cannot: it can not be removed, re-parented or given a collider, and nothing of the scene can be parented into it. ↺ ▾ resets to the nearest base, which inside a container is always the **template**; ↑ ▾ offers **Apply to container** (write the value into the template, where every other instance still at the old value picks it up) and/or **Apply to asset** (which drops the template's override as well). With one base left the menu collapses to a plain ↺ / ↑, with none the buttons disappear. Bold marks the same thing the buttons offer. |
| **Unpack** | Writes a placed container's content into the scene as ordinary objects (hierarchy context menu, or the inspector's *Content* section). The ids survive it, so the selection does; from then on the structure is editable and the objects no longer follow the template. |
| **Edit mode** | The Scene tab draws the project model; panels, grid, gizmos and drag-to-move are active. |
| **Play mode** | The editor switches to a **runtime copy** of the project (same ids). A `World` is instantiated from its active scene (one entity per scene object, resolved properties as mutable state) and shown in the **Game** tab. Systems tick it and mirror their changes into the copy; every panel stays open and edits the copy, which the world picks up immediately. **Stop discards world and copy** – the saved model is never touched. |
@@ -276,19 +277,27 @@ were editing, and only a deleted container makes it fall back to another one. Se
*object* also switches to the tab, the rule a graph node and a texture frame already follow: it
exists in no other view. Selecting the asset itself does not.
Placing a container **into a template** creates the one object and nothing below it, where placing
it into a scene expands its content: the expansion happens when a copy of the outer container is
placed, so the template stays what it says. A container that reaches this one, directly or through
another, is refused by the command – because that cycle expands forever the moment either of them
is placed.
Placing a container anywhere creates **one** object. Its content is never copied: `resolveObjects`
(`src/core/model/instance.ts`) weaves the template in whenever the holder is read, one level per
nesting step, and the resolved objects get derived ids – `<instance id>~<template id>`, 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/<category>/<Type>.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.
+80 -87
View File
@@ -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<T>(
project: Project,
link: TemplateLink,
slots: readonly Slot<T>[],
values: readonly T[],
): SlotMemo<T>[] {
const before = slots.map((slot) => slot.read(link.template));
const memos: SlotMemo<T>[] = 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<PropertyValue | undefined>[] = [];
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<PropertyValue | undefined>[] = [];
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<number>[] = [];
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);
}
}
+5 -1
View File
@@ -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',
+60 -33
View File
@@ -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<SceneObject>[] = [];
/** Kept across a redo: the ids of the resolved content are built from them. */
private readonly templateIds = new Map<string, string>();
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<string, InstanceEdit> = {};
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;
}
}
+25 -1
View File
@@ -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`);
+63 -99
View File
@@ -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<number>[] = [];
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<string>[] = [];
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<SceneObject> {
private memos: SlotMemo<PropertyValue | undefined>[] = [];
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');
}
-89
View File
@@ -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<T> {
read(object: SceneObject): T;
write(object: SceneObject, value: T): void;
}
export interface SlotMemo<T> {
object: SceneObject;
slot: Slot<T>;
previous: T;
}
export const overrideSlot = (key: string): Slot<PropertyValue | undefined> => ({
read: (object) => object.overrides[key],
write: (object, value) => setBagValue(object.overrides, key, value),
});
export const positionSlot = (axis: Axis): Slot<number> => ({
read: (object) => object.position[axis],
write: (object, value) => {
object.position[axis] = value;
},
});
export const nameSlot: Slot<string> = {
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<T>(
project: Project,
template: SceneObject,
slots: readonly Slot<T>[],
before: readonly T[],
after: readonly T[],
): SlotMemo<T>[] {
const memos: SlotMemo<T>[] = [];
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<T>(
project: Project,
holderId: string,
object: SceneObject,
slots: readonly Slot<T>[],
before: readonly T[],
): SlotMemo<T>[] {
if (findObjectHolder(project, holderId)?.type !== 'container') return [];
return syncInstances(
project,
object,
slots,
before,
slots.map((slot) => slot.read(object)),
);
}
export function restoreSlots<T>(memos: readonly SlotMemo<T>[]): void {
for (const memo of [...memos].reverse()) memo.slot.write(memo.object, memo.previous);
}
+16 -13
View File
@@ -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<PropertyDef>; 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;
+3 -3
View File
@@ -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';
+12 -8
View File
@@ -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;
}
+6 -4
View File
@@ -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<string, InstanceEdit>;
}
export function createSceneObject(
@@ -43,6 +45,6 @@ export function createSceneObject(
editorOffset: { x: 0, y: 0 },
overrides: {},
parentId,
templateId: '',
instanceEdits: {},
};
}
+3 -79
View File
@@ -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;
}
+429
View File
@@ -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 (`<instance id>~<path>`), 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<Record<Axis, number>>;
editorOffset?: Vec2;
overrides: PropertyBag;
}
export function emptyEdit(): InstanceEdit {
return { position: {}, overrides: {} };
}
export function isEmptyEdit(edit: InstanceEdit): boolean {
return (
edit.name === undefined &&
edit.editorOffset === undefined &&
Object.keys(edit.position).length === 0 &&
Object.keys(edit.overrides).length === 0
);
}
function copyEdit(edit: InstanceEdit): InstanceEdit {
return {
...(edit.name === undefined ? {} : { name: edit.name }),
...(edit.editorOffset === undefined ? {} : { editorOffset: { ...edit.editorOffset } }),
position: { ...edit.position },
overrides: { ...edit.overrides },
};
}
/** The outer edit wins per field and per key; anything it leaves out keeps the inner value. */
function mergeEdits(
inner: InstanceEdit | undefined,
outer: InstanceEdit | undefined,
): InstanceEdit | undefined {
if (!inner) return outer;
if (!outer) return inner;
return {
name: outer.name ?? inner.name,
editorOffset: outer.editorOffset ?? inner.editorOffset,
position: { ...inner.position, ...outer.position },
overrides: { ...inner.overrides, ...outer.overrides },
};
}
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<SceneObject, ContainerAsset> {
const parents = new Set(holder.objects.map((o) => o.parentId));
const live = new Map<SceneObject, ContainerAsset>();
for (const object of holder.objects) {
if (parents.has(object.id)) continue;
const asset = containerAssetOf(project, object);
if (asset) live.set(object, asset);
}
return live;
}
export function isLiveInstance(project: Project, holder: ObjectTree, object: SceneObject): boolean {
return liveInstances(project, holder).has(object);
}
export function instanceChildId(instanceId: string, path: string): string {
return `${instanceId}${ID_SEP}${path}`;
}
/** Splits a derived id back into the instance it belongs to and the template path below it. */
export function splitInstanceId(id: string): { instanceId: string; path: string } | undefined {
const at = id.indexOf(ID_SEP);
if (at < 0) return undefined;
return { instanceId: id.slice(0, at), path: id.slice(at + 1) };
}
/**
* 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;
}
+28 -1
View File
@@ -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<string, PropertySchema>): 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<string, PropertySchema>): 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<string, InstanceEdit> {
const edits: Record<string, InstanceEdit> = {};
for (const [path, value] of Object.entries(asRecord(raw))) {
if (!path) continue;
const e = asRecord(value);
const edit = emptyEdit();
if (typeof e.name === 'string') edit.name = e.name;
const position = asRecord(e.position);
for (const axis of ['x', 'y', 'z'] as const)
if (typeof position[axis] === 'number' && Number.isFinite(position[axis]))
edit.position[axis] = position[axis];
const offset = asRecord(e.editorOffset);
if (typeof offset.x === 'number' || typeof offset.y === 'number')
edit.editorOffset = { x: toNumber(offset.x), y: toNumber(offset.y) };
for (const [key, v] of Object.entries(asRecord(e.overrides)))
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<string>();
+12
View File
@@ -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,
+151 -82
View File
@@ -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<V extends object> {
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<V extends object>(
host,
{ key: spec.key, label: spec.label, kind: 'number', default: 0, step: 1 },
value,
previewingHandlers<SceneObject, V>(host, {
target: () => currentObject(host.state, holderId, objectId),
previewingHandlers<ObjectRef, V>(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<V extends object>(
* 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<SceneObject>(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<ObjectRef>(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: <name> ▾" – 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;
}
+19 -3
View File
@@ -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),
},
]);
+5 -2
View File
@@ -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;
}
+8 -6
View File
@@ -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<Entity>();
@@ -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;
+17 -12
View File
@@ -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();
+6 -3
View File
@@ -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,
+26 -19
View File
@@ -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();
});
});
+230 -178
View File
@@ -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(
+44 -6
View File
@@ -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();
+1 -1
View File
@@ -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({
+7 -2
View File
@@ -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)),
};
}
+1 -1
View File
@@ -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(
+418 -166
View File
@@ -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);
}
+1 -1
View File
@@ -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'));
+18 -7
View File
@@ -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);
+1 -2
View File
@@ -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);
+1 -1
View File
@@ -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));
+81 -25
View File
@@ -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 `<instance>~${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 `<instance>~<outer>/<inner>`.
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);
}
+9 -31
View File
@@ -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: `<instance>~<template ids>`. 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 };
}
+11 -2
View File
@@ -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,
+8 -9
View File
@@ -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 },
+47 -12
View File
@@ -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(' | '));