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:
1 parent
9d93020bd8
commit
ead649eec3
37 files changed
+1878
-1006
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(' | '));
|
||||
|
||||
Reference in new issue
Block a user