diff --git a/README.md b/README.md
index 47dac9d4..26f70c0c 100644
--- a/README.md
+++ b/README.md
@@ -15,8 +15,8 @@ Live: · Widget playground: /.ts
src/core/store/ serialize (untrusted JSON → Project), HttpProjectStore
src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, BehaviorSystem
@@ -309,6 +352,7 @@ src/editor/inspector/ Inspector panel, field factory, previewHandlers (scrub
one section per selection kind (asset/object/node)
src/editor/panels/ AssetPanel, HierarchyPanel, Toolbar
src/editor/graph/ GraphEditor (header + picker), GraphCanvas, NodeView, NodePalette
+src/editor/container/ ContainerEditor (header + picker), ContainerCanvas (a SceneViewport on a template)
api/ PHP backend data/projects/ persisted projects
tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/), smoke-jumprun, lib/
```
@@ -353,7 +397,8 @@ rewritten by every e2e run and the **jump-run** project by its seeder. The **pla
project is the author's own and is maintained in the editor – every tool refuses to touch it.
The e2e run is a list of steps in `tools/e2e/` (hierarchy, inspector, play, assets, drawing,
-graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect) that build
+graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect,
+container tab) that build
on each other's scene state
and share the CDP driver in `tools/lib/harness.mjs` – `press`, `undo`, `play`, `object(id)`,
`world()`, `mutate(js)` and friends, so a check reads like the interaction it exercises.
@@ -405,11 +450,9 @@ object in the selection (hierarchy rows too).
## Not yet
-Editing a container's template directly (today changes reach it only via **Apply to
-container** from an instance), reordering the hierarchy, several scenes in one run (a
-`Load scene` node – screens are containers today), sound, auth, touch-optimised layout,
-graph comments/groups. A frame is always stretched to the sprite's `width` × `height`: no
-animation (nothing steps through a sheet's frames yet) and no nine-slice. ↔ / ↕ fit the
+Reordering the hierarchy, several scenes in one run (a `Load scene` node – screens are containers
+today), sound, auth, touch-optimised layout, graph comments/groups. A frame is always stretched to
+the sprite's `width` × `height`: no animation (nothing steps through a sheet's frames yet) and no nine-slice. ↔ / ↕ fit the
proportions, but nothing sets a sprite to the picture's pixel size outright. Slicing cuts a
regular grid only — no detection of where the sprites actually
sit on the sheet. Unreferenced image files are never collected.
diff --git a/src/core/commands/applyCommands.ts b/src/core/commands/applyCommands.ts
index 3b20696e..10c4cb4e 100644
--- a/src/core/commands/applyCommands.ts
+++ b/src/core/commands/applyCommands.ts
@@ -3,47 +3,30 @@
* 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.
*/
-import { instancesOf, type TemplateLink, templateLinkOf } from '../model/container';
+import { type TemplateLink, templateLinkOf } from '../model/container';
import { type Axis, type Position, type PropertyValue, setBagValue } from '../model/Properties';
-import { findScene, type Project } from '../model/Project';
+import { findObjectHolder, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
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';
-const ALL_AXES: readonly Axis[] = ['x', 'y', 'z'];
-
-function requireLink(project: Project, sceneId: string, object: SceneObject): TemplateLink {
- const scene = findScene(project, sceneId);
- const link = scene && templateLinkOf(project, scene, object);
+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;
}
-/** One value that is applied on its own: an override key, or a single axis of the position. */
-interface Slot {
- read(object: SceneObject): T;
- write(object: SceneObject, value: T): void;
-}
-
-const overrideSlot = (key: string): Slot => ({
- read: (object) => object.overrides[key],
- write: (object, value) => setBagValue(object.overrides, key, value),
-});
-
-const positionSlot = (axis: Axis): Slot => ({
- read: (object) => object.position[axis],
- write: (object, value) => {
- object.position[axis] = value;
- },
-});
-
-interface SlotMemo {
- object: SceneObject;
- slot: Slot;
- previous: T;
-}
-
/**
* 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.
@@ -60,21 +43,11 @@ function applyToTemplate(
slot,
previous: before[i]!,
}));
- for (const { object } of instancesOf(project, link.template.id)) {
- slots.forEach((slot, i) => {
- if (slot.read(object) !== before[i]) return;
- memos.push({ object, slot, previous: slot.read(object) });
- slot.write(object, values[i]!);
- });
- }
+ memos.push(...syncInstances(project, link.template, slots, before, values));
slots.forEach((slot, i) => slot.write(link.template, values[i]!));
return memos;
}
-function restoreSlots(memos: readonly SlotMemo[]): void {
- for (const memo of [...memos].reverse()) memo.slot.write(memo.object, memo.previous);
-}
-
/**
* 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.
@@ -86,7 +59,7 @@ export class ApplyOverrideToAssetCommand implements Command {
private templateMemos: SlotMemo[] = [];
constructor(
- private readonly sceneId: string,
+ private readonly holderId: string,
private readonly objectId: string,
private readonly key: string,
) {
@@ -94,8 +67,8 @@ export class ApplyOverrideToAssetCommand implements Command {
}
execute(project: Project): void {
- const scene = findScene(project, this.sceneId);
- const object = requireObject(project, this.sceneId, this.objectId);
+ const holder = findObjectHolder(project, this.holderId);
+ const object = requireObject(project, this.holderId, this.objectId);
const asset = requireAsset(project, object.assetId);
requirePropDef(asset, this.key);
const value = resolveSceneObjectProps(asset, object)[this.key];
@@ -103,12 +76,12 @@ export class ApplyOverrideToAssetCommand implements Command {
this.previousOverride = object.overrides[this.key];
asset.props[this.key] = value!;
delete object.overrides[this.key];
- const link = scene && templateLinkOf(project, scene, object);
+ const link = holder && templateLinkOf(project, holder, object);
this.templateMemos = link ? applyToTemplate(project, link, [overrideSlot(this.key)], [undefined]) : [];
}
undo(project: Project): void {
- const object = requireObject(project, this.sceneId, this.objectId);
+ const object = requireObject(project, this.holderId, this.objectId);
const asset = requireAsset(project, object.assetId);
restoreSlots(this.templateMemos);
setBagValue(object.overrides, this.key, this.previousOverride);
@@ -121,7 +94,7 @@ export class ApplyOverrideToContainerCommand implements Command {
private memos: SlotMemo[] = [];
constructor(
- private readonly sceneId: string,
+ private readonly holderId: string,
private readonly objectId: string,
private readonly key: string,
) {
@@ -129,8 +102,8 @@ export class ApplyOverrideToContainerCommand implements Command {
}
execute(project: Project): void {
- const object = requireObject(project, this.sceneId, this.objectId);
- const link = requireLink(project, this.sceneId, object);
+ 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]]);
}
@@ -144,7 +117,7 @@ export class RevertOverrideToTemplateCommand implements Command {
private previous: PropertyValue | undefined;
constructor(
- private readonly sceneId: string,
+ private readonly holderId: string,
private readonly objectId: string,
private readonly key: string,
) {
@@ -152,14 +125,14 @@ export class RevertOverrideToTemplateCommand implements Command {
}
execute(project: Project): void {
- const object = requireObject(project, this.sceneId, this.objectId);
- const link = requireLink(project, this.sceneId, object);
+ 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]);
}
undo(project: Project): void {
- setBagValue(requireObject(project, this.sceneId, this.objectId).overrides, this.key, this.previous);
+ setBagValue(requireObject(project, this.holderId, this.objectId).overrides, this.key, this.previous);
}
}
@@ -169,7 +142,7 @@ export class ApplyPositionToContainerCommand implements Command {
private memos: SlotMemo[] = [];
constructor(
- private readonly sceneId: string,
+ private readonly holderId: string,
private readonly objectId: string,
private readonly axis?: Axis,
) {
@@ -177,8 +150,8 @@ export class ApplyPositionToContainerCommand implements Command {
}
execute(project: Project): void {
- const object = requireObject(project, this.sceneId, this.objectId);
- const link = requireLink(project, this.sceneId, object);
+ 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,
@@ -198,7 +171,7 @@ export class RevertPositionToTemplateCommand implements Command {
private previous: Position = { x: 0, y: 0, z: 0 };
constructor(
- private readonly sceneId: string,
+ private readonly holderId: string,
private readonly objectId: string,
private readonly axis?: Axis,
) {
@@ -206,13 +179,13 @@ export class RevertPositionToTemplateCommand implements Command {
}
execute(project: Project): void {
- const object = requireObject(project, this.sceneId, this.objectId);
- const link = requireLink(project, this.sceneId, object);
+ 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];
}
undo(project: Project): void {
- requireObject(project, this.sceneId, this.objectId).position = { ...this.previous };
+ requireObject(project, this.holderId, this.objectId).position = { ...this.previous };
}
}
diff --git a/src/core/commands/colliderCommands.ts b/src/core/commands/colliderCommands.ts
index 830eda44..f601cf24 100644
--- a/src/core/commands/colliderCommands.ts
+++ b/src/core/commands/colliderCommands.ts
@@ -6,7 +6,7 @@ import { removeById } from '../util/list';
import { buildAsset } from './assetCommands';
import { type Command, CommandError } from './Command';
import { nextAssetName } from './naming';
-import { requireAsset, requireObject, requireScene } from './require';
+import { requireAsset, requireObject, requireHolder } from './require';
/**
* Creates a collider asset sized like the object (sprites: width × height; anything else: schema
@@ -18,13 +18,13 @@ export class AddColliderCommand implements Command {
readonly objectId = newId();
constructor(
- private readonly sceneId: string,
+ private readonly holderId: string,
private readonly targetId: string,
) {}
execute(project: Project): void {
- const scene = requireScene(project, this.sceneId);
- const target = requireObject(project, this.sceneId, this.targetId);
+ const holder = requireHolder(project, this.holderId);
+ 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');
@@ -39,12 +39,12 @@ export class AddColliderCommand implements Command {
asset.props.height = Number(props.height);
}
project.assets.push(asset);
- scene.objects.push(createSceneObject(this.objectId, this.assetId, { x: 0, y: 0, z: 0 }, this.targetId));
+ holder.objects.push(createSceneObject(this.objectId, this.assetId, { x: 0, y: 0, z: 0 }, this.targetId));
}
undo(project: Project): void {
- const scene = requireScene(project, this.sceneId);
- scene.objects = removeById(scene.objects, this.objectId);
+ const holder = requireHolder(project, this.holderId);
+ holder.objects = removeById(holder.objects, this.objectId);
project.assets = removeById(project.assets, this.assetId);
}
}
diff --git a/src/core/commands/containerCommands.ts b/src/core/commands/containerCommands.ts
index 94e01736..5b4c8944 100644
--- a/src/core/commands/containerCommands.ts
+++ b/src/core/commands/containerCommands.ts
@@ -9,13 +9,13 @@ import {
worldPosition,
} from '../model/hierarchy';
import type { Position } from '../model/Properties';
-import { findScene, type Project } from '../model/Project';
+import { findObjectHolder, type Project } from '../model/Project';
import { createSceneObject } from '../model/SceneObject';
import { removeById } from '../util/list';
import { buildAsset } from './assetCommands';
import { type Command, CommandError } from './Command';
import { nextAssetName } from './naming';
-import { requireObject, requireScene } from './require';
+import { requireObject, requireHolder } from './require';
/**
* Turns the selected objects, with everything parented below them, into a container asset placed
@@ -30,16 +30,16 @@ export class CreateContainerCommand implements Command {
private previous: { id: string; parentId: string; position: Position; templateId: string }[] = [];
constructor(
- private readonly sceneId: string,
+ private readonly holderId: string,
private readonly objectIds: readonly string[],
) {}
execute(project: Project): void {
- const scene = requireScene(project, this.sceneId);
- const selected = [...new Set(this.objectIds)].map((id) => requireObject(project, this.sceneId, id));
+ const holder = requireHolder(project, this.holderId);
+ const selected = [...new Set(this.objectIds)].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(scene, o));
+ const worlds = selected.map((o) => worldPosition(holder, o));
const centre: Position = {
x: Math.round((Math.min(...worlds.map((p) => p.x)) + Math.max(...worlds.map((p) => p.x))) / 2),
y: Math.round((Math.min(...worlds.map((p) => p.y)) + Math.max(...worlds.map((p) => p.y))) / 2),
@@ -49,15 +49,15 @@ export class CreateContainerCommand implements Command {
// Members are collected in tree order, so a parent is always built before its children.
const roots = new Set(
subtreeRoots(
- scene,
+ holder,
selected.map((o) => o.id),
),
);
- const memberIds = subtreeIds(scene, roots);
- const members = treeOrder(scene)
+ const memberIds = subtreeIds(holder, roots);
+ const members = treeOrder(holder)
.map((e) => e.object)
.filter((o) => memberIds.has(o.id));
- const rootParents = new Set([...roots].map((id) => findObject(scene, id)!.parentId));
+ const rootParents = new Set([...roots].map((id) => findObject(holder, id)!.parentId));
const containerParentId = rootParents.size === 1 ? [...rootParents][0]! : '';
const asset = buildAsset(
@@ -68,7 +68,7 @@ export class CreateContainerCommand implements Command {
const templateIds = new Map(members.map((o) => [o.id, newId()]));
for (const object of members) {
const isRoot = roots.has(object.id);
- const world = worldPosition(scene, object);
+ const world = worldPosition(holder, object);
asset.objects.push({
id: templateIds.get(object.id)!,
assetId: object.assetId,
@@ -93,32 +93,32 @@ export class CreateContainerCommand implements Command {
const container = createSceneObject(
this.objectId,
this.assetId,
- localPositionFor(scene, containerParentId, centre),
+ localPositionFor(holder, containerParentId, centre),
containerParentId,
);
for (const object of members) {
if (roots.has(object.id)) {
- const world = worldPosition(scene, object);
+ 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)!;
}
project.assets.push(asset);
- scene.objects.push(container);
+ holder.objects.push(container);
}
undo(project: Project): void {
- const scene = findScene(project, this.sceneId);
- if (scene) {
+ const holder = findObjectHolder(project, this.holderId);
+ if (holder) {
for (const entry of this.previous) {
- const object = findObject(scene, entry.id);
+ const object = findObject(holder, entry.id);
if (!object) continue;
object.parentId = entry.parentId;
object.position = { ...entry.position };
object.templateId = entry.templateId;
}
- scene.objects = removeById(scene.objects, this.objectId);
+ holder.objects = removeById(holder.objects, this.objectId);
}
project.assets = removeById(project.assets, this.assetId);
}
diff --git a/src/core/commands/require.ts b/src/core/commands/require.ts
index e8eff9bd..b4141ef7 100644
--- a/src/core/commands/require.ts
+++ b/src/core/commands/require.ts
@@ -3,7 +3,7 @@ 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 { findAsset, findScene, type Project } from '../model/Project';
+import { findAsset, findObjectHolder, findScene, type ObjectHolder, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { propDef, type PropertyDef } from '../properties/PropertySchema';
import { CommandError } from './Command';
@@ -20,8 +20,14 @@ export function requireScene(project: Project, sceneId: string): SceneAsset {
return scene;
}
-export function requireObject(project: Project, sceneId: string, objectId: string): SceneObject {
- const object = findObject(requireScene(project, sceneId), objectId);
+export function requireHolder(project: Project, holderId: string): ObjectHolder {
+ const holder = findObjectHolder(project, holderId);
+ if (!holder) throw new CommandError(`Scene or container ${holderId} not found`);
+ return holder;
+}
+
+export function requireObject(project: Project, holderId: string, objectId: string): SceneObject {
+ const object = findObject(requireHolder(project, holderId), objectId);
if (!object) throw new CommandError(`Scene object ${objectId} not found`);
return object;
}
diff --git a/src/core/commands/sceneObjectCommands.ts b/src/core/commands/sceneObjectCommands.ts
index 3cd280f8..6da64eda 100644
--- a/src/core/commands/sceneObjectCommands.ts
+++ b/src/core/commands/sceneObjectCommands.ts
@@ -1,6 +1,6 @@
import { newId } from '../ids';
import { isPlaceableType } from '../model/AssetTypes';
-import { instantiateContainer } from '../model/container';
+import { containerReaches, instantiateContainer } from '../model/container';
import { isSelfOrAncestor, localPositionFor, removeSubtrees, worldPosition } from '../model/hierarchy';
import {
type Position,
@@ -14,12 +14,23 @@ 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, requireScene } from './require';
+import { requireAsset, requireObject, requirePropDef, requireHolder } from './require';
import { SetBagValueCommand } from './SetBagValueCommand';
+import {
+ ALL_AXES,
+ nameSlot,
+ overrideSlot,
+ positionSlot,
+ restoreSlots,
+ type SlotMemo,
+ syncTemplateEdit,
+} from './templateSync';
/**
* The position is a world position; with a parent it is converted to the parent's local space.
- * Placing a container also creates its content (see `instantiateContainer`).
+ * 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.
*/
export class PlaceAssetCommand implements Command {
readonly label = 'Place asset';
@@ -27,28 +38,35 @@ export class PlaceAssetCommand implements Command {
private createdIds: string[] = [];
constructor(
- private readonly sceneId: string,
+ private readonly holderId: string,
private readonly assetId: string,
private readonly position: Vec2 | Position,
private readonly parentId = '',
) {}
execute(project: Project): void {
- const scene = requireScene(project, this.sceneId);
+ 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 (this.parentId) requireObject(project, this.sceneId, this.parentId);
- const local = localPositionFor(scene, this.parentId, toPosition(this.position));
+ if (
+ holder.type === 'container' &&
+ 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);
+ const local = localPositionFor(holder, this.parentId, toPosition(this.position));
const created = [createSceneObject(this.objectId, this.assetId, local, this.parentId)];
- if (asset.type === 'container') created.push(...instantiateContainer(project, asset, this.objectId));
+ if (asset.type === 'container' && holder.type === 'scene')
+ created.push(...instantiateContainer(project, asset, this.objectId));
this.createdIds = created.map((o) => o.id);
- scene.objects.push(...created);
+ holder.objects.push(...created);
}
undo(project: Project): void {
- const scene = requireScene(project, this.sceneId);
+ const holder = requireHolder(project, this.holderId);
const ids = new Set(this.createdIds);
- scene.objects = scene.objects.filter((o) => !ids.has(o.id));
+ holder.objects = holder.objects.filter((o) => !ids.has(o.id));
}
}
@@ -58,25 +76,25 @@ export class RemoveSceneObjectsCommand implements Command {
private removed: Removed[] = [];
constructor(
- private readonly sceneId: string,
+ private readonly holderId: string,
private readonly objectIds: readonly string[],
) {
this.label = objectIds.length === 1 ? 'Remove object' : 'Remove objects';
}
execute(project: Project): void {
- for (const id of this.objectIds) requireObject(project, this.sceneId, id);
- this.removed = removeSubtrees(requireScene(project, this.sceneId), this.objectIds);
+ for (const id of this.objectIds) requireObject(project, this.holderId, id);
+ this.removed = removeSubtrees(requireHolder(project, this.holderId), this.objectIds);
}
undo(project: Project): void {
- restoreAt(requireScene(project, this.sceneId).objects, this.removed);
+ restoreAt(requireHolder(project, this.holderId).objects, this.removed);
}
}
export class RemoveSceneObjectCommand extends RemoveSceneObjectsCommand {
- constructor(sceneId: string, objectId: string) {
- super(sceneId, [objectId]);
+ constructor(holderId: string, objectId: string) {
+ super(holderId, [objectId]);
}
}
@@ -87,21 +105,30 @@ 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[] = [];
constructor(
- private readonly sceneId: string,
+ private readonly holderId: string,
private readonly objectId: string,
private readonly position: Position,
) {}
execute(project: Project): void {
- const object = requireObject(project, this.sceneId, this.objectId);
+ 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]),
+ );
}
undo(project: Project): void {
- requireObject(project, this.sceneId, this.objectId).position = { ...this.previous };
+ restoreSlots(this.memos);
+ requireObject(project, this.holderId, this.objectId).position = { ...this.previous };
}
}
@@ -111,46 +138,51 @@ export class SetEditorOffsetCommand implements Command {
private previous: Vec2 = { x: 0, y: 0 };
constructor(
- private readonly sceneId: string,
+ private readonly holderId: string,
private readonly objectId: string,
private readonly offset: Vec2,
) {}
execute(project: Project): void {
- const object = requireObject(project, this.sceneId, this.objectId);
+ const object = requireObject(project, this.holderId, this.objectId);
this.previous = { ...object.editorOffset };
object.editorOffset = { x: this.offset.x, y: this.offset.y };
}
undo(project: Project): void {
- requireObject(project, this.sceneId, this.objectId).editorOffset = { ...this.previous };
+ requireObject(project, this.holderId, this.objectId).editorOffset = { ...this.previous };
}
}
export class RenameSceneObjectCommand implements Command {
readonly label = 'Rename object';
private previous = '';
+ private memos: SlotMemo[] = [];
constructor(
- private readonly sceneId: string,
+ private readonly holderId: string,
private readonly objectId: string,
private readonly name: string,
) {}
execute(project: Project): void {
- const object = requireObject(project, this.sceneId, this.objectId);
+ 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]);
}
undo(project: Project): void {
- requireObject(project, this.sceneId, this.objectId).name = this.previous;
+ restoreSlots(this.memos);
+ requireObject(project, this.holderId, this.objectId).name = this.previous;
}
}
abstract class OverrideCommand extends SetBagValueCommand {
+ private memos: SlotMemo[] = [];
+
protected constructor(
- private readonly sceneId: string,
+ private readonly holderId: string,
private readonly objectId: string,
key: string,
label: string,
@@ -159,22 +191,39 @@ abstract class OverrideCommand extends SetBagValueCommand {
}
protected override resolveTarget(project: Project): SceneObject {
- return requireObject(project, this.sceneId, this.objectId);
+ 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);
+ }
}
export class SetOverrideCommand extends OverrideCommand {
constructor(
- sceneId: string,
+ holderId: string,
objectId: string,
key: string,
private readonly value: PropertyValue,
) {
- super(sceneId, objectId, key, `Override ${key}`);
+ super(holderId, objectId, key, `Override ${key}`);
}
protected override computeNext(object: SceneObject, project: Project): PropertyValue {
@@ -183,8 +232,8 @@ export class SetOverrideCommand extends OverrideCommand {
}
export class ClearOverrideCommand extends OverrideCommand {
- constructor(sceneId: string, objectId: string, key: string) {
- super(sceneId, objectId, key, `Reset ${key}`);
+ constructor(holderId: string, objectId: string, key: string) {
+ super(holderId, objectId, key, `Reset ${key}`);
}
// No schema check, unlike setting one: a key the asset no longer declares is exactly what one
@@ -206,28 +255,28 @@ export class SetParentCommand implements Command {
};
constructor(
- private readonly sceneId: string,
+ private readonly holderId: string,
private readonly objectId: string,
private readonly parentId: string,
) {}
execute(project: Project): void {
- const scene = requireScene(project, this.sceneId);
- const object = requireObject(project, this.sceneId, this.objectId);
+ const holder = requireHolder(project, this.holderId);
+ 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.sceneId, this.parentId);
- if (isSelfOrAncestor(scene, this.objectId, this.parentId))
+ requireObject(project, this.holderId, this.parentId);
+ if (isSelfOrAncestor(holder, this.objectId, this.parentId))
throw new CommandError('An object cannot be parented to one of its own children');
}
this.previous = { parentId: object.parentId, position: { ...object.position } };
- const world = worldPosition(scene, object);
+ const world = worldPosition(holder, object);
object.parentId = this.parentId;
- object.position = localPositionFor(scene, this.parentId, world);
+ object.position = localPositionFor(holder, this.parentId, world);
}
undo(project: Project): void {
- const object = requireObject(project, this.sceneId, this.objectId);
+ const object = requireObject(project, this.holderId, this.objectId);
object.parentId = this.previous.parentId;
object.position = { ...this.previous.position };
}
diff --git a/src/core/commands/templateSync.ts b/src/core/commands/templateSync.ts
new file mode 100644
index 00000000..3a61e236
--- /dev/null
+++ b/src/core/commands/templateSync.ts
@@ -0,0 +1,89 @@
+/**
+ * Prefab semantics for container templates: a placed copy that still holds the template's old value
+ * follows a change made to the template, a copy modified on its own keeps what it has. Used by both
+ * directions – an edit in the container editor (see the object commands) and "apply" from a placed
+ * object upwards (see `applyCommands`).
+ */
+import { instancesOf } from '../model/container';
+import { type Axis, type PropertyValue, setBagValue } from '../model/Properties';
+import { findObjectHolder, type Project } from '../model/Project';
+import type { SceneObject } from '../model/SceneObject';
+
+export const ALL_AXES: readonly Axis[] = ['x', 'y', 'z'];
+
+/** One value that follows on its own: an override key, a single axis of the position, the name. */
+export interface Slot {
+ read(object: SceneObject): T;
+ write(object: SceneObject, value: T): void;
+}
+
+export interface SlotMemo {
+ object: SceneObject;
+ slot: Slot;
+ previous: T;
+}
+
+export const overrideSlot = (key: string): Slot => ({
+ read: (object) => object.overrides[key],
+ write: (object, value) => setBagValue(object.overrides, key, value),
+});
+
+export const positionSlot = (axis: Axis): Slot => ({
+ read: (object) => object.position[axis],
+ write: (object, value) => {
+ object.position[axis] = value;
+ },
+});
+
+export const nameSlot: Slot = {
+ read: (object) => object.name,
+ write: (object, value) => {
+ object.name = value;
+ },
+};
+
+/** Writes `after` into every copy of `template` whose slot still reads `before`. */
+export function syncInstances(
+ project: Project,
+ template: SceneObject,
+ slots: readonly Slot[],
+ before: readonly T[],
+ after: readonly T[],
+): SlotMemo[] {
+ const memos: SlotMemo[] = [];
+ for (const { object } of instancesOf(project, template.id)) {
+ slots.forEach((slot, i) => {
+ const current = slot.read(object);
+ if (current !== before[i]) return;
+ memos.push({ object, slot, previous: current });
+ slot.write(object, after[i]!);
+ });
+ }
+ return memos;
+}
+
+/**
+ * Carries an edit of a container template object to the placed copies; nothing to do when the
+ * holder is a scene, where an edit is meant for that one object. `before` has to be read off the
+ * object before it is written, the new values are read back from it here.
+ */
+export function syncTemplateEdit(
+ project: Project,
+ holderId: string,
+ object: SceneObject,
+ slots: readonly Slot[],
+ before: readonly T[],
+): SlotMemo[] {
+ if (findObjectHolder(project, holderId)?.type !== 'container') return [];
+ return syncInstances(
+ project,
+ object,
+ slots,
+ before,
+ slots.map((slot) => slot.read(object)),
+ );
+}
+
+export function restoreSlots(memos: readonly SlotMemo[]): void {
+ for (const memo of [...memos].reverse()) memo.slot.write(memo.object, memo.previous);
+}
diff --git a/src/core/model/Project.ts b/src/core/model/Project.ts
index beda7fae..4962733e 100644
--- a/src/core/model/Project.ts
+++ b/src/core/model/Project.ts
@@ -37,8 +37,26 @@ export function listScenes(project: Project): SceneAsset[] {
*/
export type ObjectHolder = SceneAsset | ContainerAsset;
+export function isObjectHolder(asset: Asset): asset is ObjectHolder {
+ return asset.type === 'scene' || asset.type === 'container';
+}
+
export function objectHolders(project: Project): ObjectHolder[] {
- return project.assets.filter((a): a is ObjectHolder => a.type === 'scene' || a.type === 'container');
+ return project.assets.filter(isObjectHolder);
+}
+
+/**
+ * The scene or container asset an object belongs to. Editing a container's template is the same
+ * work as editing a scene, so the object commands resolve their target through this rather than
+ * through `findScene`.
+ */
+export function findObjectHolder(project: Project, holderId: string): ObjectHolder | undefined {
+ const asset = findAsset(project, holderId);
+ return asset && isObjectHolder(asset) ? asset : undefined;
+}
+
+export function listContainers(project: Project): ContainerAsset[] {
+ return project.assets.filter((a): a is ContainerAsset => a.type === 'container');
}
export interface SceneObjectRef {
diff --git a/src/core/model/container.ts b/src/core/model/container.ts
index 7104b291..8f301b07 100644
--- a/src/core/model/container.ts
+++ b/src/core/model/container.ts
@@ -37,6 +37,27 @@ export function instancesOf(project: Project, templateId: string): SceneObjectRe
return objectsWhere(project, (o) => o.templateId === templateId);
}
+/**
+ * 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.
+ */
+export function containerReaches(project: Project, asset: ContainerAsset, wantedId: string): boolean {
+ if (asset.id === wantedId) return true;
+ const seen = new Set([asset.id]);
+ const queue = [asset];
+ for (const current of queue) {
+ for (const template of current.objects) {
+ const nested = findAsset(project, template.assetId);
+ if (nested?.type !== 'container' || seen.has(nested.id)) continue;
+ if (nested.id === wantedId) return true;
+ seen.add(nested.id);
+ queue.push(nested);
+ }
+ }
+ return false;
+}
+
const MAX_NESTING = 8;
/**
diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts
index f5f1afe0..070298ce 100644
--- a/src/editor/Editor.ts
+++ b/src/editor/Editor.ts
@@ -1,13 +1,15 @@
import type { Application } from 'pixi.js';
+import { findAsset, findObjectHolder } from '../core/model/Project';
import type { ProjectStore } from '../core/store/ProjectStore';
import type { TextureStore } from '../core/store/TextureStore';
import { Button, HStack, Splitter, Theme, ui, VStack } from '../ui';
import { GameViewport } from '../viewport/GameViewport';
import { SceneViewport } from '../viewport/SceneViewport';
import { attachAutosave } from './autosave';
+import { ContainerEditor } from './container/ContainerEditor';
import { GraphEditor } from './graph/GraphEditor';
import { Inspector } from './inspector/Inspector';
-import { AssetPanel } from './panels/AssetPanel';
+import { AssetPanel, type PlacementTarget } from './panels/AssetPanel';
import { HierarchyPanel } from './panels/HierarchyPanel';
import { Toolbar } from './panels/Toolbar';
import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession';
@@ -17,9 +19,9 @@ import type { EditorState } from './state';
import { TextureEditor } from './texture/TextureEditor';
import { installTextureImport } from './textureImport';
-export type CentreTab = 'scene' | 'graph' | 'game' | 'texture';
+export type CentreTab = 'scene' | 'graph' | 'game' | 'texture' | 'container';
-const TAB_ORDER = ['scene', 'game', 'graph', 'texture'] as const;
+const TAB_ORDER = ['scene', 'game', 'graph', 'texture', 'container'] as const;
const MIN_LEFT_WIDTH = 160;
const MAX_LEFT_WIDTH = 500;
@@ -38,6 +40,7 @@ export class Editor implements PlayHost {
readonly gameView: GameViewport;
readonly graphEditor: GraphEditor;
readonly textureEditor: TextureEditor;
+ readonly containerEditor: ContainerEditor;
readonly hierarchy: HierarchyPanel;
tab: CentreTab = 'scene';
/** Frame rate, refreshed twice a second; null until the first measurement. */
@@ -60,7 +63,7 @@ export class Editor implements PlayHost {
constructor(
app: Application,
root: VStack,
- state: EditorState,
+ private readonly state: EditorState,
store: ProjectStore,
textures: TextureStore,
) {
@@ -68,6 +71,7 @@ export class Editor implements PlayHost {
this.gameView = new GameViewport(state);
this.graphEditor = new GraphEditor(state);
this.textureEditor = new TextureEditor(state);
+ this.containerEditor = new ContainerEditor(state);
this.hierarchy = new HierarchyPanel(state);
this.inspector = new Inspector(state);
this.play = new PlaySession(state, this);
@@ -87,7 +91,7 @@ export class Editor implements PlayHost {
this.workspace = new HStack();
this.leftColumn = new VStack({ gap: 1 });
- this.leftColumn.add(new AssetPanel(state, () => this.viewport.worldCenter(), textures), 1);
+ this.leftColumn.add(new AssetPanel(state, () => this.placementTarget(), textures), 1);
installTextureImport(app.canvas, { state, textures });
this.leftColumn.add(this.hierarchy, 1);
@@ -103,6 +107,11 @@ export class Editor implements PlayHost {
width: 90,
onClick: () => this.showTab('texture'),
}),
+ container: new Button('Container', {
+ variant: 'ghost',
+ width: 90,
+ onClick: () => this.showTab('container'),
+ }),
};
for (const tab of TAB_ORDER) tabs.add(this.tabButtons[tab]);
this.centre.add(tabs);
@@ -110,9 +119,11 @@ export class Editor implements PlayHost {
this.centre.add(this.graphEditor, 1);
this.centre.add(this.gameView, 1);
this.centre.add(this.textureEditor, 1);
+ this.centre.add(this.containerEditor, 1);
this.graphEditor.visible = false;
this.gameView.visible = false;
this.textureEditor.visible = false;
+ this.containerEditor.visible = false;
this.tabButtons.scene.setToggled(true);
this.leftColumn.minWidth = this.leftWidth;
@@ -138,12 +149,18 @@ export class Editor implements PlayHost {
attachAutosave(state, store);
installShortcuts(state, { play: () => this.play.play(), stop: () => this.play.stop() });
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
- // A node and a frame exist only in their own tab, so selecting one switches to it;
- // selecting the behavior or texture asset itself does not.
+ // A node, a frame and a template object exist only in their own tab, so selecting one
+ // switches to it; selecting the behavior, texture or container asset itself does not.
state.on('selection', () => {
- const kind = state.selection?.kind;
- if (kind === 'node' && this.tab !== 'graph') this.showTab('graph');
- if (kind === 'frame' && this.tab !== 'texture') this.showTab('texture');
+ const s = state.selection;
+ if (s?.kind === 'node' && this.tab !== 'graph') this.showTab('graph');
+ if (s?.kind === 'frame' && this.tab !== 'texture') this.showTab('texture');
+ if (
+ s?.kind === 'object' &&
+ this.tab !== 'container' &&
+ findAsset(state.project, s.holderId)?.type === 'container'
+ )
+ this.showTab('container');
});
state.on('project', () => this.play.syncFromModel());
}
@@ -154,11 +171,28 @@ export class Editor implements PlayHost {
this.graphEditor.visible = tab === 'graph';
this.gameView.visible = tab === 'game';
this.textureEditor.visible = tab === 'texture';
+ this.containerEditor.visible = tab === 'container';
+ // The hierarchy lists what the visible tab edits; every other tab is about the scene.
+ this.hierarchy.showObjectsOf(
+ tab === 'container'
+ ? () => findObjectHolder(this.state.project, this.containerEditor.currentAssetId)
+ : () => this.state.activeScene,
+ );
for (const key of TAB_ORDER) this.tabButtons[key].setToggled(key === tab);
this.centre.relayout();
ui().focus.validate();
}
+ /** Where the Assets panel places: into the container being edited, otherwise into the scene. */
+ private placementTarget(): PlacementTarget | undefined {
+ if (this.tab === 'container') {
+ const holder = findObjectHolder(this.state.project, this.containerEditor.currentAssetId);
+ return holder ? { holder, at: this.containerEditor.worldCenter() } : undefined;
+ }
+ const scene = this.state.activeScene;
+ return scene ? { holder: scene, at: this.viewport.worldCenter() } : undefined;
+ }
+
boot(): Promise {
return this.projects.boot();
}
diff --git a/src/editor/actions.ts b/src/editor/actions.ts
index 376aa748..d282387c 100644
--- a/src/editor/actions.ts
+++ b/src/editor/actions.ts
@@ -12,24 +12,24 @@ import { Dialog } from '../ui';
import { type EditorState, objectSelection } from './state';
/** Groups the objects into a new container asset placed in their stead; selects the container. */
-export function createContainer(state: EditorState, sceneId: string, objectIds: readonly string[]): boolean {
- const command = new CreateContainerCommand(sceneId, objectIds);
+export function createContainer(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
+ const command = new CreateContainerCommand(holderId, objectIds);
if (!state.execute(command)) return false;
- state.select(objectSelection(sceneId, [command.objectId]));
+ state.select(objectSelection(holderId, [command.objectId]));
return true;
}
/** Gives the object a collider child sized like it; selects the collider. */
-export function addCollider(state: EditorState, sceneId: string, objectId: string): boolean {
- const command = new AddColliderCommand(sceneId, objectId);
+export function addCollider(state: EditorState, holderId: string, objectId: string): boolean {
+ const command = new AddColliderCommand(holderId, objectId);
if (!state.execute(command)) return false;
- state.select(objectSelection(sceneId, [command.objectId]));
+ state.select(objectSelection(holderId, [command.objectId]));
return true;
}
/** Removes the objects (with their subtrees) as one undo step. */
-export function removeObjects(state: EditorState, sceneId: string, objectIds: readonly string[]): boolean {
- return state.execute(new RemoveSceneObjectsCommand(sceneId, objectIds));
+export function removeObjects(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
+ return state.execute(new RemoveSceneObjectsCommand(holderId, objectIds));
}
/** Asks before deleting an asset, spelling out what goes with it. */
diff --git a/src/editor/container/ContainerCanvas.ts b/src/editor/container/ContainerCanvas.ts
new file mode 100644
index 00000000..192a43be
--- /dev/null
+++ b/src/editor/container/ContainerCanvas.ts
@@ -0,0 +1,65 @@
+import { Graphics } from 'pixi.js';
+import { findAsset, type ObjectHolder } from '../../core/model/Project';
+import { Theme } from '../../ui';
+import type { DrawItem } from '../../viewport/items';
+import { SceneViewport } from '../../viewport/SceneViewport';
+import { type View, worldToScreen } from '../../viewport/view';
+import type { EditorState } from '../state';
+
+/** Arm length of the origin cross, in screen pixels. */
+const ORIGIN_ARM = 14;
+
+/**
+ * The editable view of one container's template. Everything about picking, dragging, the marquee
+ * and the labels is the scene view's – the only difference is which asset holds the objects.
+ */
+export class ContainerCanvas extends SceneViewport {
+ private assetId = '';
+ private readonly originMark = new Graphics();
+
+ constructor(state: EditorState) {
+ super(state);
+ this.content.addChild(this.originMark);
+ }
+
+ get currentAssetId(): string {
+ return this.assetId;
+ }
+
+ show(assetId: string): void {
+ if (this.assetId === assetId) return;
+ this.assetId = assetId;
+ this.redraw();
+ }
+
+ protected override holder(): ObjectHolder | undefined {
+ const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
+ return asset?.type === 'container' ? asset : undefined;
+ }
+
+ /** A container has no background of its own – painting one would invent a scene it is not in. */
+ protected override paintsBackground(): boolean {
+ return false;
+ }
+
+ protected override beforeItems(): void {
+ super.beforeItems();
+ this.originMark.clear();
+ }
+
+ /**
+ * The origin, marked because it carries meaning here: it is the point the container is held by
+ * when placed, so where the content sits relative to it is the thing being authored.
+ */
+ protected override afterItems(items: DrawItem[], view: View): void {
+ super.afterItems(items, view);
+ const at = worldToScreen(view, 0, 0);
+ this.originMark
+ .moveTo(at.x - ORIGIN_ARM, at.y)
+ .lineTo(at.x + ORIGIN_ARM, at.y)
+ .moveTo(at.x, at.y - ORIGIN_ARM)
+ .lineTo(at.x, at.y + ORIGIN_ARM)
+ .stroke({ width: 1, color: Theme.colors.gizmo, alpha: 0.6 });
+ this.originMark.circle(at.x, at.y, 3).stroke({ width: 1, color: Theme.colors.gizmo, alpha: 0.6 });
+ }
+}
diff --git a/src/editor/container/ContainerEditor.ts b/src/editor/container/ContainerEditor.ts
new file mode 100644
index 00000000..87cd88bc
--- /dev/null
+++ b/src/editor/container/ContainerEditor.ts
@@ -0,0 +1,124 @@
+import { Graphics } from 'pixi.js';
+import type { ContainerAsset } from '../../core/model/Asset';
+import type { Vec2 } from '../../core/model/Properties';
+import { findAsset, listContainers } from '../../core/model/Project';
+import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, Theme, VStack } from '../../ui';
+import type { EditorState } from '../state';
+import { ContainerCanvas } from './ContainerCanvas';
+
+/** Room kept for the "N objects" readout, wide enough for a three-digit template. */
+const INFO_WIDTH = 110;
+
+/**
+ * The Container tab. It follows the selection, but keeps showing the last container when the
+ * selection moves elsewhere – clicking a sprite must not empty the tab being worked in.
+ */
+export class ContainerEditor extends VStack {
+ private readonly canvas: ContainerCanvas;
+ private readonly picker: Dropdown;
+ private readonly info: Label;
+ private readonly headerBg = new Graphics();
+
+ constructor(private readonly state: EditorState) {
+ super();
+ const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
+ header.minHeight = Theme.controlHeight + Theme.padding * 2;
+ header.addChildAt(this.headerBg, 0);
+
+ header.add(new Label('Container', { style: 'muted' }));
+ this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() });
+ header.add(this.picker);
+ header.add(new Button('Fit', { onClick: () => this.canvas.fit() }));
+ header.add(new Spacer(), 1);
+ this.info = new Label('', { style: 'small', align: 'right' });
+ // Reserved, not measured: a stack sizes the label once, from the empty text it was built with.
+ this.info.minWidth = INFO_WIDTH;
+ header.add(this.info);
+
+ this.canvas = new ContainerCanvas(state);
+ this.add(header);
+ this.add(this.canvas, 1);
+
+ state.on('selection', () => this.followSelection());
+ state.on('project', () => this.refresh());
+ state.on('load', () => {
+ this.canvas.show('');
+ this.followSelection();
+ });
+ this.followSelection();
+ }
+
+ /** The container currently shown – the check scripts read this. */
+ get currentAssetId(): string {
+ return this.canvas.currentAssetId;
+ }
+
+ /** For the check scripts: the picker's width must not follow the name it happens to show. */
+ get pickerWidth(): number {
+ return this.picker.w;
+ }
+
+ show(assetId: string): void {
+ this.canvas.show(assetId);
+ this.refresh();
+ }
+
+ /** Middle of the view – where the Assets panel drops what is placed into this template. */
+ worldCenter(): Vec2 {
+ return this.canvas.worldCenter();
+ }
+
+ /** Where a template object is drawn, in global screen coordinates – for the check scripts. */
+ objectScreen(objectId: string): { x: number; y: number } | null {
+ return this.canvas.objectScreen(objectId);
+ }
+
+ /** The drawing area without the header, so a check can ask whether a point is on screen. */
+ canvasRect(): { x: number; y: number; w: number; h: number } {
+ const p = this.canvas.getGlobalPosition();
+ return { x: p.x, y: p.y, w: this.canvas.w, h: this.canvas.h };
+ }
+
+ private followSelection(): void {
+ const id = this.state.selectedContainerId;
+ // Null merely means "the selection is about something else"; the tab keeps its subject.
+ if (id) this.canvas.show(id);
+ this.refresh();
+ }
+
+ private asset(): ContainerAsset | null {
+ const asset = findAsset(this.state.project, this.canvas.currentAssetId);
+ return asset && asset.type === 'container' ? asset : null;
+ }
+
+ private containers(): ContainerAsset[] {
+ return listContainers(this.state.project).sort((a, b) => a.name.localeCompare(b.name));
+ }
+
+ private openPicker(): void {
+ const current = this.canvas.currentAssetId;
+ const items: MenuItem[] = this.containers().map((asset) => ({
+ label: asset.id === current ? `● ${asset.name}` : asset.name,
+ onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }),
+ }));
+ if (items.length === 0) items.push({ label: 'No containers yet', onSelect: () => undefined });
+ new Menu(items, { searchable: true }).openBelow(this.picker);
+ }
+
+ private refresh(): void {
+ // A deleted container leaves the canvas empty; fall back to whatever container is left.
+ if (!this.asset()) this.canvas.show(this.containers()[0]?.id ?? '');
+ const asset = this.asset();
+ this.picker.setText(asset ? asset.name : 'No container');
+ const count = asset?.objects.length ?? 0;
+ this.info.setText(asset ? `${count} ${count === 1 ? 'object' : 'objects'}` : '');
+ }
+
+ override redraw(): void {
+ super.redraw();
+ this.headerBg.clear();
+ const h = Theme.controlHeight + Theme.padding * 2;
+ this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader);
+ this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border);
+ }
+}
diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts
index 033a01cc..d6204bde 100644
--- a/src/editor/debugHook.ts
+++ b/src/editor/debugHook.ts
@@ -8,7 +8,7 @@ import type { EditorState } from './state';
* visible text and to read editor state.
*/
export function installDebugHook(app: Application, state: EditorState, editor: Editor): void {
- const { viewport, gameView, graphEditor, textureEditor, hierarchy } = editor;
+ const { viewport, gameView, graphEditor, textureEditor, containerEditor, hierarchy } = editor;
const centreOf = (widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }) => {
const p = widget.getGlobalPosition();
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
@@ -135,6 +135,14 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
/** Where an image pixel of the shown sheet sits on screen (frames are dragged in image coordinates). */
pointAt: (x: number, y: number) => textureEditor.imageToScreen(x, y),
},
+ container: {
+ current: () => containerEditor.currentAssetId,
+ pickerWidth: () => containerEditor.pickerWidth,
+ center: () => centreOf(containerEditor),
+ rect: () => containerEditor.canvasRect(),
+ /** Where a template object is drawn, so a check can click or sample it. */
+ objectScreen: (id: string) => containerEditor.objectScreen(id),
+ },
world: () => editor.worldSnapshot(),
/** Rows the hierarchy shows – collapsed subtrees are not in here. */
hierarchyOrder: () => hierarchy.rowOrder(),
diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts
index ef2b0d31..042888f4 100644
--- a/src/editor/inspector/Inspector.ts
+++ b/src/editor/inspector/Inspector.ts
@@ -72,8 +72,8 @@ export class Inspector extends Panel {
else if (s.kind === 'asset') buildAssetSection(this.host, s.assetId);
else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId);
else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId);
- else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.sceneId, s.objectIds);
- else buildObjectSection(this.host, s.sceneId, s.objectId);
+ else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.holderId, s.objectIds);
+ else buildObjectSection(this.host, s.holderId, s.objectId);
this.scroll.setContent(this.host.body);
}
@@ -83,7 +83,7 @@ export class Inspector extends Panel {
if (s.kind === 'asset') return assetSignature(this.state.project, s.assetId);
if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId);
if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId);
- return objectSignature(this.state.project, s.sceneId, s.objectIds, s.objectId);
+ return objectSignature(this.state.project, s.holderId, s.objectIds, s.objectId);
}
/** Pushes current model values into the widgets (after undo, drag in viewport, …). */
@@ -101,7 +101,7 @@ export class Inspector extends Panel {
? refreshNodeSection(this.host, s.assetId, s.nodeId)
: s.kind === 'frame'
? refreshFrameSection(this.host, s.assetId, s.frameId)
- : s.objectIds.length > 1 || refreshObjectSection(this.host, s.sceneId, s.objectId);
+ : s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
if (!ok) this.rebuild();
}
}
diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts
index 661eaa9e..63a40acd 100644
--- a/src/editor/inspector/objectSection.ts
+++ b/src/editor/inspector/objectSection.ts
@@ -14,11 +14,11 @@ import {
SetOverrideCommand,
SetParentCommand,
} from '../../core/commands/sceneObjectCommands';
-import type { Asset, SceneAsset } from '../../core/model/Asset';
+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 { Axis, PropertyBag, PropertyValue } from '../../core/model/Properties';
-import { findAsset, findScene, type Project } from '../../core/model/Project';
+import { findAsset, findObjectHolder, type ObjectHolder, type Project } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject';
import {
COMMON_PROPS,
@@ -46,13 +46,13 @@ const offsetKey = (axis: 'x' | 'y') => `editorOffset${axis.toUpperCase()}`;
/** Structure-defining facts of an object selection: parent, template link and schema. */
export function objectSignature(
project: Project,
- sceneId: string,
+ holderId: string,
objectIds: readonly string[],
objectId: string,
): string {
if (objectIds.length > 1) return `multi|${objectIds.join(',')}`;
- const scene = findScene(project, sceneId);
- const object = scene && findObject(scene, objectId);
+ const holder = findObjectHolder(project, holderId);
+ const object = holder && findObject(holder, objectId);
const asset = object && findAsset(project, object.assetId);
if (!object || !asset) return 'missing';
return (
@@ -64,44 +64,44 @@ export function objectSignature(
}
/** Several objects: what they are, and the actions that apply to all of them at once. */
-export function buildObjectsSection(host: InspectorHost, sceneId: string, objectIds: string[]): void {
- const scene = findScene(host.state.project, sceneId);
- if (!scene) return;
+export function buildObjectsSection(host: InspectorHost, holderId: string, objectIds: string[]): void {
+ const holder = findObjectHolder(host.state.project, holderId);
+ if (!holder) return;
addTitle(host, `${objectIds.length} OBJECTS SELECTED`);
for (const id of objectIds) {
- const object = findObject(scene, id);
+ const object = findObject(holder, id);
const asset = object && findAsset(host.state.project, object.assetId);
if (object && asset) addNote(host, `${object.name || asset.name} · ${asset.type}`);
}
addGap(host);
addNote(host, 'Ctrl-click adds or removes objects.', 'small');
- addNote(host, 'Dragging one in the scene moves all of them.', 'small');
+ addNote(host, 'Dragging one in the viewport moves all of them.', 'small');
addGap(host);
host.body.add(
- new Button('Create container', { onClick: () => createContainer(host.state, sceneId, objectIds) }),
+ new Button('Create container', { onClick: () => createContainer(host.state, holderId, objectIds) }),
);
addNote(host, 'Groups the selection into a reusable container', 'small');
addNote(host, 'asset placed at its centre (Ctrl+G).', 'small');
addGap(host);
host.body.add(
- new Button('Remove from scene', {
+ new Button('Remove', {
variant: 'danger',
- onClick: () => removeObjects(host.state, sceneId, objectIds),
+ onClick: () => removeObjects(host.state, holderId, objectIds),
}),
);
}
/** One object: name, asset, parent, container link, the switch, position, editor offset, properties, collider button. */
-export function buildObjectSection(host: InspectorHost, sceneId: string, objectId: string): void {
- const scene = findScene(host.state.project, sceneId);
- const object = scene && findObject(scene, objectId);
+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 (!scene || !object || !asset) return;
+ if (!holder || !object || !asset) return;
addTitle(host, `${asset.type.toUpperCase()} OBJECT`);
host.nameField = new TextField(object.name, {
placeholder: asset.name,
- onChange: (name) => host.state.execute(new RenameSceneObjectCommand(sceneId, objectId, name.trim())),
+ onChange: (name) => host.state.execute(new RenameSceneObjectCommand(holderId, objectId, name.trim())),
});
const nameRow = labelledRow('Name');
nameRow.row.add(host.nameField, 1);
@@ -117,10 +117,10 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
);
host.body.add(assetRow.row);
- host.body.add(parentRow(host, scene, object));
+ host.body.add(parentRow(host, holder, object));
// Part of a placed container: values compare against the template, not just the asset.
- const link = templateLinkOf(host.state.project, scene, object);
+ const link = templateLinkOf(host.state.project, holder, object);
if (link) {
const linkRow = labelledRow('Part of');
linkRow.row.add(
@@ -135,25 +135,25 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
// The master switch, right where it is looked for: above everything type-specific.
for (const def of COMMON_PROPS)
- propertyRow(host, sceneId, objectId, asset, def, resolveSceneObjectProps(asset, object));
+ propertyRow(host, holderId, objectId, asset, def, resolveSceneObjectProps(asset, object));
addTitle(host, object.parentId ? 'POSITION (LOCAL TO PARENT)' : 'POSITION');
for (const axis of POSITION_AXES) {
- const moved = () => positionMoved(host.state, sceneId, objectId, axis);
- componentRow(host, sceneId, objectId, object.position[axis], {
+ const moved = () => positionMoved(host.state, holderId, objectId, axis);
+ componentRow(host, holderId, objectId, object.position[axis], {
key: axis,
label: axis.toUpperCase(),
axis,
read: (o) => o.position,
write: (o, v) => (o.position = v),
- command: (v) => new MoveSceneObjectCommand(sceneId, objectId, v),
+ command: (v) => new MoveSceneObjectCommand(holderId, objectId, v),
reset: () =>
moved()
? [
{
label: 'Reset to container',
onSelect: () =>
- host.state.execute(new RevertPositionToTemplateCommand(sceneId, objectId, axis)),
+ host.state.execute(new RevertPositionToTemplateCommand(holderId, objectId, axis)),
},
]
: [],
@@ -163,33 +163,33 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
{
label: 'Apply to container',
onSelect: () =>
- host.state.execute(new ApplyPositionToContainerCommand(sceneId, objectId, axis)),
+ host.state.execute(new ApplyPositionToContainerCommand(holderId, objectId, axis)),
},
]
: [],
});
}
- // Where the object is *drawn* in the scene view, nothing else – for pulling stacked objects apart.
+ // Where the object is *drawn* in the viewport, nothing else – for pulling stacked objects apart.
addTitle(host, 'EDITOR OFFSET (SCENE VIEW ONLY)');
for (const axis of OFFSET_AXES) {
- componentRow(host, sceneId, objectId, object.editorOffset[axis], {
+ componentRow(host, holderId, objectId, object.editorOffset[axis], {
key: offsetKey(axis),
label: `Offset ${axis.toUpperCase()}`,
axis,
read: (o) => o.editorOffset,
write: (o, v) => (o.editorOffset = v),
- command: (v) => new SetEditorOffsetCommand(sceneId, objectId, v),
+ command: (v) => new SetEditorOffsetCommand(holderId, objectId, v),
// No apply: the offset never leaves the placed object.
reset: () => {
- const current = currentObject(host.state, sceneId, objectId);
+ const current = currentObject(host.state, holderId, objectId);
if (!current || current.editorOffset[axis] === 0) return [];
return [
{
label: 'Reset to 0',
onSelect: () =>
host.state.execute(
- new SetEditorOffsetCommand(sceneId, objectId, { ...current.editorOffset, [axis]: 0 }),
+ new SetEditorOffsetCommand(holderId, objectId, { ...current.editorOffset, [axis]: 0 }),
),
},
];
@@ -204,7 +204,7 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
if (own.length > 0 || asset.type === 'behavior')
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, sceneId, objectId, asset, def, values);
+ for (const def of own) propertyRow(host, holderId, objectId, asset, def, values);
markObjectRows(host, object, asset, link);
if (asset.type === 'container') {
@@ -219,24 +219,24 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
host.body.add(
new Button('+ Add collider', {
variant: 'ghost',
- onClick: () => addCollider(host.state, sceneId, objectId),
+ onClick: () => addCollider(host.state, holderId, objectId),
}),
);
}
}
/** Pushes the current model values into the rows. False when the object is gone (rebuild). */
-export function refreshObjectSection(host: InspectorHost, sceneId: string, objectId: string): boolean {
- const scene = findScene(host.state.project, sceneId);
- const object = scene && findObject(scene, objectId);
+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 (!scene || !object || !asset) return false;
+ if (!holder || !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, scene, object));
+ markObjectRows(host, object, asset, templateLinkOf(host.state.project, holder, object));
return true;
}
@@ -258,7 +258,7 @@ interface ComponentSpec {
*/
function componentRow(
host: InspectorHost,
- sceneId: string,
+ holderId: string,
objectId: string,
value: number,
spec: ComponentSpec,
@@ -269,7 +269,7 @@ function componentRow(
{ key: spec.key, label: spec.label, kind: 'number', default: 0, step: 1 },
value,
previewingHandlers(host, {
- target: () => currentObject(host.state, sceneId, objectId),
+ target: () => currentObject(host.state, holderId, objectId),
// Copied: the snapshot outlives the scrub, the vector in the model does not.
read: (object) => ({ ...spec.read(object) }),
write: spec.write,
@@ -283,7 +283,7 @@ function componentRow(
}
/**
- * One editable property of a scene object: writes an override, offers ↺ (back to the asset
+ * 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
@@ -293,31 +293,31 @@ function componentRow(
*/
function propertyRow(
host: InspectorHost,
- sceneId: string,
+ holderId: string,
objectId: string,
owner: Asset,
def: PropertyDef,
values: Record,
): FieldRow {
- const bases = () => deviations(host.state, sceneId, objectId, def.key);
+ const bases = () => deviations(host.state, holderId, objectId, def.key);
const row = createFieldRow(
host,
def,
values[def.key] ?? def.default,
previewingHandlers(host, {
- target: () => currentObject(host.state, sceneId, objectId),
+ 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],
- commit: (value) => new SetOverrideCommand(sceneId, objectId, def.key, value),
+ commit: (value) => new SetOverrideCommand(holderId, objectId, def.key, value),
}),
{
extra: aspectActions(
host,
owner,
def,
- () => currentProps(host.state, sceneId, objectId),
- (value) => host.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value)),
+ () => currentProps(host.state, holderId, objectId),
+ (value) => host.state.execute(new SetOverrideCommand(holderId, objectId, def.key, value)),
),
reset: () => {
const { template, asset } = bases();
@@ -327,7 +327,7 @@ function propertyRow(
{
label: 'Reset to container',
onSelect: () =>
- host.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)),
+ host.state.execute(new RevertOverrideToTemplateCommand(holderId, objectId, def.key)),
},
]
: []),
@@ -335,7 +335,7 @@ function propertyRow(
? [
{
label: 'Reset to asset',
- onSelect: () => host.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)),
+ onSelect: () => host.state.execute(new ClearOverrideCommand(holderId, objectId, def.key)),
},
]
: []),
@@ -349,7 +349,7 @@ function propertyRow(
{
label: 'Apply to container',
onSelect: () =>
- host.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)),
+ host.state.execute(new ApplyOverrideToContainerCommand(holderId, objectId, def.key)),
},
]
: []),
@@ -358,7 +358,7 @@ function propertyRow(
{
label: 'Apply to asset',
onSelect: () =>
- host.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)),
+ host.state.execute(new ApplyOverrideToAssetCommand(holderId, objectId, def.key)),
},
]
: []),
@@ -372,27 +372,27 @@ function propertyRow(
}
/**
- * "Parent: ▾" – picks another object of the same scene, or none. The object's own
+ * "Parent: ▾" – picks another object of the same scene or container, or none. The object's own
* subtree is excluded (that would be a cycle). For behaviors the parent is the owner.
*/
-function parentRow(host: InspectorHost, scene: SceneAsset, object: SceneObject) {
+function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObject) {
const { row } = labelledRow('Parent');
const nameOf = (o: SceneObject) => o.name || findAsset(host.state.project, o.assetId)?.name || '?';
- const parent = findObject(scene, object.parentId);
+ const parent = findObject(holder, object.parentId);
const button = new Dropdown(parent ? nameOf(parent) : 'None');
button.onClick = () => {
const items: MenuItem[] = [
- { label: 'None', onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, '')) },
+ { label: 'None', onSelect: () => host.state.execute(new SetParentCommand(holder.id, object.id, '')) },
];
- for (const { object: candidate, depth } of treeOrder(scene)) {
- if (isSelfOrAncestor(scene, object.id, candidate.id)) continue;
+ for (const { object: candidate, depth } of treeOrder(holder)) {
+ if (isSelfOrAncestor(holder, object.id, candidate.id)) continue;
const name = `${' '.repeat(depth)}${nameOf(candidate)}`;
items.push({
label: candidate.id === object.parentId ? `● ${name}` : name,
- onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, candidate.id)),
+ onSelect: () => host.state.execute(new SetParentCommand(holder.id, object.id, candidate.id)),
});
}
- // Searchable: this lists every object of the scene, which grows with the project.
+ // Searchable: this lists every object of the scene or container, which grows with the project.
new Menu(items, { searchable: true }).openBelow(button);
};
row.add(button, 1);
@@ -431,15 +431,15 @@ function markObjectRows(
*/
function deviations(
state: InspectorHost['state'],
- sceneId: string,
+ holderId: string,
objectId: string,
key: string,
): { template: boolean; asset: boolean } {
- const scene = findScene(state.project, sceneId);
- const object = scene && findObject(scene, objectId);
+ const holder = findObjectHolder(state.project, holderId);
+ const object = holder && findObject(holder, objectId);
const asset = object && findAsset(state.project, object.assetId);
- if (!scene || !object || !asset) return { template: false, asset: false };
- const link = templateLinkOf(state.project, scene, object);
+ if (!holder || !object || !asset) return { template: false, asset: false };
+ const link = templateLinkOf(state.project, holder, object);
return {
template: Boolean(link) && deviatesFromTemplate(object, link!.template, key),
asset: deviatesFromAsset(object, asset, key),
@@ -449,28 +449,28 @@ function deviations(
/** Whether the object sits somewhere else on `axis` than its container template says. */
function positionMoved(
state: InspectorHost['state'],
- sceneId: string,
+ holderId: string,
objectId: string,
axis: Axis,
): boolean {
- const scene = findScene(state.project, sceneId);
- const object = scene && findObject(scene, objectId);
- const link = scene && object && templateLinkOf(state.project, scene, object);
+ 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];
}
function currentObject(
state: InspectorHost['state'],
- sceneId: string,
+ holderId: string,
objectId: string,
): SceneObject | undefined {
- const scene = findScene(state.project, sceneId);
- return scene && findObject(scene, objectId);
+ const holder = findObjectHolder(state.project, holderId);
+ return holder && findObject(holder, objectId);
}
/** The values the object shows right now, asset defaults and overrides merged. */
-function currentProps(state: InspectorHost['state'], sceneId: string, objectId: string): PropertyBag | null {
- const object = currentObject(state, sceneId, objectId);
+function currentProps(state: InspectorHost['state'], holderId: string, objectId: string): PropertyBag | null {
+ const object = currentObject(state, holderId, objectId);
const asset = object && findAsset(state.project, object.assetId);
return object && asset ? resolveSceneObjectProps(asset, object) : null;
}
diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts
index f5e4e46b..5153dbfd 100644
--- a/src/editor/panels/AssetPanel.ts
+++ b/src/editor/panels/AssetPanel.ts
@@ -3,13 +3,22 @@ import { nextAssetName } from '../../core/commands/naming';
import { PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType, hasColorSwatch } from '../../core/model/AssetTypes';
import type { Vec2 } from '../../core/model/Properties';
-import { findAsset } from '../../core/model/Project';
+import { findAsset, type ObjectHolder } from '../../core/model/Project';
import type { TextureStore } from '../../core/store/TextureStore';
import { Button, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui';
import { confirmDeleteAsset } from '../actions';
import { type EditorState, objectSelection } from '../state';
import { pickAndImport } from '../textureImport';
+/**
+ * Where a placed asset lands. The tab in front decides, so it appears where the user is
+ * looking: the Scene tab places into the active scene, the Container tab into the template.
+ */
+export interface PlacementTarget {
+ holder: ObjectHolder;
+ at: Vec2;
+}
+
/** Lists all assets of the project; creates, places and deletes them. */
export class AssetPanel extends Panel {
private readonly list = new ListView();
@@ -17,7 +26,7 @@ export class AssetPanel extends Panel {
constructor(
private readonly state: EditorState,
- private readonly placementPoint: () => Vec2,
+ private readonly target: () => PlacementTarget | undefined,
/** Where imported images go. Without it the Texture entry has nothing to upload to. */
private readonly textures: TextureStore | null = null,
) {
@@ -92,18 +101,19 @@ export class AssetPanel extends Panel {
}
private place(assetId: string): void {
- const scene = this.state.activeScene;
- if (!scene) {
- this.state.setNotice('Create a scene first, then place assets into it.');
+ const target = this.target();
+ if (!target) {
+ this.state.setNotice('Nothing to place into – create a scene, or open a container first.');
return;
}
- const command = new PlaceAssetCommand(scene.id, assetId, this.placementPoint());
- if (this.state.execute(command)) this.state.select(objectSelection(scene.id, [command.objectId]));
+ const { holder, at } = target;
+ const command = new PlaceAssetCommand(holder.id, assetId, at);
+ if (this.state.execute(command)) this.state.select(objectSelection(holder.id, [command.objectId]));
}
- private openContextMenu(id: string, x: number, y: number): void {
+ private openContextMenu(id: string, x: number, y: number): Menu | undefined {
const asset = findAsset(this.state.project, id);
- if (!asset) return;
+ if (!asset) return undefined;
const items: MenuItem[] = [];
if (asset.type === 'scene') {
items.push({
@@ -112,13 +122,16 @@ export class AssetPanel extends Panel {
disabled: this.state.project.activeSceneId === id,
});
} else {
+ const target = this.target();
items.push({
- label: 'Place in scene',
+ label: target?.holder.type === 'container' ? 'Place in container' : 'Place in scene',
onSelect: () => this.place(id),
- disabled: !this.state.activeScene,
+ disabled: !target,
});
}
items.push({ label: 'Delete…', danger: true, onSelect: () => confirmDeleteAsset(this.state, id) });
- new Menu(items).open(x, y);
+ const menu = new Menu(items);
+ menu.open(x, y);
+ return menu;
}
}
diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts
index 2a4ac0b4..b15fddc7 100644
--- a/src/editor/panels/HierarchyPanel.ts
+++ b/src/editor/panels/HierarchyPanel.ts
@@ -1,14 +1,14 @@
import { SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { hasColorSwatch } from '../../core/model/AssetTypes';
import { ancestorIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy';
-import { findAsset } from '../../core/model/Project';
+import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { resolveSceneObjectProps } from '../../core/properties/resolve';
import { ListView, Menu, Panel, ScrollContainer } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions';
import { type EditorState, objectSelection } from '../state';
/**
- * Objects placed in the active scene as a tree (children indented below their parent).
+ * Objects of the scene or container being edited, as a tree (children indented below their parent).
* Click selects, Ctrl/Shift-click adds to or removes from the selection. Drag a row onto
* another to parent it there; drop it on the empty space below the rows to move it to the
* top level. The object keeps its world position either way.
@@ -22,24 +22,31 @@ export class HierarchyPanel extends Panel {
private readonly scroll = new ScrollContainer();
/** Ids of the objects whose children are shown. Anything missing is collapsed. */
private readonly expanded = new Set();
+ /**
+ * Which objects are listed. A function, not an id, so a container renamed or swapped under the
+ * panel needs no notification; the Container tab points this at the template it edits.
+ */
+ private source: () => ObjectHolder | undefined = () => this.state.activeScene;
+ /** Id of the holder the rows were built from, to notice when the source starts naming another. */
+ private shownHolderId = '';
constructor(private readonly state: EditorState) {
super({ title: 'Scene objects', padding: 0, gap: 0 });
this.minWidth = 160;
this.list.emptyText = 'Place assets here';
this.list.onSelect = (id, modifiers) => {
- const scene = state.activeScene;
- if (!scene) return;
- if (id && modifiers && (modifiers.ctrl || modifiers.shift)) state.toggleObject(scene.id, id);
- else state.select(id ? objectSelection(scene.id, [id]) : null);
+ const holder = this.holder();
+ if (!holder) return;
+ if (id && modifiers && (modifiers.ctrl || modifiers.shift)) state.toggleObject(holder.id, id);
+ else state.select(id ? objectSelection(holder.id, [id]) : null);
};
this.list.onDrop = (draggedId, targetId) => {
- const scene = state.activeScene;
- const object = scene && findObject(scene, draggedId);
- if (!scene || !object || object.parentId === (targetId ?? '')) return;
+ const holder = this.holder();
+ const object = holder && findObject(holder, draggedId);
+ if (!holder || !object || object.parentId === (targetId ?? '')) return;
// Unfold the new parent, otherwise the dragged row would seem to vanish on drop.
if (targetId) this.expanded.add(targetId);
- state.execute(new SetParentCommand(scene.id, draggedId, targetId ?? ''));
+ state.execute(new SetParentCommand(holder.id, draggedId, targetId ?? ''));
};
this.list.onToggle = (id, expand) => this.setExpanded(id, expand);
this.list.onActivate = (id) => this.setExpanded(id, !this.expanded.has(id));
@@ -53,14 +60,33 @@ export class HierarchyPanel extends Panel {
this.expanded.clear();
this.refresh();
});
- state.on('selection', () => (this.revealSelection() ? this.refresh() : this.syncSelection()));
+ state.on('selection', () => {
+ // The Container tab's picker only changes the selection, which swaps the holder under this
+ // panel; without the id check the title and rows would keep naming the previous one.
+ const swapped = (this.holder()?.id ?? '') !== this.shownHolderId;
+ if (this.revealSelection() || swapped) this.refresh();
+ else this.syncSelection();
+ });
this.refresh();
}
+ /**
+ * Lists another holder's objects – the Container tab hands in its template, the Scene tab the
+ * active scene again.
+ */
+ showObjectsOf(source: () => ObjectHolder | undefined): void {
+ this.source = source;
+ this.refresh();
+ }
+
+ private holder(): ObjectHolder | undefined {
+ return this.source();
+ }
+
/** Ids of the rows currently shown – subtrees of collapsed objects are not among them (test hook). */
rowOrder(): string[] {
- const scene = this.state.activeScene;
- return scene ? visibleTreeOrder(scene, this.expanded).map((e) => e.object.id) : [];
+ const holder = this.holder();
+ return holder ? visibleTreeOrder(holder, this.expanded).map((e) => e.object.id) : [];
}
/** Global centre of a row's chevron, so tests can click the real hit area (test hook). */
@@ -69,8 +95,7 @@ export class HierarchyPanel extends Panel {
}
expandAll(): void {
- const scene = this.state.activeScene;
- for (const object of scene?.objects ?? []) this.expanded.add(object.id);
+ for (const object of this.holder()?.objects ?? []) this.expanded.add(object.id);
this.refresh();
}
@@ -81,16 +106,17 @@ export class HierarchyPanel extends Panel {
this.refresh();
}
- private openContextMenu(id: string, x: number, y: number): void {
+ private openContextMenu(id: string, x: number, y: number): Menu | undefined {
const { state } = this;
- const scene = state.activeScene;
- if (!scene) return;
- const object = findObject(scene, id);
+ const holder = this.holder();
+ if (!holder) return undefined;
+ const inScene = holder.type === 'scene';
+ const object = findObject(holder, id);
const type = object && findAsset(state.project, object.assetId)?.type;
// 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;
- new Menu([
+ const menu = new Menu([
{
label: 'Select asset',
onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }),
@@ -98,25 +124,29 @@ export class HierarchyPanel extends Panel {
{
label: 'Add collider',
disabled: !type || type === 'collider' || many,
- onSelect: () => addCollider(state, scene.id, id),
+ onSelect: () => addCollider(state, holder.id, id),
},
{
label: many ? `Create container from ${selected.length} objects` : 'Create container',
- onSelect: () => createContainer(state, scene.id, selected),
+ onSelect: () => createContainer(state, holder.id, selected),
},
{
label: 'Unparent (move to top level)',
disabled: !object?.parentId || many,
- onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')),
+ onSelect: () => state.execute(new SetParentCommand(holder.id, id, '')),
},
{ label: 'Expand all', onSelect: () => this.expandAll() },
{ label: 'Collapse all', onSelect: () => this.collapseAll() },
{
- label: many ? `Remove ${selected.length} objects from scene` : 'Remove from scene',
+ label: many
+ ? `Remove ${selected.length} objects from ${inScene ? 'scene' : 'container'}`
+ : `Remove from ${inScene ? 'scene' : 'container'}`,
danger: true,
- onSelect: () => removeObjects(state, scene.id, selected),
+ onSelect: () => removeObjects(state, holder.id, selected),
},
- ]).open(x, y);
+ ]);
+ menu.open(x, y);
+ return menu;
}
private setExpanded(id: string, expand: boolean): void {
@@ -127,11 +157,11 @@ export class HierarchyPanel extends Panel {
/** Unfolds the ancestors of every selected object. Returns whether that changed anything. */
private revealSelection(): boolean {
- const scene = this.state.activeScene;
- if (!scene) return false;
+ const holder = this.holder();
+ if (!holder) return false;
let changed = false;
for (const id of this.state.selectedObjectIds) {
- for (const ancestorId of ancestorIdsOf(scene, id)) {
+ for (const ancestorId of ancestorIdsOf(holder, id)) {
if (this.expanded.has(ancestorId)) continue;
this.expanded.add(ancestorId);
changed = true;
@@ -141,10 +171,12 @@ export class HierarchyPanel extends Panel {
}
private refresh(): void {
- const scene = this.state.activeScene;
- this.setTitle(scene ? `Scene: ${scene.name}` : 'Scene objects');
+ const holder = this.holder();
+ this.shownHolderId = holder?.id ?? '';
+ const kind = holder?.type === 'container' ? 'Container' : 'Scene';
+ this.setTitle(holder ? `${kind}: ${holder.name}` : 'Scene objects');
this.list.setItems(
- (scene ? visibleTreeOrder(scene, this.expanded) : []).map(({ object, depth, hasChildren }) => {
+ (holder ? visibleTreeOrder(holder, this.expanded) : []).map(({ object, depth, hasChildren }) => {
const asset = findAsset(this.state.project, object.assetId);
const props = asset ? resolveSceneObjectProps(asset, object) : {};
return {
diff --git a/src/editor/shortcuts.ts b/src/editor/shortcuts.ts
index f3dc6ddb..dce1d4f3 100644
--- a/src/editor/shortcuts.ts
+++ b/src/editor/shortcuts.ts
@@ -32,12 +32,12 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions):
}
if (mod && key === 'g') {
const s = state.selection;
- if (s?.kind === 'object') createContainer(state, s.sceneId, s.objectIds);
+ if (s?.kind === 'object') createContainer(state, s.holderId, s.objectIds);
return true;
}
if (event.key === 'Delete' || event.key === 'Backspace') {
const s = state.selection;
- if (s?.kind === 'object') removeObjects(state, s.sceneId, s.objectIds);
+ if (s?.kind === 'object') removeObjects(state, s.holderId, s.objectIds);
else if (s?.kind === 'node') state.execute(new RemoveNodeCommand(s.assetId, s.nodeId));
else if (s?.kind === 'frame') state.execute(new RemoveFrameCommand(s.assetId, s.frameId));
else if (s?.kind === 'asset') confirmDeleteAsset(state, s.assetId);
diff --git a/src/editor/state.ts b/src/editor/state.ts
index b716cd5c..47486daa 100644
--- a/src/editor/state.ts
+++ b/src/editor/state.ts
@@ -1,17 +1,24 @@
import { type Command, CommandError } from '../core/commands/Command';
import { CommandStack } from '../core/commands/CommandStack';
-import type { SceneAsset } from '../core/model/Asset';
+import type { Asset, SceneAsset } from '../core/model/Asset';
import { findObject } from '../core/model/hierarchy';
-import { createEmptyProject, findAsset, findScene, type Project } from '../core/model/Project';
+import {
+ createEmptyProject,
+ findAsset,
+ findObjectHolder,
+ findScene,
+ type Project,
+} from '../core/model/Project';
import { deserializeProject, serializeProject } from '../core/store/serialize';
export type Selection =
| { kind: 'asset'; assetId: string }
/**
- * One or more objects of a scene. `objectId` is the primary one (the last picked – the
- * inspector shows it); `objectIds` holds the whole selection, primary included.
+ * One or more objects of a scene or of a container's template (`holderId` names which).
+ * `objectId` is the primary one (the last picked – the inspector shows it); `objectIds`
+ * holds the whole selection, primary included.
*/
- | { kind: 'object'; sceneId: string; objectId: string; objectIds: string[] }
+ | { kind: 'object'; holderId: string; objectId: string; objectIds: string[] }
/** A node inside a behavior's graph. */
| { kind: 'node'; assetId: string; nodeId: string }
/** A frame rectangle inside a texture asset (the Texture tab). */
@@ -19,11 +26,11 @@ export type Selection =
| null;
/** Builds an object selection; the last id is the primary one unless given explicitly. */
-export function objectSelection(sceneId: string, objectIds: readonly string[], primary?: string): Selection {
+export function objectSelection(holderId: string, objectIds: readonly string[], primary?: string): Selection {
const ids = [...new Set(objectIds)];
if (ids.length === 0) return null;
const objectId = primary && ids.includes(primary) ? primary : ids[ids.length - 1]!;
- return { kind: 'object', sceneId, objectId, objectIds: ids };
+ return { kind: 'object', holderId, objectId, objectIds: ids };
}
export type EditorMode = 'edit' | 'play';
@@ -194,23 +201,23 @@ export class EditorState {
// --- selection -----------------------------------------------------------------------------
select(selection: Selection): void {
- // Test hooks pass the short form `{ kind: 'object', sceneId, objectId }` without `objectIds`.
+ // Test hooks pass the short form `{ kind: 'object', holderId, objectId }` without `objectIds`.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (selection?.kind === 'object' && !selection.objectIds)
- selection = objectSelection(selection.sceneId, [selection.objectId]);
+ selection = objectSelection(selection.holderId, [selection.objectId]);
if (sameSelection(this.selection, selection)) return;
this.selection = selection;
this.emit('selection');
}
/** Ctrl/Shift-click: adds the object to the selection or removes it again. */
- toggleObject(sceneId: string, objectId: string): void {
+ toggleObject(holderId: string, objectId: string): void {
const s = this.selection;
- const current = s?.kind === 'object' && s.sceneId === sceneId ? s.objectIds : [];
+ const current = s?.kind === 'object' && s.holderId === holderId ? s.objectIds : [];
const next = current.includes(objectId)
? current.filter((id) => id !== objectId)
: [...current, objectId];
- this.select(objectSelection(sceneId, next, current.includes(objectId) ? undefined : objectId));
+ this.select(objectSelection(holderId, next, current.includes(objectId) ? undefined : objectId));
}
/** Ids of the selected scene objects (empty unless objects are selected). */
@@ -224,9 +231,9 @@ export class EditorState {
if (!s) return;
if (s.kind === 'asset' && !findAsset(this.project, s.assetId)) this.select(null);
if (s.kind === 'object') {
- const scene = findScene(this.project, s.sceneId);
- const alive = scene ? s.objectIds.filter((id) => findObject(scene, id)) : [];
- if (alive.length !== s.objectIds.length) this.select(objectSelection(s.sceneId, alive, s.objectId));
+ const holder = findObjectHolder(this.project, s.holderId);
+ const alive = holder ? s.objectIds.filter((id) => findObject(holder, id)) : [];
+ if (alive.length !== s.objectIds.length) this.select(objectSelection(s.holderId, alive, s.objectId));
}
if (s.kind === 'node') {
const asset = findAsset(this.project, s.assetId);
@@ -259,12 +266,31 @@ export class EditorState {
if (!s) return null;
if (s.kind === 'node') return s.assetId;
if (s.kind === 'frame') return null;
- const scene = s.kind === 'object' ? findScene(this.project, s.sceneId) : undefined;
- const assetId = s.kind === 'asset' ? s.assetId : scene && findObject(scene, s.objectId)?.assetId;
- const asset = assetId ? findAsset(this.project, assetId) : undefined;
+ const asset = this.assetBehind(s);
return asset && asset.type === 'behavior' ? asset.id : null;
}
+ /**
+ * The container the current selection refers to: the asset itself, a placed container, or the
+ * template being edited – selecting one of its objects keeps the Container tab on it.
+ */
+ get selectedContainerId(): string | null {
+ const s = this.selection;
+ if (!s) return null;
+ if (s.kind === 'object' && findAsset(this.project, s.holderId)?.type === 'container') return s.holderId;
+ const asset = this.assetBehind(s);
+ return asset && asset.type === 'container' ? asset.id : null;
+ }
+
+ /** The asset a selection stands for: itself, or the one a selected object was placed from. */
+ private assetBehind(s: NonNullable): Asset | undefined {
+ 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;
+ return assetId ? findAsset(this.project, assetId) : undefined;
+ }
+
// --- status --------------------------------------------------------------------------------
setSaveStatus(status: SaveStatus): void {
@@ -286,7 +312,7 @@ export function sameSelection(a: Selection, b: Selection): boolean {
if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;
if (a.kind === 'object' && b.kind === 'object') {
return (
- a.sceneId === b.sceneId &&
+ a.holderId === b.holderId &&
a.objectId === b.objectId &&
a.objectIds.length === b.objectIds.length &&
a.objectIds.every((id, i) => id === b.objectIds[i])
diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts
index 972230bc..320ff78f 100644
--- a/src/ui/widgets/ListView.ts
+++ b/src/ui/widgets/ListView.ts
@@ -3,6 +3,7 @@ import { centreY } from '../math';
import { Theme, textStyle } from '../Theme';
import { type Size, Widget } from '../Widget';
import { ListRow } from './ListRow';
+import type { Popup } from './Popup';
export interface ListItem {
id: string;
@@ -46,9 +47,12 @@ export class ListView extends Widget {
private rows: ListRow[] = [];
private selectedId: string | null = null;
private highlighted = new Set();
+ /** Row an open context menu acts on – drawn like a selected row, but only until it closes. */
+ private contextId: string | null = null;
onSelect?: (id: string | null, modifiers?: PickModifiers) => void;
onActivate?: (id: string) => void;
- onContextMenu?: (id: string, globalX: number, globalY: number) => void;
+ /** Returning the opened menu keeps its row marked for as long as it is up. */
+ onContextMenu?: (id: string, globalX: number, globalY: number) => Popup | void;
/** Called when a dragged row is released over another row (`targetId`) or over empty space (null). */
onDrop?: (draggedId: string, targetId: string | null) => void;
/**
@@ -94,6 +98,7 @@ export class ListView extends Widget {
});
this.addChild(this.dropMarker); // keep the marker above the rows
if (this.selectedId && !items.some((i) => i.id === this.selectedId)) this.selectedId = null;
+ if (this.contextId && !items.some((i) => i.id === this.contextId)) this.contextId = null;
this.layout();
this.redraw();
}
@@ -106,6 +111,12 @@ export class ListView extends Widget {
if (!silent) this.onSelect?.(id);
}
+ private setContext(id: string | null): void {
+ if (id === this.contextId) return;
+ this.contextId = id;
+ this.redraw();
+ }
+
/** Rows drawn as selected in addition to the selected one (the owner's multi-selection). */
highlight(ids: Iterable): void {
this.highlighted = new Set(ids);
@@ -153,7 +164,11 @@ export class ListView extends Widget {
override redraw(): void {
for (const row of this.rows) {
- row.setSelected(row.item.id === this.selectedId || this.highlighted.has(row.item.id), this.focused);
+ const id = row.item.id;
+ row.setSelected(
+ id === this.selectedId || id === this.contextId || this.highlighted.has(id),
+ this.focused,
+ );
}
this.emptyLabel.text = this.items.length === 0 ? this.emptyText : '';
this.emptyLabel.position.set(Theme.padding, centreY(this.rowHeight, this.emptyLabel.height));
@@ -169,9 +184,11 @@ export class ListView extends Widget {
if (e.button === 0 && row.hitsChevron(row.toLocal(e.global).x)) {
this.onToggle?.(item.id, !item.expanded);
} else if (e.button === 2) {
- // A right-click inside a multi-selection keeps it (the menu acts on all of it).
- if (!this.highlighted.has(item.id)) this.select(item.id);
- this.onContextMenu?.(item.id, e.global.x, e.global.y);
+ // Marked, not selected: selecting also loads the row into the editor, which would swap the
+ // very tab out from under a menu opened to place something into it.
+ const menu = this.onContextMenu?.(item.id, e.global.x, e.global.y);
+ this.setContext(menu ? item.id : null);
+ if (menu) menu.onClose = () => this.setContext(null);
} else if (modifiers.ctrl || modifiers.shift) {
this.onSelect?.(item.id, modifiers);
} else {
diff --git a/src/viewport/SceneViewport.ts b/src/viewport/SceneViewport.ts
index 4623de40..b48ba345 100644
--- a/src/viewport/SceneViewport.ts
+++ b/src/viewport/SceneViewport.ts
@@ -1,6 +1,7 @@
/**
* The editable view of a scene. It shows `state.project`, which in play mode is the runtime
- * copy, so live positions are visible and stay editable.
+ * copy, so live positions are visible and stay editable. A container's template is the same
+ * work on the same shape of data, so the Container tab subclasses this and swaps `holder()`.
*/
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { CompoundCommand } from '../core/commands/CompoundCommand';
@@ -8,10 +9,11 @@ import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
import { isSymbolType } from '../core/model/AssetTypes';
import { findObject, subtreeRoots } from '../core/model/hierarchy';
import type { Position, Vec2 } from '../core/model/Properties';
+import type { ObjectHolder } from '../core/model/Project';
import { footprintOf } from '../core/properties/bounds';
import { type EditorState, objectSelection } from '../editor/state';
import { clamp, drawGrid, type Size, Theme, textStyle, zoomFactor } from '../ui';
-import { collectSceneItems, type DrawItem } from './items';
+import { collectHolderItems, type DrawItem } from './items';
import { dashedLine } from './symbols';
import { freeView, screenToWorld, type View, worldToScreen } from './view';
import { type Frame, WorldViewport } from './WorldViewport';
@@ -24,9 +26,11 @@ const GRID_STEP = 100;
const MIN_GRID_STEP = 12;
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 8;
+/** Breathing room left around the content by `fit()`, in screen pixels. */
+const FIT_MARGIN = 40;
interface ObjectDrag {
- sceneId: string;
+ holderId: string;
items: { objectId: string; start: Position }[];
pointer: Vec2;
moved: boolean;
@@ -70,11 +74,47 @@ export class SceneViewport extends WorldViewport {
});
}
+ /** What this view shows and edits; the Container tab returns its container asset instead. */
+ protected holder(): ObjectHolder | undefined {
+ return this.state.activeScene;
+ }
+
/** World coordinates at the centre of the view. */
worldCenter(): Vec2 {
return { x: Math.round(this.view.x), y: Math.round(this.view.y) };
}
+ /**
+ * Frames everything the holder contains. The origin is always included: it is where a container
+ * is grabbed when placed, so a template drawn far off to one side would otherwise hide it.
+ */
+ fit(): void {
+ const { items } = this.collectFrame();
+ let minX = 0;
+ let minY = 0;
+ let maxX = 0;
+ let maxY = 0;
+ for (const item of items) {
+ const { w, h } = footprintOf(item.type, item.props);
+ minX = Math.min(minX, item.x - w / 2);
+ maxX = Math.max(maxX, item.x + w / 2);
+ minY = Math.min(minY, item.y - h / 2);
+ maxY = Math.max(maxY, item.y + h / 2);
+ }
+ const width = Math.max(maxX - minX, 1);
+ const height = Math.max(maxY - minY, 1);
+ this.view = {
+ x: (minX + maxX) / 2,
+ y: (minY + maxY) / 2,
+ zoom: clamp(
+ Math.min((this.w - FIT_MARGIN * 2) / width, (this.h - FIT_MARGIN * 2) / height),
+ MIN_ZOOM,
+ MAX_ZOOM,
+ ),
+ };
+ this.redraw();
+ }
+
// --- the view -------------------------------------------------------------------------------
protected override currentView(): View {
@@ -82,7 +122,7 @@ export class SceneViewport extends WorldViewport {
}
protected override collectFrame(): Frame {
- return collectSceneItems(this.state, Theme.colors.viewport);
+ return collectHolderItems(this.state.project, this.holder(), Theme.colors.viewport);
}
protected override get baseColor(): number {
@@ -91,7 +131,7 @@ export class SceneViewport extends WorldViewport {
/** Without a scene there is nothing to frame: the neutral base colour stands alone. */
protected override paintsBackground(): boolean {
- return Boolean(this.state.activeScene);
+ return Boolean(this.holder());
}
protected override get itemsInteractive(): boolean {
@@ -129,11 +169,11 @@ export class SceneViewport extends WorldViewport {
const dy = (local.y - this.dragging.pointer.y) / this.view.zoom;
if (!this.dragging.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
this.dragging.moved = true;
- const scene = this.state.activeScene;
- if (!scene) return;
+ const holder = this.holder();
+ 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(scene, item.objectId);
+ const object = findObject(holder, item.objectId);
if (object)
object.position = {
x: Math.round(item.start.x + dx),
@@ -167,16 +207,16 @@ export class SceneViewport extends WorldViewport {
const drag = this.dragging;
this.dragging = null;
if (!drag || !drag.moved) return;
- const scene = this.state.activeScene;
- if (!scene) return;
+ const holder = this.holder();
+ if (!holder) return;
// 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(scene, item.objectId);
+ const object = findObject(holder, item.objectId);
if (!object) continue;
const next = { ...object.position };
object.position = { ...item.start };
- moves.push(new MoveSceneObjectCommand(drag.sceneId, item.objectId, next));
+ moves.push(new MoveSceneObjectCommand(drag.holderId, item.objectId, next));
}
if (moves.length === 1) this.state.execute(moves[0]!);
else if (moves.length > 1) this.state.execute(new CompoundCommand('Move objects', moves));
@@ -195,30 +235,30 @@ export class SceneViewport extends WorldViewport {
protected override onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
if (e.button !== 0) return;
- const scene = this.state.activeScene;
- const object = scene && findObject(scene, objectId);
- if (!scene || !object) return;
+ const holder = this.holder();
+ const object = holder && findObject(holder, objectId);
+ if (!holder || !object) return;
e.stopPropagation();
if (e.ctrlKey || e.metaKey || e.shiftKey) {
- this.state.toggleObject(scene.id, objectId);
+ this.state.toggleObject(holder.id, objectId);
return;
}
// Clicking a selected object keeps the selection so the whole group can be dragged.
if (!this.state.selectedObjectIds.includes(objectId))
- this.state.select(objectSelection(scene.id, [objectId]));
+ this.state.select(objectSelection(holder.id, [objectId]));
// Only the tops of the selected subtrees move; descendants follow through the hierarchy.
- const items = subtreeRoots(scene, this.state.selectedObjectIds)
- .map((id) => findObject(scene, id))
+ const items = subtreeRoots(holder, this.state.selectedObjectIds)
+ .map((id) => findObject(holder, id))
.filter((o): o is NonNullable => Boolean(o))
.map((o) => ({ objectId: o.id, start: { ...o.position } }));
const local = this.toLocal(e.global);
- this.dragging = { sceneId: scene.id, items, pointer: { x: local.x, y: local.y }, moved: false };
+ this.dragging = { holderId: holder.id, items, pointer: { x: local.x, y: local.y }, moved: false };
}
/** Selects every object whose position – not its bounds – lies inside the band. */
private selectInMarquee(marquee: Marquee): void {
- const scene = this.state.activeScene;
- if (!scene) return;
+ const holder = this.holder();
+ if (!holder) return;
const view = this.currentView();
const a = screenToWorld(view, marquee.start.x, marquee.start.y);
const b = screenToWorld(view, marquee.current.x, marquee.current.y);
@@ -229,7 +269,7 @@ export class SceneViewport extends WorldViewport {
const ids = this.lastItems
.filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY)
.map((i) => i.id);
- this.state.select(objectSelection(scene.id, ids));
+ this.state.select(objectSelection(holder.id, ids));
}
// --- decorations ----------------------------------------------------------------------------
diff --git a/src/viewport/items.ts b/src/viewport/items.ts
index 43a7a8fb..7d22315b 100644
--- a/src/viewport/items.ts
+++ b/src/viewport/items.ts
@@ -2,11 +2,10 @@ import { isHandleEnabled, worldPositionOf } from '../core/graph';
import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
import { editorPosition, treeOrder } from '../core/model/hierarchy';
import type { PropertyBag } from '../core/model/Properties';
-import { findAsset } from '../core/model/Project';
+import { findAsset, type ObjectHolder, type Project } from '../core/model/Project';
import { resolveTexture, type TextureRef } from '../core/model/texture';
import { enabledAlongChain } from '../core/properties/enabled';
import { resolveSceneObjectProps } from '../core/properties/resolve';
-import type { EditorState } from '../editor/state';
import type { World } from '../runtime/World';
/** What the viewport draws: the same shape whether it comes from the model or the world. */
@@ -32,24 +31,30 @@ interface DrawList {
background: number;
}
-/** The model's scene, in tree order – the tie-break for equal `z` – at the editor positions. */
-export function collectSceneItems(state: EditorState, fallbackBackground: number): DrawList {
- const scene = state.activeScene;
- if (!scene) return { items: [], background: fallbackBackground };
+/**
+ * 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.
+ */
+export function collectHolderItems(
+ project: Project,
+ holder: ObjectHolder | undefined,
+ fallbackBackground: number,
+): DrawList {
+ if (!holder) return { items: [], background: fallbackBackground };
const items: DrawItem[] = [];
- for (const { object } of treeOrder(scene)) {
- const asset = findAsset(state.project, object.assetId);
+ for (const { object } of treeOrder(holder)) {
+ const asset = findAsset(project, object.assetId);
if (!asset) continue;
const props = resolveSceneObjectProps(asset, object);
items.push({
id: object.id,
type: asset.type,
name: object.name || asset.name,
- ...editorPosition(scene, object),
+ ...editorPosition(holder, object),
props,
parentId: object.parentId,
enabled: true,
- texture: asset.type === 'sprite' ? resolveTexture(state.project, props) : null,
+ texture: asset.type === 'sprite' ? resolveTexture(project, props) : null,
});
}
const byId = new Map(items.map((i) => [i.id, i]));
@@ -59,7 +64,7 @@ export function collectSceneItems(state: EditorState, fallbackBackground: number
(i) => i.props,
(i) => byId.get(i.parentId) ?? null,
);
- return { items, background: Number(scene.props.backgroundColor ?? fallbackBackground) };
+ return { items, background: Number(holder.props.backgroundColor ?? fallbackBackground) };
}
/** The running world: scene content only, since symbols are editor-only. */
diff --git a/tests/core/container.test.ts b/tests/core/container.test.ts
index 0b58b727..3df9334b 100644
--- a/tests/core/container.test.ts
+++ b/tests/core/container.test.ts
@@ -1,16 +1,24 @@
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 {
MoveSceneObjectCommand,
+ PlaceAssetCommand,
+ RemoveSceneObjectCommand,
RemoveSceneObjectsCommand,
+ ClearOverrideCommand,
+ RenameSceneObjectCommand,
+ SetOverrideCommand,
SetParentCommand,
} from '../../src/core/commands/sceneObjectCommands';
import { worldPositionOf } from '../../src/core/graph';
import type { ContainerAsset } from '../../src/core/model/Asset';
-import { subtreeRoots, treeOrder } from '../../src/core/model/hierarchy';
+import { containerReaches } from '../../src/core/model/container';
+import { descendantsOf, subtreeRoots, treeOrder } from '../../src/core/model/hierarchy';
import { findAsset, findScene } from '../../src/core/model/Project';
+import { createSceneObject } from '../../src/core/model/SceneObject';
import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
/** Three boxes: a at (100,100,z1), b at (10,0) and c at (0,5) parented under b (local (-10,5)). */
@@ -23,6 +31,27 @@ 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. */
+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 };
+}
+
+/** Three container assets holding one box each, none of them nested yet. */
+function makeContainers() {
+ const f = makeSpriteScene();
+ const build = () => {
+ const command = new CreateContainerCommand(f.sceneId, [place(f, f.spriteId, { x: 0, y: 0 })]);
+ f.stack.execute(command);
+ return command.assetId;
+ };
+ const assetOf = (id: string) => findAsset(f.project, id) as ContainerAsset;
+ return { ...f, outerId: build(), innerId: build(), deepId: build(), assetOf };
+}
+
describe('subtree roots', () => {
it('drops ids that have a selected ancestor', () => {
const { sceneOf, a, b, c } = setup();
@@ -164,6 +193,218 @@ describe('placing a container', () => {
});
});
+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);
+
+ 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 RemoveSceneObjectCommand(containerId, templateA));
+ expect(template()).toBeUndefined();
+ expect(asset().objects).toHaveLength(2);
+
+ stack.undo();
+ expect(template()!.overrides.width).toBe(55);
+ stack.undo();
+ expect(template()!.overrides.width).toBeUndefined();
+ stack.undo();
+ expect(template()!.name).toBe('');
+ stack.undo();
+ expect(template()!.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);
+
+ stack.execute(new RemoveSceneObjectsCommand(containerId, [ids[1]!]));
+ expect(asset().objects.map((t) => t.id)).toEqual([ids[0]]);
+ stack.undo();
+ expect(asset().objects.map((t) => t.id)).toEqual(ids);
+ expect(asset().objects[2]!.parentId).toBe(ids[1]);
+ });
+
+ 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/);
+ });
+});
+
+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() {
+ 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))!,
+ };
+ }
+
+ 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);
+
+ stack.undo();
+ expect(object(a).overrides.width).toBeUndefined();
+ expect(object(modified).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('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 }));
+
+ expect(object(a).position).toEqual({ x: 7, y: 8, z: 9 });
+ expect(object(moved).position).toEqual({ x: 999, y: 8, z: 9 });
+
+ stack.undo();
+ expect(object(moved).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('reaches the content of a container nested in another one', () => {
+ const f = makeContainers();
+ const { stack, outerId, innerId, sceneOf, assetOf } = f;
+ stack.execute(new PlaceAssetCommand(outerId, innerId, { x: 20, y: 0 }));
+ 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)!;
+
+ stack.execute(new RenameSceneObjectCommand(innerId, innerTemplate.id, 'Deep'));
+ expect(deepCopy.name).toBe('Deep');
+ expect(descendantsOf(sceneOf(), placed)).toHaveLength(3);
+
+ stack.undo();
+ expect(deepCopy.name).toBe('');
+ });
+});
+
+describe('placing into a container template', () => {
+ it('creates a single object, without expanding a placed container', () => {
+ const { stack, outerId, innerId, assetOf } = makeContainers();
+ const command = new PlaceAssetCommand(outerId, innerId, { x: 20, y: 0 });
+ stack.execute(command);
+
+ const objects = assetOf(outerId).objects;
+ expect(objects).toHaveLength(2);
+ const placed = objects.find((o) => o.id === command.objectId)!;
+ expect(placed.assetId).toBe(innerId);
+ expect(placed.position).toEqual({ x: 20, y: 0, z: 0 });
+ expect(objects.some((o) => o.parentId === command.objectId)).toBe(false);
+
+ stack.undo();
+ 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(
+ /nests forever/,
+ );
+ expect(assetOf(outerId).objects).toHaveLength(1);
+
+ stack.execute(new PlaceAssetCommand(outerId, innerId, { x: 0, y: 0 }));
+ expect(() => stack.execute(new PlaceAssetCommand(innerId, outerId, { x: 0, y: 0 }))).toThrow(
+ CommandError,
+ );
+
+ stack.execute(new PlaceAssetCommand(innerId, deepId, { x: 0, y: 0 }));
+ expect(() => stack.execute(new PlaceAssetCommand(deepId, outerId, { x: 0, y: 0 }))).toThrow(CommandError);
+ expect(assetOf(deepId).objects).toHaveLength(1);
+ });
+});
+
+describe('containerReaches', () => {
+ it('sees the container itself and its content, transitively', () => {
+ const { project, stack, outerId, innerId, deepId, spriteId, assetOf } = makeContainers();
+ expect(containerReaches(project, assetOf(outerId), outerId)).toBe(true);
+ expect(containerReaches(project, assetOf(outerId), innerId)).toBe(false);
+
+ stack.execute(new PlaceAssetCommand(outerId, innerId, { x: 0, y: 0 }));
+ stack.execute(new PlaceAssetCommand(innerId, deepId, { x: 0, y: 0 }));
+ expect(containerReaches(project, assetOf(outerId), innerId)).toBe(true);
+ expect(containerReaches(project, assetOf(outerId), deepId)).toBe(true);
+ expect(containerReaches(project, assetOf(deepId), outerId)).toBe(false);
+ expect(containerReaches(project, assetOf(outerId), spriteId)).toBe(false);
+ });
+
+ it('terminates on a cycle the commands would have refused to create', () => {
+ const { project, stack, outerId, innerId, deepId, assetOf } = makeContainers();
+ stack.execute(new PlaceAssetCommand(outerId, innerId, { x: 0, y: 0 }));
+ assetOf(innerId).objects.push(createSceneObject('cycle', outerId, { x: 0, y: 0 }));
+
+ expect(containerReaches(project, assetOf(outerId), deepId)).toBe(false);
+ expect(containerReaches(project, assetOf(innerId), outerId)).toBe(true);
+ });
+});
+
describe('multi-selection commands', () => {
it('removes several subtrees as one step, covering nested ids', () => {
const { stack, sceneId, a, b, c, sceneOf, object } = setup();
diff --git a/tests/core/holders.test.ts b/tests/core/holders.test.ts
index c614a4e6..a42e28c6 100644
--- a/tests/core/holders.test.ts
+++ b/tests/core/holders.test.ts
@@ -9,7 +9,13 @@ import { CreateContainerCommand } from '../../src/core/commands/containerCommand
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
import type { ContainerAsset } from '../../src/core/model/Asset';
-import { findAsset, findUsages, objectHolders } from '../../src/core/model/Project';
+import {
+ findAsset,
+ findObjectHolder,
+ findUsages,
+ listContainers,
+ objectHolders,
+} from '../../src/core/model/Project';
import { makeBehavior, makeSpriteScene, place } from '../helpers';
/** A behavior with a `speed` variable, placed and overridden to 42, then wrapped in a container. */
@@ -21,7 +27,7 @@ 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, objectId, template };
+ return { ...f, behaviorId: behavior.id, containerId: container.assetId, objectId, template };
}
describe('object holders', () => {
@@ -34,6 +40,20 @@ describe('object holders', () => {
).toEqual(['container', 'scene']);
});
+ it('resolves a scene or a container by id, but no other asset', () => {
+ const { project, sceneId, containerId, spriteId, behaviorId } = containerWithOverride();
+ expect(findObjectHolder(project, sceneId)?.type).toBe('scene');
+ expect(findObjectHolder(project, containerId)?.type).toBe('container');
+ expect(findObjectHolder(project, spriteId)).toBeUndefined();
+ expect(findObjectHolder(project, behaviorId)).toBeUndefined();
+ expect(findObjectHolder(project, 'nope')).toBeUndefined();
+ });
+
+ it('lists the container assets on their own', () => {
+ const { project, containerId } = containerWithOverride();
+ expect(listContainers(project).map((c) => c.id)).toEqual([containerId]);
+ });
+
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.
diff --git a/tools/e2e/01-hierarchy.mjs b/tools/e2e/01-hierarchy.mjs
index da1ceba5..127f4d06 100644
--- a/tools/e2e/01-hierarchy.mjs
+++ b/tools/e2e/01-hierarchy.mjs
@@ -31,7 +31,7 @@ export async function hierarchy(t) {
same(await rowIds(), ['o1', 'o2', 'o3', 'o5']),
await rows(),
);
- await select({ kind: 'object', sceneId: 'scene1', objectId: 'oc1' });
+ await select({ kind: 'object', holderId: 'scene1', objectId: 'oc1' });
await sleep(150);
check(
'selecting a nested object unfolds its ancestors',
diff --git a/tools/e2e/03-play.mjs b/tools/e2e/03-play.mjs
index 24892b87..92dbbae1 100644
--- a/tools/e2e/03-play.mjs
+++ b/tools/e2e/03-play.mjs
@@ -80,7 +80,7 @@ export async function play(t) {
Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) &&
(await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 7,
);
- await select({ kind: 'object', sceneId: 'scene1', objectId: 'o2' });
+ await select({ kind: 'object', holderId: 'scene1', objectId: 'o2' });
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 400`);
let liveO2 = await entity('o2');
check(
@@ -158,7 +158,7 @@ export async function play(t) {
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = ${xBeforePlay}`);
// 7c. Without a camera nothing is rendered: delete the camera object, play, then undo.
- await select({ kind: 'object', sceneId: 'scene1', objectId: 'o3' });
+ await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' });
await press('Delete');
await sleep(200);
check('camera object deleted', (await objects()).length === 6);
diff --git a/tools/e2e/05-drawing.mjs b/tools/e2e/05-drawing.mjs
index 718ca554..a7b4c9e0 100644
--- a/tools/e2e/05-drawing.mjs
+++ b/tools/e2e/05-drawing.mjs
@@ -30,7 +30,7 @@ export async function drawing(t) {
JSON.stringify(await orderOfCrates()) === '["o1","o2"]',
JSON.stringify(await orderOfCrates()),
);
- await select({ kind: 'object', sceneId: 'scene1', objectId: 'o1' });
+ await select({ kind: 'object', holderId: 'scene1', objectId: 'o1' });
await sleep(200);
const zLabel = await locate('Z');
await click(zLabel.x + 160, zLabel.y); // plain click → text editing with everything selected
diff --git a/tools/e2e/09-containers.mjs b/tools/e2e/09-containers.mjs
index 6e520d79..93791307 100644
--- a/tools/e2e/09-containers.mjs
+++ b/tools/e2e/09-containers.mjs
@@ -146,7 +146,7 @@ export async function containers(t) {
// 16d. 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', sceneId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
+ 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');
@@ -161,7 +161,7 @@ export async function containers(t) {
// 16e. Delete with a multi-selection removes all of them (with subtrees) as one step.
const objectsBeforeDelete = (await objects()).length;
- await select({ kind: 'object', sceneId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
+ await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
await press('Delete');
await sleep(150);
check(
@@ -226,7 +226,7 @@ export async function containers(t) {
// ↑ 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', sceneId: 'scene1', objectId: 'o1' });
+ 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));
check('no apply/revert while the instance matches its template', !(await has('↑')) && !(await has('↺')));
diff --git a/tools/e2e/10-text-and-enabled.mjs b/tools/e2e/10-text-and-enabled.mjs
index 6d050999..5d83d82c 100644
--- a/tools/e2e/10-text-and-enabled.mjs
+++ b/tools/e2e/10-text-and-enabled.mjs
@@ -15,7 +15,7 @@ export async function textAndEnabled(t) {
// 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.select({ kind: 'object', sceneId: 'scene1', objectIds: ['otext'], objectId: 'otext' })`);
+ __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'));
await shot('29-text-object');
diff --git a/tools/e2e/11-menus.mjs b/tools/e2e/11-menus.mjs
index ab09eee1..b8bf5697 100644
--- a/tools/e2e/11-menus.mjs
+++ b/tools/e2e/11-menus.mjs
@@ -15,7 +15,7 @@ export async function menus(t) {
copy.parentId = '';
scene.objects.push(copy);
}
- __gm.state.select({ kind: 'object', sceneId: 'scene1', objectIds: ['otext'], objectId: 'otext' })`);
+ __gm.state.select({ kind: 'object', holderId: 'scene1', objectIds: ['otext'], objectId: 'otext' })`);
await sleep(150);
// 18. A picker with 40+ entries is capped in height and stays fully on screen instead of
diff --git a/tools/e2e/14-overflow.mjs b/tools/e2e/14-overflow.mjs
index 044264b8..ed38a57d 100644
--- a/tools/e2e/14-overflow.mjs
+++ b/tools/e2e/14-overflow.mjs
@@ -14,7 +14,7 @@ export async function overflow(t) {
const pickerWidths = async () => {
const out = {};
- for (const tab of ['graph', 'texture']) {
+ for (const tab of ['graph', 'texture', 'container']) {
await evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
await sleep(300);
out[tab] = await json(`__gm.${tab}.pickerWidth()`);
@@ -25,7 +25,8 @@ export async function overflow(t) {
await mutate(`
for (const asset of __gm.state.project.assets) {
- if (asset.type === 'behavior' || asset.type === 'texture') asset.name = ${JSON.stringify(LONG_NAME)};
+ if (asset.type === 'behavior' || asset.type === 'texture' || asset.type === 'container')
+ asset.name = ${JSON.stringify(LONG_NAME)};
}
__gm.state.select(null)`);
await sleep(600);
@@ -33,7 +34,7 @@ export async function overflow(t) {
// 21. The pickers keep the width they were given. `width` used to set only a minimum, so a
// picker grew with whatever name it happened to show and the header reflowed on load.
const after = await pickerWidths();
- for (const tab of ['graph', 'texture']) {
+ for (const tab of ['graph', 'texture', 'container']) {
check(
`the ${tab} picker keeps its width when the name gets long`,
before[tab] === 200 && after[tab] === 200,
@@ -42,7 +43,7 @@ export async function overflow(t) {
}
// 21a. Every tab, because each one owns a different set of controls.
- for (const tab of ['scene', 'graph', 'texture', 'game']) {
+ for (const tab of ['scene', 'graph', 'texture', 'game', 'container']) {
await evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
await sleep(400);
const spills = await json('__gm.textOverflows()');
@@ -75,7 +76,7 @@ export async function overflow(t) {
// 21d. The other direction: a control narrow enough to swallow its whole label draws nothing
// at all, which the overflow walk cannot see – an empty text spills over no edge.
- for (const tab of ['scene', 'graph', 'texture']) {
+ for (const tab of ['scene', 'graph', 'texture', 'container']) {
await evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
await sleep(400);
const dropped = await json('__gm.labelsDropped()');
diff --git a/tools/e2e/15-aspect.mjs b/tools/e2e/15-aspect.mjs
index a4a07d1f..8481ed54 100644
--- a/tools/e2e/15-aspect.mjs
+++ b/tools/e2e/15-aspect.mjs
@@ -93,7 +93,7 @@ export async function aspect(t) {
// 30. The same rows on a placed object write an override rather than the asset value.
await mutate(`${spriteProp('texture')} = ${JSON.stringify(sheet)}`);
- await select({ kind: 'object', sceneId: 'scene1', objectIds: ['otex'], objectId: 'otex' });
+ await select({ kind: 'object', holderId: 'scene1', objectIds: ['otex'], objectId: 'otex' });
await sleep(600);
await mutate("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides.height = 90");
await sleep(400);
diff --git a/tools/e2e/16-container.mjs b/tools/e2e/16-container.mjs
new file mode 100644
index 00000000..1fe115d2
--- /dev/null
+++ b/tools/e2e/16-container.mjs
@@ -0,0 +1,262 @@
+/**
+ * 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"
+ * a question worth asking.
+ */
+
+const NAME = 'Crate pair';
+
+export async function containerTab(t) {
+ const {
+ check,
+ sleep,
+ json,
+ evaluate,
+ mutate,
+ select,
+ shot,
+ click,
+ clickText,
+ drag,
+ press,
+ undo,
+ state,
+ asset,
+ } = t;
+ await press('Escape');
+
+ const containerId = await json("(__gm.state.project.assets.find(a => a.type === 'container') ?? {}).id");
+ // The overflow step renames assets to absurd lengths; a readable name makes the header checkable.
+ await evaluate(
+ `(() => { __gm.state.project.assets.find(a => a.id === ${JSON.stringify(containerId)}).name = ${JSON.stringify(NAME)}; __gm.state.preview(); })()`,
+ );
+ await sleep(200);
+
+ // 22. The tab opens on the container the project has, and its header says which one.
+ await clickText('Container');
+ await sleep(500);
+ const template = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`);
+ const count = template.length;
+ check(
+ 'the Container tab opens showing a container',
+ (await json('__gm.tab()')) === 'container' && (await json('__gm.container.current()')) === containerId,
+ JSON.stringify({ tab: await json('__gm.tab()'), shown: await json('__gm.container.current()') }),
+ );
+ check(
+ 'the header names the container and counts its template objects',
+ Boolean(await evaluate(`__gm.locate(${JSON.stringify(NAME)})`)) &&
+ Boolean(await evaluate(`__gm.locate(${JSON.stringify(`${count} objects`)})`)),
+ JSON.stringify({ name: NAME, count }),
+ );
+ check(
+ 'the hierarchy panel retitles itself for the container it lists',
+ // A panel draws its title in capitals, so that is what there is to find.
+ Boolean(await evaluate(`__gm.locate(${JSON.stringify(`Container: ${NAME}`.toUpperCase())})`)),
+ );
+ await shot('30-container-tab');
+
+ // 22a. The hierarchy follows the visible tab: it lists the template, not the scene.
+ const rows = await json('__gm.hierarchyOrder()');
+ check(
+ 'the hierarchy lists the template objects while the tab is open',
+ rows.length > 0 && rows.every((id) => template.some((o) => o.id === id)),
+ JSON.stringify(rows),
+ );
+
+ // 22b. Clicking a template object selects it against the container as its holder – the same
+ // id set as the scene would give, but a different holder.
+ const root = template.find((o) => o.parentId === '');
+ const at = await json(`__gm.container.objectScreen(${JSON.stringify(root.id)})`);
+ await click(at.x, at.y);
+ let s = await state();
+ check(
+ 'clicking a template object selects it against the container, not the scene',
+ s.selection?.kind === 'object' &&
+ s.selection.holderId === containerId &&
+ s.selection.objectId === root.id,
+ 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.
+ const templateX = () =>
+ json(
+ `__gm.state.project.assets.find(a => a.id === '${containerId}').objects.find(o => o.id === '${root.id}').position.x`,
+ );
+ const copyXs = () =>
+ json(
+ `Object.fromEntries(__gm.state.activeScene.objects.filter(o => o.templateId === '${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 before = await templateX();
+ await drag(at.x, at.y, at.x + 90, at.y);
+ const after = await templateX();
+ const copiesAfter = await copyXs();
+ 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 }),
+ );
+ 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] }),
+ );
+ await undo();
+ const copiesUndone = await copyXs();
+ 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 }),
+ );
+ await mutate(`__gm.state.activeScene.objects.find(o => o.id === '${dirtyId}').position.x -= 7`);
+
+ // 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;
+ await clickText(crateName, 'right');
+ check(
+ 'the asset menu offers Place in container while the tab is open',
+ Boolean(await evaluate("__gm.locate('Place in container')")) &&
+ !(await evaluate("__gm.locate('Place in scene')")),
+ );
+ await clickText('Place in container');
+ await sleep(300);
+ const grown = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`);
+ s = await state();
+ check(
+ 'Place in container puts one object into the template and selects it there',
+ grown.length === count + 1 &&
+ s.selection?.holderId === containerId &&
+ 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);
+ await shot('31-container-object-added');
+
+ // 22e. Delete works on a template object like on any other, as one undo step.
+ await press('Delete');
+ await sleep(250);
+ check(
+ 'Delete removes a template object',
+ (await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`)).length === count,
+ );
+ await undo();
+ check(
+ 'undo puts it back',
+ (await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`)).length ===
+ count + 1,
+ );
+ await press('Delete');
+ await sleep(250);
+
+ // 22f. Fit frames the content. Proven against an object parked far outside the view: without
+ // fitting it is nowhere near the canvas.
+ await evaluate(
+ `(() => { const t = __gm.state.project.assets.find(a => a.id === '${containerId}').objects.find(o => o.id === '${root.id}'); t.position.x = 3000; __gm.state.preview(); })()`,
+ );
+ await sleep(300);
+ const area = await json('__gm.container.rect()');
+ const inside = (p) => p.x >= area.x && p.x <= area.x + area.w && p.y >= area.y && p.y <= area.y + area.h;
+ const far = await json(`__gm.container.objectScreen(${JSON.stringify(root.id)})`);
+ await clickText('Fit');
+ await sleep(300);
+ const fitted = await json(`__gm.container.objectScreen(${JSON.stringify(root.id)})`);
+ check(
+ 'Fit brings an object parked far outside back into view',
+ !inside(far) && inside(fitted),
+ JSON.stringify({ area, far, fitted }),
+ );
+ await evaluate(
+ `(() => { const t = __gm.state.project.assets.find(a => a.id === '${containerId}').objects.find(o => o.id === '${root.id}'); t.position.x = ${before}; __gm.state.preview(); })()`,
+ );
+ await clickText('Fit');
+ await sleep(300);
+ await shot('32-container-fitted');
+
+ // 22g. A template object exists only in this tab, so selecting one from elsewhere switches to
+ // it – the rule a graph node and a texture frame already follow. A scene object does not.
+ await evaluate("__gm.showTab('scene')");
+ await sleep(300);
+ await evaluate(
+ `__gm.state.select({ kind: 'object', holderId: ${JSON.stringify(containerId)}, objectId: ${JSON.stringify(root.id)} })`,
+ );
+ await sleep(300);
+ check(
+ 'selecting a template object switches to the Container tab',
+ (await json('__gm.tab()')) === 'container',
+ );
+ await evaluate("__gm.showTab('scene')");
+ await sleep(300);
+ await evaluate("__gm.state.select({ kind: 'object', holderId: 'scene1', objectId: 'o1' })");
+ 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'),
+ JSON.stringify(await json('__gm.hierarchyOrder()')),
+ );
+ await clickText(crateName, 'right');
+ check(
+ 'and back on the Scene tab the same entry reads Place in scene',
+ Boolean(await evaluate("__gm.locate('Place in scene')")) &&
+ !(await evaluate("__gm.locate('Place in container')")),
+ );
+ await press('Escape');
+
+ // 22h. Switching to another container is a plain asset selection – the same thing the header's
+ // picker does. The hierarchy has to follow it, title and rows both.
+ await evaluate("__gm.showTab('container')");
+ await sleep(300);
+ await mutate(`const src = __gm.state.project.assets.find(a => a.id === '${containerId}');
+ const copy = JSON.parse(JSON.stringify(src));
+ copy.id = 'c2'; copy.name = 'Spare box';
+ copy.objects = copy.objects.slice(0, 1).map((o) => ({ ...o, id: 'c2o1', parentId: '' }));
+ __gm.state.project.assets.push(copy)`);
+ await select({ kind: 'asset', assetId: 'c2' });
+ await sleep(400);
+ check(
+ 'picking another container moves the tab and the hierarchy to it',
+ (await json('__gm.container.current()')) === 'c2' &&
+ JSON.stringify(await json('__gm.hierarchyOrder()')) === JSON.stringify(['c2o1']) &&
+ Boolean(await evaluate("__gm.locate('CONTAINER: SPARE BOX')")),
+ JSON.stringify({
+ shown: await json('__gm.container.current()'),
+ rows: await json('__gm.hierarchyOrder()'),
+ }),
+ );
+ await shot('33-container-switched');
+
+ // 22i. Nesting: a right-click must not select, or picking the container to place would load it
+ // into the tab first – you would then be placing it into itself.
+ const nested = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
+ await clickText(NAME, 'right');
+ check(
+ 'right-clicking another container leaves the open one alone',
+ (await json('__gm.container.current()')) === 'c2',
+ JSON.stringify({ shown: await json('__gm.container.current()') }),
+ );
+ await clickText('Place in container');
+ await sleep(300);
+ const nestedAfter = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
+ check(
+ 'and placing it nests it inside that one',
+ nestedAfter.length === nested.length + 1 &&
+ nestedAfter.some((o) => o.assetId === containerId) &&
+ (await json('__gm.container.current()')) === 'c2',
+ JSON.stringify({ before: nested.length, after: nestedAfter.length }),
+ );
+ await undo();
+ await sleep(200);
+}
diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs
index 789c33b4..168fa57f 100644
--- a/tools/e2e/index.mjs
+++ b/tools/e2e/index.mjs
@@ -18,6 +18,7 @@ import { textures } from './12-textures.mjs';
import { frames } from './13-frames.mjs';
import { overflow } from './14-overflow.mjs';
import { aspect } from './15-aspect.mjs';
+import { containerTab } from './16-container.mjs';
export const STEPS = [
hierarchy,
@@ -35,4 +36,5 @@ export const STEPS = [
frames,
overflow,
aspect,
+ containerTab,
];