Add a Container tab and propagate template edits

The Container tab reuses SceneViewport against any object holder, so a
container template is edited exactly like a scene. Right-click no longer
changes the selection, which previously made nesting impossible: opening
the menu on a container loaded it, and "Place in container" then targeted
itself.

Template edits (move, rename, override) now reach already-placed instances
per slot, through a shared templateSync used by both the apply path and the
plain object commands. Copies the user has touched keep their own value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 20:19:54 +02:00
1 parent 6bedc948be
commit 9d93020bd8
35 files changed
+1452 -363

No files matched your search

+58 -15
View File
@@ -15,8 +15,8 @@ Live: <https://nockal.com/game-maker/> · Widget playground: <https://nockal.com
| **Text** | An asset that draws a line of text (`text`, `fontSize`, `color`, `align`) – titles, HUD readouts, button labels. Its content is an ordinary property, so `Set text` (or `Set property`) changes it while playing. | | **Text** | An asset that draws a line of text (`text`, `fontSize`, `color`, `align`) – titles, HUD readouts, button labels. Its content is an ordinary property, so `Set text` (or `Set property`) changes it while playing. |
| **Enabled** | Every placeable asset has the switch `enabled` (default on). A disabled object is not drawn, does not collide and its behavior does not run – **and neither does anything parented below it**. Switching one instance off is a normal override, which makes a container a *screen*: switch it on to show it, off to hide it. `Set enabled` / `Is enabled` do it from a graph. | | **Enabled** | Every placeable asset has the switch `enabled` (default on). A disabled object is not drawn, does not collide and its behavior does not run – **and neither does anything parented below it**. Switching one instance off is a normal override, which makes a container a *screen*: switch it on to show it, off to hide it. `Set enabled` / `Is enabled` do it from a graph. |
| **Scene object** | An asset placed into a scene: a link to the asset + a **position** (`x`, `y`, `z`) + sparse **overrides** of the asset's properties + an optional **parent** (another object of the scene). Scenes cannot be placed. | | **Scene object** | An asset placed into a scene: a link to the asset + a **position** (`x`, `y`, `z`) + sparse **overrides** of the asset's properties + an optional **parent** (another object of the scene). Scenes cannot be placed. |
| **Container** | A reusable group ("prefab"): the asset holds a **template** of objects (asset link, local position, overrides, parent links) centred on its origin. Placing a container creates a container object plus fresh copies of the template below it – ordinary objects, each remembering its template id. **Create container** turns the current selection into such an asset and replaces the selected objects by one placed container at their centre (world positions unchanged). The template is edited from its instances (see *Instance*). | | **Container** | A reusable group ("prefab"): the asset holds a **template** of objects (asset link, local position, overrides, parent links) centred on its origin. Placing a container creates a container object plus fresh copies of the template below it – ordinary objects, each remembering its template id. **Create container** turns the current selection into such an asset and replaces the selected objects by one placed container at their centre (world positions unchanged). The template is edited in the **Container** tab (see *Editing a container*) or from an instance through **Apply to container**. |
| **Selection** | One or more scene objects. Click selects, `Ctrl`-click adds/removes, dragging on empty scene space draws a rubber band (objects whose position lies inside are selected). Dragging a selected object moves the whole selection; `Delete` removes it; the inspector offers **Create container** and **Remove from scene** for several objects. | | **Selection** | One or more scene objects. Click selects, `Ctrl`-click adds/removes, dragging on empty scene space draws a rubber band (objects whose position lies inside are selected). Dragging a selected object moves the whole selection; `Delete` removes it; the inspector offers **Create container** and **Remove** for several objects. |
| **Behavior** | Logic as an asset: a **node graph** plus the **variables** it declares. Placed like anything else (a diamond symbol in the scene); its parent is the **owner** the graph acts on. | | **Behavior** | Logic as an asset: a **node graph** plus the **variables** it declares. Placed like anything else (a diamond symbol in the scene); its parent is the **owner** the graph acts on. |
| **Collider** | A shape (box or circle) for collision detection, placed as a **child** of the object it belongs to (its *body*). Drawn as a green outline in edit mode only. | | **Collider** | A shape (box or circle) for collision detection, placed as a **child** of the object it belongs to (its *body*). Drawn as a green outline in edit mode only. |
| **Override** | A per-object value that replaces the asset's value for one property. Only changed keys are stored; the inspector marks them bold and offers a reset (↺) and an **apply** (↑) that writes the value into the asset instead. Both appear only where the value really differs from its base – an override carrying the asset's own value is none. | | **Override** | A per-object value that replaces the asset's value for one property. Only changed keys are stored; the inspector marks them bold and offers a reset (↺) and an **apply** (↑) that writes the value into the asset instead. Both appear only where the value really differs from its base – an override carrying the asset's own value is none. |
@@ -89,9 +89,9 @@ shown on the node and editable in the inspector.
(types are checked; a data input holds one link and is replaced), drag nodes, right-click a (types are checked; a data input holds one link and is replaced), drag nodes, right-click a
node or port to delete/disconnect, `Delete` removes the selected node, wheel zooms, dragging node or port to delete/disconnect, `Delete` removes the selected node, wheel zooms, dragging
the empty canvas pans. Everything is undoable. the empty canvas pans. Everything is undoable.
- **Header bar** (the same one the Texture tab has): the behavior picker, **Add node…** (the - **Header bar** (the same one the Texture and Container tabs have): the behavior picker,
palette, dropping the node in the middle of the view – the right-click nobody finds), **Fit**, **Add node…** (the palette, dropping the node in the middle of the view – the right-click nobody
and the size of the graph as "N nodes · M links". finds), **Fit**, and the size of the graph as "N nodes · M links".
- **Selection-following, not selection-bound**: selecting a behavior asset, a placed behavior - **Selection-following, not selection-bound**: selecting a behavior asset, a placed behavior
or one of its nodes shows that graph; a selection about anything else (a sprite, nothing at or one of its nodes shows that graph; a selection about anything else (a sprite, nothing at
all) leaves the tab on the behavior you were editing, because checking a sprite's position all) leaves the tab on the behavior you were editing, because checking a sprite's position
@@ -249,6 +249,47 @@ large is refused with a clear message instead of a broken upload. The server acc
JPEG, WebP and GIF, deriving the type from the content rather than from anything the client JPEG, WebP and GIF, deriving the type from the content rather than from anything the client
says, and rejects images over 8192 px per side. says, and rejects images over 8192 px per side.
## Editing a container
The **Container tab** shows one container's template with the *same* viewport the Scene tab uses –
`ContainerCanvas` subclasses `SceneViewport` and swaps nothing but which asset holds the objects,
so picking, rubber band, dragging, gizmos, labels and parent lines are not written twice. It adds a
cross at the **origin**, because that is the point the container is held by when placed: where the
content sits relative to it is the thing being authored. A template has no background of its own –
painting one would invent the scene it is not in.
The **Scene objects** panel lists whatever the visible tab edits, so in the Container tab it lists
the template, with the same drag-to-parent, context menu and folding as a scene; the inspector,
`Delete` and `Ctrl+G` work on template objects like on placed ones. None of that is a special case:
a scene asset and a container asset are both `ObjectHolder`s, and every object command takes a
`holderId` that is either one.
**Header bar**: the container picker (searchable), **Fit** (frames the content, origin always
included) and the size of the template as "N objects". Placing needs no button of its own: the
Assets panel already places, and its entry follows the visible tab – **Place in scene** becomes
**Place in container** while this tab is open, and drops at the middle of *this* view. A right-click
marks a row without selecting it, so opening that menu on a container does not load it into the tab
first – otherwise nesting one container in another would place it into itself.
Like the Graph tab it follows the selection without being bound to it – the container asset, one of
its placed copies or one of its template objects shows it, anything else leaves the tab on what you
were editing, and only a deleted container makes it fall back to another one. Selecting a template
*object* also switches to the tab, the rule a graph node and a texture frame already follow: it
exists in no other view. Selecting the asset itself does not.
Placing a container **into a template** creates the one object and nothing below it, where placing
it into a scene expands its content: the expansion happens when a copy of the outer container is
placed, so the template stays what it says. A container that reaches this one, directly or through
another, is refused by the command – because that cycle expands forever the moment either of them
is placed.
Editing a template **moves the copies already placed** with it, by the same rule **Apply to
container** follows: a copy that still holds the template's old value takes the new one, a copy
changed on its own keeps what it has – per override key and per position axis, so a copy dragged
sideways still follows a template change in `y`. This covers what a template object *is* (position,
name, overrides); what a template *contains* is read at placement time only, so adding, deleting or
re-parenting a template object decides what the next placement looks like and leaves the existing
copies as they are.
## Screens without scene switching ## Screens without scene switching
A **screen** (title, win, lose, a pause overlay) is just a container whose `enabled` is A **screen** (title, win, lose, a pause overlay) is just a container whose `enabled` is
@@ -279,13 +320,15 @@ selected and switched back on.
measures from it – only what is drawn is shortened – and a subclass that puts something else measures from it – only what is drawn is shortened – and a subclass that puts something else
inside the control takes its room off `labelSlot()`, so a long value is cut rather than run inside the control takes its room off `labelSlot()`, so a long value is cut rather than run
underneath the chevron. Given a `width`, a button is exactly that wide and truncates instead: underneath the chevron. Given a `width`, a button is exactly that wide and truncates instead:
the tab-bar buttons and the Graph and Texture pickers keep their size whatever they show. the tab-bar buttons and the Graph, Texture and Container pickers keep their size whatever they
show.
The padding is an inset rather than a claim, so a 24 px icon button spends it on the glyph The padding is an inset rather than a claim, so a 24 px icon button spends it on the glyph
instead of truncating it away; a button that switches between label forms (`↺` and `↺ ▾`) asks instead of truncating it away; a button that switches between label forms (`↺` and `↺ ▾`) asks
for `minWidth` and grows. `__gm.textOverflows()` guards the whole class of bug: it reports any for `minWidth` and grows. `__gm.textOverflows()` guards the whole class of bug: it reports any
visible text drawn wider than the widget owning it, `__gm.labelsDropped()` guards the other visible text drawn wider than the widget owning it, `__gm.labelsDropped()` guards the other
direction – a button too narrow to draw any of its label – and `__gm.graph.pickerWidth()` / direction – a button too narrow to draw any of its label – and the `pickerWidth()` of
`__gm.texture.pickerWidth()` catch a picker that resizes itself around its content (e2e step 14). `__gm.graph` / `__gm.texture` / `__gm.container` catch a picker that resizes itself around its
content (e2e step 14).
- **Vite + TypeScript**, **Vitest** for the pure core. - **Vite + TypeScript**, **Vitest** for the pure core.
- **PHP 8** JSON API (`api/`) storing one JSON file per project in `data/projects/`, with - **PHP 8** JSON API (`api/`) storing one JSON file per project in `data/projects/`, with
atomic writes and an integer revision that rejects stale saves (HTTP 409). atomic writes and an integer revision that rejects stale saves (HTTP 409).
@@ -296,7 +339,7 @@ selected and switched back on.
web/index.html Vite source page (dev) index.html built page (generated, served) web/index.html Vite source page (dev) index.html built page (generated, served)
src/core/model/ Project (object holders), Asset, SceneObject, hierarchy, walk (generic parent/child walks), container templates src/core/model/ Project (object holders), Asset, SceneObject, hierarchy, walk (generic parent/child walks), container templates
src/core/properties/ PropertySchema, resolve (the only override merge), collider/camera/text geometry, enabled src/core/properties/ PropertySchema, resolve (the only override merge), collider/camera/text geometry, enabled
src/core/commands/ undoable commands, CommandStack, SetBagValueCommand base, require.ts (validated lookups), naming.ts src/core/commands/ undoable commands, CommandStack, SetBagValueCommand base, templateSync.ts (which copies follow a template), require.ts (validated lookups), naming.ts
src/core/graph/ graph model, NodeRegistry/NodeContext/values, interpreter; nodes/<category>/<Type>.ts src/core/graph/ graph model, NodeRegistry/NodeContext/values, interpreter; nodes/<category>/<Type>.ts
src/core/store/ serialize (untrusted JSON → Project), HttpProjectStore src/core/store/ serialize (untrusted JSON → Project), HttpProjectStore
src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, BehaviorSystem 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) one section per selection kind (asset/object/node)
src/editor/panels/ AssetPanel, HierarchyPanel, Toolbar src/editor/panels/ AssetPanel, HierarchyPanel, Toolbar
src/editor/graph/ GraphEditor (header + picker), GraphCanvas, NodeView, NodePalette 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 api/ PHP backend data/projects/ persisted projects
tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/), smoke-jumprun, lib/ 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. 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, 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 on each other's scene state
and share the CDP driver in `tools/lib/harness.mjs` – `press`, `undo`, `play`, `object(id)`, 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. `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 ## Not yet
Editing a container's template directly (today changes reach it only via **Apply to Reordering the hierarchy, several scenes in one run (a `Load scene` node – screens are containers
container** from an instance), reordering the hierarchy, several scenes in one run (a today), sound, auth, touch-optimised layout, graph comments/groups. A frame is always stretched to
`Load scene` node – screens are containers today), sound, auth, touch-optimised layout, the sprite's `width` × `height`: no animation (nothing steps through a sheet's frames yet) and no nine-slice. ↔ / ↕ fit the
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 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 regular grid only — no detection of where the sprites actually
sit on the sheet. Unreferenced image files are never collected. sit on the sheet. Unreferenced image files are never collected.
+34 -61
View File
@@ -3,47 +3,30 @@
* back to the asset, or to the template of the container it is part of. Prefab semantics for the * 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. * 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 { 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 type { SceneObject } from '../model/SceneObject';
import { resolveSceneObjectProps } from '../properties/resolve'; import { resolveSceneObjectProps } from '../properties/resolve';
import { type Command, CommandError } from './Command'; import { type Command, CommandError } from './Command';
import { requireAsset, requireObject, requirePropDef } from './require'; 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, holderId: string, object: SceneObject): TemplateLink {
const holder = findObjectHolder(project, holderId);
function requireLink(project: Project, sceneId: string, object: SceneObject): TemplateLink { const link = holder && templateLinkOf(project, holder, object);
const scene = findScene(project, sceneId);
const link = scene && templateLinkOf(project, scene, object);
if (!link) throw new CommandError(`${object.name || object.id} is not part of a placed container`); if (!link) throw new CommandError(`${object.name || object.id} is not part of a placed container`);
return link; return link;
} }
/** One value that is applied on its own: an override key, or a single axis of the position. */
interface Slot<T> {
read(object: SceneObject): T;
write(object: SceneObject, value: T): void;
}
const overrideSlot = (key: string): Slot<PropertyValue | undefined> => ({
read: (object) => object.overrides[key],
write: (object, value) => setBagValue(object.overrides, key, value),
});
const positionSlot = (axis: Axis): Slot<number> => ({
read: (object) => object.position[axis],
write: (object, value) => {
object.position[axis] = value;
},
});
interface SlotMemo<T> {
object: SceneObject;
slot: Slot<T>;
previous: T;
}
/** /**
* Writes `values` into the template's slots and carries along every instance that still agreed with * 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. * it. Returns the memos in application order; `restoreSlots` undoes them.
@@ -60,21 +43,11 @@ function applyToTemplate<T>(
slot, slot,
previous: before[i]!, previous: before[i]!,
})); }));
for (const { object } of instancesOf(project, link.template.id)) { memos.push(...syncInstances(project, link.template, slots, before, values));
slots.forEach((slot, i) => {
if (slot.read(object) !== before[i]) return;
memos.push({ object, slot, previous: slot.read(object) });
slot.write(object, values[i]!);
});
}
slots.forEach((slot, i) => slot.write(link.template, values[i]!)); slots.forEach((slot, i) => slot.write(link.template, values[i]!));
return memos; return memos;
} }
function restoreSlots<T>(memos: readonly SlotMemo<T>[]): 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 * 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. * 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<PropertyValue | undefined>[] = []; private templateMemos: SlotMemo<PropertyValue | undefined>[] = [];
constructor( constructor(
private readonly sceneId: string, private readonly holderId: string,
private readonly objectId: string, private readonly objectId: string,
private readonly key: string, private readonly key: string,
) { ) {
@@ -94,8 +67,8 @@ export class ApplyOverrideToAssetCommand implements Command {
} }
execute(project: Project): void { execute(project: Project): void {
const scene = findScene(project, this.sceneId); const holder = findObjectHolder(project, this.holderId);
const object = requireObject(project, this.sceneId, this.objectId); const object = requireObject(project, this.holderId, this.objectId);
const asset = requireAsset(project, object.assetId); const asset = requireAsset(project, object.assetId);
requirePropDef(asset, this.key); requirePropDef(asset, this.key);
const value = resolveSceneObjectProps(asset, object)[this.key]; const value = resolveSceneObjectProps(asset, object)[this.key];
@@ -103,12 +76,12 @@ export class ApplyOverrideToAssetCommand implements Command {
this.previousOverride = object.overrides[this.key]; this.previousOverride = object.overrides[this.key];
asset.props[this.key] = value!; asset.props[this.key] = value!;
delete object.overrides[this.key]; 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]) : []; this.templateMemos = link ? applyToTemplate(project, link, [overrideSlot(this.key)], [undefined]) : [];
} }
undo(project: Project): void { 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); const asset = requireAsset(project, object.assetId);
restoreSlots(this.templateMemos); restoreSlots(this.templateMemos);
setBagValue(object.overrides, this.key, this.previousOverride); setBagValue(object.overrides, this.key, this.previousOverride);
@@ -121,7 +94,7 @@ export class ApplyOverrideToContainerCommand implements Command {
private memos: SlotMemo<PropertyValue | undefined>[] = []; private memos: SlotMemo<PropertyValue | undefined>[] = [];
constructor( constructor(
private readonly sceneId: string, private readonly holderId: string,
private readonly objectId: string, private readonly objectId: string,
private readonly key: string, private readonly key: string,
) { ) {
@@ -129,8 +102,8 @@ export class ApplyOverrideToContainerCommand implements Command {
} }
execute(project: Project): void { execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId); const object = requireObject(project, this.holderId, this.objectId);
const link = requireLink(project, this.sceneId, object); const link = requireLink(project, this.holderId, object);
this.memos = applyToTemplate(project, link, [overrideSlot(this.key)], [object.overrides[this.key]]); 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; private previous: PropertyValue | undefined;
constructor( constructor(
private readonly sceneId: string, private readonly holderId: string,
private readonly objectId: string, private readonly objectId: string,
private readonly key: string, private readonly key: string,
) { ) {
@@ -152,14 +125,14 @@ export class RevertOverrideToTemplateCommand implements Command {
} }
execute(project: Project): void { execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId); const object = requireObject(project, this.holderId, this.objectId);
const link = requireLink(project, this.sceneId, object); const link = requireLink(project, this.holderId, object);
this.previous = object.overrides[this.key]; this.previous = object.overrides[this.key];
setBagValue(object.overrides, this.key, link.template.overrides[this.key]); setBagValue(object.overrides, this.key, link.template.overrides[this.key]);
} }
undo(project: Project): void { 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<number>[] = []; private memos: SlotMemo<number>[] = [];
constructor( constructor(
private readonly sceneId: string, private readonly holderId: string,
private readonly objectId: string, private readonly objectId: string,
private readonly axis?: Axis, private readonly axis?: Axis,
) { ) {
@@ -177,8 +150,8 @@ export class ApplyPositionToContainerCommand implements Command {
} }
execute(project: Project): void { execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId); const object = requireObject(project, this.holderId, this.objectId);
const link = requireLink(project, this.sceneId, object); const link = requireLink(project, this.holderId, object);
const axes = this.axis ? [this.axis] : ALL_AXES; const axes = this.axis ? [this.axis] : ALL_AXES;
this.memos = applyToTemplate( this.memos = applyToTemplate(
project, project,
@@ -198,7 +171,7 @@ export class RevertPositionToTemplateCommand implements Command {
private previous: Position = { x: 0, y: 0, z: 0 }; private previous: Position = { x: 0, y: 0, z: 0 };
constructor( constructor(
private readonly sceneId: string, private readonly holderId: string,
private readonly objectId: string, private readonly objectId: string,
private readonly axis?: Axis, private readonly axis?: Axis,
) { ) {
@@ -206,13 +179,13 @@ export class RevertPositionToTemplateCommand implements Command {
} }
execute(project: Project): void { execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId); const object = requireObject(project, this.holderId, this.objectId);
const link = requireLink(project, this.sceneId, object); const link = requireLink(project, this.holderId, object);
this.previous = { ...object.position }; this.previous = { ...object.position };
for (const a of this.axis ? [this.axis] : ALL_AXES) object.position[a] = link.template.position[a]; for (const a of this.axis ? [this.axis] : ALL_AXES) object.position[a] = link.template.position[a];
} }
undo(project: Project): void { undo(project: Project): void {
requireObject(project, this.sceneId, this.objectId).position = { ...this.previous }; requireObject(project, this.holderId, this.objectId).position = { ...this.previous };
} }
} }
+7 -7
View File
@@ -6,7 +6,7 @@ import { removeById } from '../util/list';
import { buildAsset } from './assetCommands'; import { buildAsset } from './assetCommands';
import { type Command, CommandError } from './Command'; import { type Command, CommandError } from './Command';
import { nextAssetName } from './naming'; 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 * 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(); readonly objectId = newId();
constructor( constructor(
private readonly sceneId: string, private readonly holderId: string,
private readonly targetId: string, private readonly targetId: string,
) {} ) {}
execute(project: Project): void { execute(project: Project): void {
const scene = requireScene(project, this.sceneId); const holder = requireHolder(project, this.holderId);
const target = requireObject(project, this.sceneId, this.targetId); const target = requireObject(project, this.holderId, this.targetId);
const targetAsset = requireAsset(project, target.assetId); const targetAsset = requireAsset(project, target.assetId);
if (targetAsset.type === 'collider') throw new CommandError('A collider cannot have a collider'); 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); asset.props.height = Number(props.height);
} }
project.assets.push(asset); 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 { undo(project: Project): void {
const scene = requireScene(project, this.sceneId); const holder = requireHolder(project, this.holderId);
scene.objects = removeById(scene.objects, this.objectId); holder.objects = removeById(holder.objects, this.objectId);
project.assets = removeById(project.assets, this.assetId); project.assets = removeById(project.assets, this.assetId);
} }
} }
+18 -18
View File
@@ -9,13 +9,13 @@ import {
worldPosition, worldPosition,
} from '../model/hierarchy'; } from '../model/hierarchy';
import type { Position } from '../model/Properties'; 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 { createSceneObject } from '../model/SceneObject';
import { removeById } from '../util/list'; import { removeById } from '../util/list';
import { buildAsset } from './assetCommands'; import { buildAsset } from './assetCommands';
import { type Command, CommandError } from './Command'; import { type Command, CommandError } from './Command';
import { nextAssetName } from './naming'; 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 * 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 }[] = []; private previous: { id: string; parentId: string; position: Position; templateId: string }[] = [];
constructor( constructor(
private readonly sceneId: string, private readonly holderId: string,
private readonly objectIds: readonly string[], private readonly objectIds: readonly string[],
) {} ) {}
execute(project: Project): void { execute(project: Project): void {
const scene = requireScene(project, this.sceneId); const holder = requireHolder(project, this.holderId);
const selected = [...new Set(this.objectIds)].map((id) => requireObject(project, this.sceneId, id)); 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'); 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 = { const centre: Position = {
x: Math.round((Math.min(...worlds.map((p) => p.x)) + Math.max(...worlds.map((p) => p.x))) / 2), 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), 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. // Members are collected in tree order, so a parent is always built before its children.
const roots = new Set( const roots = new Set(
subtreeRoots( subtreeRoots(
scene, holder,
selected.map((o) => o.id), selected.map((o) => o.id),
), ),
); );
const memberIds = subtreeIds(scene, roots); const memberIds = subtreeIds(holder, roots);
const members = treeOrder(scene) const members = treeOrder(holder)
.map((e) => e.object) .map((e) => e.object)
.filter((o) => memberIds.has(o.id)); .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 containerParentId = rootParents.size === 1 ? [...rootParents][0]! : '';
const asset = buildAsset( const asset = buildAsset(
@@ -68,7 +68,7 @@ export class CreateContainerCommand implements Command {
const templateIds = new Map(members.map((o) => [o.id, newId()])); const templateIds = new Map(members.map((o) => [o.id, newId()]));
for (const object of members) { for (const object of members) {
const isRoot = roots.has(object.id); const isRoot = roots.has(object.id);
const world = worldPosition(scene, object); const world = worldPosition(holder, object);
asset.objects.push({ asset.objects.push({
id: templateIds.get(object.id)!, id: templateIds.get(object.id)!,
assetId: object.assetId, assetId: object.assetId,
@@ -93,32 +93,32 @@ export class CreateContainerCommand implements Command {
const container = createSceneObject( const container = createSceneObject(
this.objectId, this.objectId,
this.assetId, this.assetId,
localPositionFor(scene, containerParentId, centre), localPositionFor(holder, containerParentId, centre),
containerParentId, containerParentId,
); );
for (const object of members) { for (const object of members) {
if (roots.has(object.id)) { if (roots.has(object.id)) {
const world = worldPosition(scene, object); const world = worldPosition(holder, object);
object.parentId = this.objectId; object.parentId = this.objectId;
object.position = { x: world.x - centre.x, y: world.y - centre.y, z: world.z }; object.position = { x: world.x - centre.x, y: world.y - centre.y, z: world.z };
} }
object.templateId = templateIds.get(object.id)!; object.templateId = templateIds.get(object.id)!;
} }
project.assets.push(asset); project.assets.push(asset);
scene.objects.push(container); holder.objects.push(container);
} }
undo(project: Project): void { undo(project: Project): void {
const scene = findScene(project, this.sceneId); const holder = findObjectHolder(project, this.holderId);
if (scene) { if (holder) {
for (const entry of this.previous) { for (const entry of this.previous) {
const object = findObject(scene, entry.id); const object = findObject(holder, entry.id);
if (!object) continue; if (!object) continue;
object.parentId = entry.parentId; object.parentId = entry.parentId;
object.position = { ...entry.position }; object.position = { ...entry.position };
object.templateId = entry.templateId; 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); project.assets = removeById(project.assets, this.assetId);
} }
+9 -3
View File
@@ -3,7 +3,7 @@ import { findNode, type GraphNode } from '../graph/Graph';
import { type NodeDef, nodeDef } from '../graph/NodeRegistry'; import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
import type { Asset, BehaviorAsset, SceneAsset } from '../model/Asset'; import type { Asset, BehaviorAsset, SceneAsset } from '../model/Asset';
import { findObject } from '../model/hierarchy'; 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 type { SceneObject } from '../model/SceneObject';
import { propDef, type PropertyDef } from '../properties/PropertySchema'; import { propDef, type PropertyDef } from '../properties/PropertySchema';
import { CommandError } from './Command'; import { CommandError } from './Command';
@@ -20,8 +20,14 @@ export function requireScene(project: Project, sceneId: string): SceneAsset {
return scene; return scene;
} }
export function requireObject(project: Project, sceneId: string, objectId: string): SceneObject { export function requireHolder(project: Project, holderId: string): ObjectHolder {
const object = findObject(requireScene(project, sceneId), objectId); 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`); if (!object) throw new CommandError(`Scene object ${objectId} not found`);
return object; return object;
} }
+89 -40
View File
@@ -1,6 +1,6 @@
import { newId } from '../ids'; import { newId } from '../ids';
import { isPlaceableType } from '../model/AssetTypes'; 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 { isSelfOrAncestor, localPositionFor, removeSubtrees, worldPosition } from '../model/hierarchy';
import { import {
type Position, type Position,
@@ -14,12 +14,23 @@ import { createSceneObject, type SceneObject } from '../model/SceneObject';
import { coerceValue } from '../properties/PropertySchema'; import { coerceValue } from '../properties/PropertySchema';
import { type Removed, restoreAt } from '../util/list'; import { type Removed, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command'; 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 { 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. * 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 { export class PlaceAssetCommand implements Command {
readonly label = 'Place asset'; readonly label = 'Place asset';
@@ -27,28 +38,35 @@ export class PlaceAssetCommand implements Command {
private createdIds: string[] = []; private createdIds: string[] = [];
constructor( constructor(
private readonly sceneId: string, private readonly holderId: string,
private readonly assetId: string, private readonly assetId: string,
private readonly position: Vec2 | Position, private readonly position: Vec2 | Position,
private readonly parentId = '', private readonly parentId = '',
) {} ) {}
execute(project: Project): void { execute(project: Project): void {
const scene = requireScene(project, this.sceneId); const holder = requireHolder(project, this.holderId);
const asset = requireAsset(project, this.assetId); const asset = requireAsset(project, this.assetId);
if (!isPlaceableType(asset.type)) throw new CommandError('A scene cannot be placed into a scene'); if (!isPlaceableType(asset.type)) throw new CommandError('A scene cannot be placed into a scene');
if (this.parentId) requireObject(project, this.sceneId, this.parentId); if (
const local = localPositionFor(scene, this.parentId, toPosition(this.position)); 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)]; 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); this.createdIds = created.map((o) => o.id);
scene.objects.push(...created); holder.objects.push(...created);
} }
undo(project: Project): void { undo(project: Project): void {
const scene = requireScene(project, this.sceneId); const holder = requireHolder(project, this.holderId);
const ids = new Set(this.createdIds); 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<SceneObject>[] = []; private removed: Removed<SceneObject>[] = [];
constructor( constructor(
private readonly sceneId: string, private readonly holderId: string,
private readonly objectIds: readonly string[], private readonly objectIds: readonly string[],
) { ) {
this.label = objectIds.length === 1 ? 'Remove object' : 'Remove objects'; this.label = objectIds.length === 1 ? 'Remove object' : 'Remove objects';
} }
execute(project: Project): void { execute(project: Project): void {
for (const id of this.objectIds) requireObject(project, this.sceneId, id); for (const id of this.objectIds) requireObject(project, this.holderId, id);
this.removed = removeSubtrees(requireScene(project, this.sceneId), this.objectIds); this.removed = removeSubtrees(requireHolder(project, this.holderId), this.objectIds);
} }
undo(project: Project): void { 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 { export class RemoveSceneObjectCommand extends RemoveSceneObjectsCommand {
constructor(sceneId: string, objectId: string) { constructor(holderId: string, objectId: string) {
super(sceneId, [objectId]); super(holderId, [objectId]);
} }
} }
@@ -87,21 +105,30 @@ export class RemoveSceneObjectCommand extends RemoveSceneObjectsCommand {
export class MoveSceneObjectCommand implements Command { export class MoveSceneObjectCommand implements Command {
readonly label = 'Move object'; readonly label = 'Move object';
private previous: Position = { x: 0, y: 0, z: 0 }; private previous: Position = { x: 0, y: 0, z: 0 };
private memos: SlotMemo<number>[] = [];
constructor( constructor(
private readonly sceneId: string, private readonly holderId: string,
private readonly objectId: string, private readonly objectId: string,
private readonly position: Position, private readonly position: Position,
) {} ) {}
execute(project: Project): void { execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId); const object = requireObject(project, this.holderId, this.objectId);
this.previous = { ...object.position }; this.previous = { ...object.position };
object.position = { ...this.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 { 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 }; private previous: Vec2 = { x: 0, y: 0 };
constructor( constructor(
private readonly sceneId: string, private readonly holderId: string,
private readonly objectId: string, private readonly objectId: string,
private readonly offset: Vec2, private readonly offset: Vec2,
) {} ) {}
execute(project: Project): void { execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId); const object = requireObject(project, this.holderId, this.objectId);
this.previous = { ...object.editorOffset }; this.previous = { ...object.editorOffset };
object.editorOffset = { x: this.offset.x, y: this.offset.y }; object.editorOffset = { x: this.offset.x, y: this.offset.y };
} }
undo(project: Project): void { 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 { export class RenameSceneObjectCommand implements Command {
readonly label = 'Rename object'; readonly label = 'Rename object';
private previous = ''; private previous = '';
private memos: SlotMemo<string>[] = [];
constructor( constructor(
private readonly sceneId: string, private readonly holderId: string,
private readonly objectId: string, private readonly objectId: string,
private readonly name: string, private readonly name: string,
) {} ) {}
execute(project: Project): void { execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId); const object = requireObject(project, this.holderId, this.objectId);
this.previous = object.name; this.previous = object.name;
object.name = this.name; object.name = this.name;
this.memos = syncTemplateEdit(project, this.holderId, object, [nameSlot], [this.previous]);
} }
undo(project: Project): void { 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<SceneObject> { abstract class OverrideCommand extends SetBagValueCommand<SceneObject> {
private memos: SlotMemo<PropertyValue | undefined>[] = [];
protected constructor( protected constructor(
private readonly sceneId: string, private readonly holderId: string,
private readonly objectId: string, private readonly objectId: string,
key: string, key: string,
label: string, label: string,
@@ -159,22 +191,39 @@ abstract class OverrideCommand extends SetBagValueCommand<SceneObject> {
} }
protected override resolveTarget(project: Project): SceneObject { 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 { protected override bagOf(object: SceneObject): PropertyBag {
return object.overrides; 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 { export class SetOverrideCommand extends OverrideCommand {
constructor( constructor(
sceneId: string, holderId: string,
objectId: string, objectId: string,
key: string, key: string,
private readonly value: PropertyValue, private readonly value: PropertyValue,
) { ) {
super(sceneId, objectId, key, `Override ${key}`); super(holderId, objectId, key, `Override ${key}`);
} }
protected override computeNext(object: SceneObject, project: Project): PropertyValue { protected override computeNext(object: SceneObject, project: Project): PropertyValue {
@@ -183,8 +232,8 @@ export class SetOverrideCommand extends OverrideCommand {
} }
export class ClearOverrideCommand extends OverrideCommand { export class ClearOverrideCommand extends OverrideCommand {
constructor(sceneId: string, objectId: string, key: string) { constructor(holderId: string, objectId: string, key: string) {
super(sceneId, objectId, key, `Reset ${key}`); super(holderId, objectId, key, `Reset ${key}`);
} }
// No schema check, unlike setting one: a key the asset no longer declares is exactly what one // 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( constructor(
private readonly sceneId: string, private readonly holderId: string,
private readonly objectId: string, private readonly objectId: string,
private readonly parentId: string, private readonly parentId: string,
) {} ) {}
execute(project: Project): void { execute(project: Project): void {
const scene = requireScene(project, this.sceneId); const holder = requireHolder(project, this.holderId);
const object = requireObject(project, this.sceneId, this.objectId); const object = requireObject(project, this.holderId, this.objectId);
if (this.parentId) { if (this.parentId) {
if (this.parentId === this.objectId) throw new CommandError('An object cannot be its own parent'); if (this.parentId === this.objectId) throw new CommandError('An object cannot be its own parent');
requireObject(project, this.sceneId, this.parentId); requireObject(project, this.holderId, this.parentId);
if (isSelfOrAncestor(scene, this.objectId, this.parentId)) if (isSelfOrAncestor(holder, this.objectId, this.parentId))
throw new CommandError('An object cannot be parented to one of its own children'); throw new CommandError('An object cannot be parented to one of its own children');
} }
this.previous = { parentId: object.parentId, position: { ...object.position } }; this.previous = { parentId: object.parentId, position: { ...object.position } };
const world = worldPosition(scene, object); const world = worldPosition(holder, object);
object.parentId = this.parentId; object.parentId = this.parentId;
object.position = localPositionFor(scene, this.parentId, world); object.position = localPositionFor(holder, this.parentId, world);
} }
undo(project: Project): void { 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.parentId = this.previous.parentId;
object.position = { ...this.previous.position }; object.position = { ...this.previous.position };
} }
+89
View File
@@ -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<T> {
read(object: SceneObject): T;
write(object: SceneObject, value: T): void;
}
export interface SlotMemo<T> {
object: SceneObject;
slot: Slot<T>;
previous: T;
}
export const overrideSlot = (key: string): Slot<PropertyValue | undefined> => ({
read: (object) => object.overrides[key],
write: (object, value) => setBagValue(object.overrides, key, value),
});
export const positionSlot = (axis: Axis): Slot<number> => ({
read: (object) => object.position[axis],
write: (object, value) => {
object.position[axis] = value;
},
});
export const nameSlot: Slot<string> = {
read: (object) => object.name,
write: (object, value) => {
object.name = value;
},
};
/** Writes `after` into every copy of `template` whose slot still reads `before`. */
export function syncInstances<T>(
project: Project,
template: SceneObject,
slots: readonly Slot<T>[],
before: readonly T[],
after: readonly T[],
): SlotMemo<T>[] {
const memos: SlotMemo<T>[] = [];
for (const { object } of instancesOf(project, template.id)) {
slots.forEach((slot, i) => {
const current = slot.read(object);
if (current !== before[i]) return;
memos.push({ object, slot, previous: current });
slot.write(object, after[i]!);
});
}
return memos;
}
/**
* Carries an edit of a container template object to the placed copies; nothing to do when the
* holder is a scene, where an edit is meant for that one object. `before` has to be read off the
* object before it is written, the new values are read back from it here.
*/
export function syncTemplateEdit<T>(
project: Project,
holderId: string,
object: SceneObject,
slots: readonly Slot<T>[],
before: readonly T[],
): SlotMemo<T>[] {
if (findObjectHolder(project, holderId)?.type !== 'container') return [];
return syncInstances(
project,
object,
slots,
before,
slots.map((slot) => slot.read(object)),
);
}
export function restoreSlots<T>(memos: readonly SlotMemo<T>[]): void {
for (const memo of [...memos].reverse()) memo.slot.write(memo.object, memo.previous);
}
+19 -1
View File
@@ -37,8 +37,26 @@ export function listScenes(project: Project): SceneAsset[] {
*/ */
export type ObjectHolder = SceneAsset | ContainerAsset; 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[] { 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 { export interface SceneObjectRef {
+21
View File
@@ -37,6 +37,27 @@ export function instancesOf(project: Project, templateId: string): SceneObjectRe
return objectsWhere(project, (o) => o.templateId === templateId); 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; const MAX_NESTING = 8;
/** /**
+44 -10
View File
@@ -1,13 +1,15 @@
import type { Application } from 'pixi.js'; import type { Application } from 'pixi.js';
import { findAsset, findObjectHolder } from '../core/model/Project';
import type { ProjectStore } from '../core/store/ProjectStore'; import type { ProjectStore } from '../core/store/ProjectStore';
import type { TextureStore } from '../core/store/TextureStore'; import type { TextureStore } from '../core/store/TextureStore';
import { Button, HStack, Splitter, Theme, ui, VStack } from '../ui'; import { Button, HStack, Splitter, Theme, ui, VStack } from '../ui';
import { GameViewport } from '../viewport/GameViewport'; import { GameViewport } from '../viewport/GameViewport';
import { SceneViewport } from '../viewport/SceneViewport'; import { SceneViewport } from '../viewport/SceneViewport';
import { attachAutosave } from './autosave'; import { attachAutosave } from './autosave';
import { ContainerEditor } from './container/ContainerEditor';
import { GraphEditor } from './graph/GraphEditor'; import { GraphEditor } from './graph/GraphEditor';
import { Inspector } from './inspector/Inspector'; import { Inspector } from './inspector/Inspector';
import { AssetPanel } from './panels/AssetPanel'; import { AssetPanel, type PlacementTarget } from './panels/AssetPanel';
import { HierarchyPanel } from './panels/HierarchyPanel'; import { HierarchyPanel } from './panels/HierarchyPanel';
import { Toolbar } from './panels/Toolbar'; import { Toolbar } from './panels/Toolbar';
import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession'; import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession';
@@ -17,9 +19,9 @@ import type { EditorState } from './state';
import { TextureEditor } from './texture/TextureEditor'; import { TextureEditor } from './texture/TextureEditor';
import { installTextureImport } from './textureImport'; 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 MIN_LEFT_WIDTH = 160;
const MAX_LEFT_WIDTH = 500; const MAX_LEFT_WIDTH = 500;
@@ -38,6 +40,7 @@ export class Editor implements PlayHost {
readonly gameView: GameViewport; readonly gameView: GameViewport;
readonly graphEditor: GraphEditor; readonly graphEditor: GraphEditor;
readonly textureEditor: TextureEditor; readonly textureEditor: TextureEditor;
readonly containerEditor: ContainerEditor;
readonly hierarchy: HierarchyPanel; readonly hierarchy: HierarchyPanel;
tab: CentreTab = 'scene'; tab: CentreTab = 'scene';
/** Frame rate, refreshed twice a second; null until the first measurement. */ /** Frame rate, refreshed twice a second; null until the first measurement. */
@@ -60,7 +63,7 @@ export class Editor implements PlayHost {
constructor( constructor(
app: Application, app: Application,
root: VStack, root: VStack,
state: EditorState, private readonly state: EditorState,
store: ProjectStore, store: ProjectStore,
textures: TextureStore, textures: TextureStore,
) { ) {
@@ -68,6 +71,7 @@ export class Editor implements PlayHost {
this.gameView = new GameViewport(state); this.gameView = new GameViewport(state);
this.graphEditor = new GraphEditor(state); this.graphEditor = new GraphEditor(state);
this.textureEditor = new TextureEditor(state); this.textureEditor = new TextureEditor(state);
this.containerEditor = new ContainerEditor(state);
this.hierarchy = new HierarchyPanel(state); this.hierarchy = new HierarchyPanel(state);
this.inspector = new Inspector(state); this.inspector = new Inspector(state);
this.play = new PlaySession(state, this); this.play = new PlaySession(state, this);
@@ -87,7 +91,7 @@ export class Editor implements PlayHost {
this.workspace = new HStack(); this.workspace = new HStack();
this.leftColumn = new VStack({ gap: 1 }); 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 }); installTextureImport(app.canvas, { state, textures });
this.leftColumn.add(this.hierarchy, 1); this.leftColumn.add(this.hierarchy, 1);
@@ -103,6 +107,11 @@ export class Editor implements PlayHost {
width: 90, width: 90,
onClick: () => this.showTab('texture'), 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]); for (const tab of TAB_ORDER) tabs.add(this.tabButtons[tab]);
this.centre.add(tabs); this.centre.add(tabs);
@@ -110,9 +119,11 @@ export class Editor implements PlayHost {
this.centre.add(this.graphEditor, 1); this.centre.add(this.graphEditor, 1);
this.centre.add(this.gameView, 1); this.centre.add(this.gameView, 1);
this.centre.add(this.textureEditor, 1); this.centre.add(this.textureEditor, 1);
this.centre.add(this.containerEditor, 1);
this.graphEditor.visible = false; this.graphEditor.visible = false;
this.gameView.visible = false; this.gameView.visible = false;
this.textureEditor.visible = false; this.textureEditor.visible = false;
this.containerEditor.visible = false;
this.tabButtons.scene.setToggled(true); this.tabButtons.scene.setToggled(true);
this.leftColumn.minWidth = this.leftWidth; this.leftColumn.minWidth = this.leftWidth;
@@ -138,12 +149,18 @@ export class Editor implements PlayHost {
attachAutosave(state, store); attachAutosave(state, store);
installShortcuts(state, { play: () => this.play.play(), stop: () => this.play.stop() }); installShortcuts(state, { play: () => this.play.play(), stop: () => this.play.stop() });
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000)); 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; // A node, a frame and a template object exist only in their own tab, so selecting one
// selecting the behavior or texture asset itself does not. // switches to it; selecting the behavior, texture or container asset itself does not.
state.on('selection', () => { state.on('selection', () => {
const kind = state.selection?.kind; const s = state.selection;
if (kind === 'node' && this.tab !== 'graph') this.showTab('graph'); if (s?.kind === 'node' && this.tab !== 'graph') this.showTab('graph');
if (kind === 'frame' && this.tab !== 'texture') this.showTab('texture'); 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()); state.on('project', () => this.play.syncFromModel());
} }
@@ -154,11 +171,28 @@ export class Editor implements PlayHost {
this.graphEditor.visible = tab === 'graph'; this.graphEditor.visible = tab === 'graph';
this.gameView.visible = tab === 'game'; this.gameView.visible = tab === 'game';
this.textureEditor.visible = tab === 'texture'; 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); for (const key of TAB_ORDER) this.tabButtons[key].setToggled(key === tab);
this.centre.relayout(); this.centre.relayout();
ui().focus.validate(); 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<void> { boot(): Promise<void> {
return this.projects.boot(); return this.projects.boot();
} }
+8 -8
View File
@@ -12,24 +12,24 @@ import { Dialog } from '../ui';
import { type EditorState, objectSelection } from './state'; import { type EditorState, objectSelection } from './state';
/** Groups the objects into a new container asset placed in their stead; selects the container. */ /** 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 { export function createContainer(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
const command = new CreateContainerCommand(sceneId, objectIds); const command = new CreateContainerCommand(holderId, objectIds);
if (!state.execute(command)) return false; if (!state.execute(command)) return false;
state.select(objectSelection(sceneId, [command.objectId])); state.select(objectSelection(holderId, [command.objectId]));
return true; return true;
} }
/** Gives the object a collider child sized like it; selects the collider. */ /** Gives the object a collider child sized like it; selects the collider. */
export function addCollider(state: EditorState, sceneId: string, objectId: string): boolean { export function addCollider(state: EditorState, holderId: string, objectId: string): boolean {
const command = new AddColliderCommand(sceneId, objectId); const command = new AddColliderCommand(holderId, objectId);
if (!state.execute(command)) return false; if (!state.execute(command)) return false;
state.select(objectSelection(sceneId, [command.objectId])); state.select(objectSelection(holderId, [command.objectId]));
return true; return true;
} }
/** Removes the objects (with their subtrees) as one undo step. */ /** Removes the objects (with their subtrees) as one undo step. */
export function removeObjects(state: EditorState, sceneId: string, objectIds: readonly string[]): boolean { export function removeObjects(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
return state.execute(new RemoveSceneObjectsCommand(sceneId, objectIds)); return state.execute(new RemoveSceneObjectsCommand(holderId, objectIds));
} }
/** Asks before deleting an asset, spelling out what goes with it. */ /** Asks before deleting an asset, spelling out what goes with it. */
+65
View File
@@ -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 });
}
}
+124
View File
@@ -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);
}
}
+9 -1
View File
@@ -8,7 +8,7 @@ import type { EditorState } from './state';
* visible text and to read editor state. * visible text and to read editor state.
*/ */
export function installDebugHook(app: Application, state: EditorState, editor: Editor): void { 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 centreOf = (widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }) => {
const p = widget.getGlobalPosition(); const p = widget.getGlobalPosition();
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 }; 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). */ /** 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), 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(), world: () => editor.worldSnapshot(),
/** Rows the hierarchy shows – collapsed subtrees are not in here. */ /** Rows the hierarchy shows – collapsed subtrees are not in here. */
hierarchyOrder: () => hierarchy.rowOrder(), hierarchyOrder: () => hierarchy.rowOrder(),
+4 -4
View File
@@ -72,8 +72,8 @@ export class Inspector extends Panel {
else if (s.kind === 'asset') buildAssetSection(this.host, s.assetId); 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 === 'node') buildNodeSection(this.host, s.assetId, s.nodeId);
else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId); 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 if (s.objectIds.length > 1) buildObjectsSection(this.host, s.holderId, s.objectIds);
else buildObjectSection(this.host, s.sceneId, s.objectId); else buildObjectSection(this.host, s.holderId, s.objectId);
this.scroll.setContent(this.host.body); 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 === 'asset') return assetSignature(this.state.project, s.assetId);
if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId); 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); 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, …). */ /** 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) ? refreshNodeSection(this.host, s.assetId, s.nodeId)
: s.kind === 'frame' : s.kind === 'frame'
? refreshFrameSection(this.host, s.assetId, s.frameId) ? 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(); if (!ok) this.rebuild();
} }
} }
+73 -73
View File
@@ -14,11 +14,11 @@ import {
SetOverrideCommand, SetOverrideCommand,
SetParentCommand, SetParentCommand,
} from '../../core/commands/sceneObjectCommands'; } 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 { type TemplateLink, templateLinkOf } from '../../core/model/container';
import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy'; import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
import type { Axis, PropertyBag, PropertyValue } from '../../core/model/Properties'; 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 type { SceneObject } from '../../core/model/SceneObject';
import { import {
COMMON_PROPS, 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. */ /** Structure-defining facts of an object selection: parent, template link and schema. */
export function objectSignature( export function objectSignature(
project: Project, project: Project,
sceneId: string, holderId: string,
objectIds: readonly string[], objectIds: readonly string[],
objectId: string, objectId: string,
): string { ): string {
if (objectIds.length > 1) return `multi|${objectIds.join(',')}`; if (objectIds.length > 1) return `multi|${objectIds.join(',')}`;
const scene = findScene(project, sceneId); const holder = findObjectHolder(project, holderId);
const object = scene && findObject(scene, objectId); const object = holder && findObject(holder, objectId);
const asset = object && findAsset(project, object.assetId); const asset = object && findAsset(project, object.assetId);
if (!object || !asset) return 'missing'; if (!object || !asset) return 'missing';
return ( return (
@@ -64,44 +64,44 @@ export function objectSignature(
} }
/** Several objects: what they are, and the actions that apply to all of them at once. */ /** 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 { export function buildObjectsSection(host: InspectorHost, holderId: string, objectIds: string[]): void {
const scene = findScene(host.state.project, sceneId); const holder = findObjectHolder(host.state.project, holderId);
if (!scene) return; if (!holder) return;
addTitle(host, `${objectIds.length} OBJECTS SELECTED`); addTitle(host, `${objectIds.length} OBJECTS SELECTED`);
for (const id of objectIds) { for (const id of objectIds) {
const object = findObject(scene, id); const object = findObject(holder, id);
const asset = object && findAsset(host.state.project, object.assetId); const asset = object && findAsset(host.state.project, object.assetId);
if (object && asset) addNote(host, `${object.name || asset.name} · ${asset.type}`); if (object && asset) addNote(host, `${object.name || asset.name} · ${asset.type}`);
} }
addGap(host); addGap(host);
addNote(host, 'Ctrl-click adds or removes objects.', 'small'); 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); addGap(host);
host.body.add( 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, 'Groups the selection into a reusable container', 'small');
addNote(host, 'asset placed at its centre (Ctrl+G).', 'small'); addNote(host, 'asset placed at its centre (Ctrl+G).', 'small');
addGap(host); addGap(host);
host.body.add( host.body.add(
new Button('Remove from scene', { new Button('Remove', {
variant: 'danger', 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. */ /** 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 { export function buildObjectSection(host: InspectorHost, holderId: string, objectId: string): void {
const scene = findScene(host.state.project, sceneId); const holder = findObjectHolder(host.state.project, holderId);
const object = scene && findObject(scene, objectId); const object = holder && findObject(holder, objectId);
const asset = object && findAsset(host.state.project, object.assetId); 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`); addTitle(host, `${asset.type.toUpperCase()} OBJECT`);
host.nameField = new TextField(object.name, { host.nameField = new TextField(object.name, {
placeholder: asset.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'); const nameRow = labelledRow('Name');
nameRow.row.add(host.nameField, 1); 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(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. // 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) { if (link) {
const linkRow = labelledRow('Part of'); const linkRow = labelledRow('Part of');
linkRow.row.add( 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. // The master switch, right where it is looked for: above everything type-specific.
for (const def of COMMON_PROPS) 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'); addTitle(host, object.parentId ? 'POSITION (LOCAL TO PARENT)' : 'POSITION');
for (const axis of POSITION_AXES) { for (const axis of POSITION_AXES) {
const moved = () => positionMoved(host.state, sceneId, objectId, axis); const moved = () => positionMoved(host.state, holderId, objectId, axis);
componentRow(host, sceneId, objectId, object.position[axis], { componentRow(host, holderId, objectId, object.position[axis], {
key: axis, key: axis,
label: axis.toUpperCase(), label: axis.toUpperCase(),
axis, axis,
read: (o) => o.position, read: (o) => o.position,
write: (o, v) => (o.position = v), write: (o, v) => (o.position = v),
command: (v) => new MoveSceneObjectCommand(sceneId, objectId, v), command: (v) => new MoveSceneObjectCommand(holderId, objectId, v),
reset: () => reset: () =>
moved() moved()
? [ ? [
{ {
label: 'Reset to container', label: 'Reset to container',
onSelect: () => 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', label: 'Apply to container',
onSelect: () => 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)'); addTitle(host, 'EDITOR OFFSET (SCENE VIEW ONLY)');
for (const axis of OFFSET_AXES) { for (const axis of OFFSET_AXES) {
componentRow(host, sceneId, objectId, object.editorOffset[axis], { componentRow(host, holderId, objectId, object.editorOffset[axis], {
key: offsetKey(axis), key: offsetKey(axis),
label: `Offset ${axis.toUpperCase()}`, label: `Offset ${axis.toUpperCase()}`,
axis, axis,
read: (o) => o.editorOffset, read: (o) => o.editorOffset,
write: (o, v) => (o.editorOffset = v), 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. // No apply: the offset never leaves the placed object.
reset: () => { reset: () => {
const current = currentObject(host.state, sceneId, objectId); const current = currentObject(host.state, holderId, objectId);
if (!current || current.editorOffset[axis] === 0) return []; if (!current || current.editorOffset[axis] === 0) return [];
return [ return [
{ {
label: 'Reset to 0', label: 'Reset to 0',
onSelect: () => onSelect: () =>
host.state.execute( 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') if (own.length > 0 || asset.type === 'behavior')
addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES'); addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.'); 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); markObjectRows(host, object, asset, link);
if (asset.type === 'container') { if (asset.type === 'container') {
@@ -219,24 +219,24 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
host.body.add( host.body.add(
new Button('+ Add collider', { new Button('+ Add collider', {
variant: 'ghost', 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). */ /** 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 { export function refreshObjectSection(host: InspectorHost, holderId: string, objectId: string): boolean {
const scene = findScene(host.state.project, sceneId); const holder = findObjectHolder(host.state.project, holderId);
const object = scene && findObject(scene, objectId); const object = holder && findObject(holder, objectId);
const asset = object && findAsset(host.state.project, object.assetId); 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); host.nameField?.setValue(object.name);
for (const axis of POSITION_AXES) host.fields.get(axis)?.setValue(object.position[axis]); 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]); for (const axis of OFFSET_AXES) host.fields.get(offsetKey(axis))?.setValue(object.editorOffset[axis]);
const values = resolveSceneObjectProps(asset, object); const values = resolveSceneObjectProps(asset, object);
for (const def of schemaForAsset(asset)) host.fields.get(def.key)?.setValue(values[def.key] ?? def.default); 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; return true;
} }
@@ -258,7 +258,7 @@ interface ComponentSpec<V extends object> {
*/ */
function componentRow<V extends object>( function componentRow<V extends object>(
host: InspectorHost, host: InspectorHost,
sceneId: string, holderId: string,
objectId: string, objectId: string,
value: number, value: number,
spec: ComponentSpec<V>, spec: ComponentSpec<V>,
@@ -269,7 +269,7 @@ function componentRow<V extends object>(
{ key: spec.key, label: spec.label, kind: 'number', default: 0, step: 1 }, { key: spec.key, label: spec.label, kind: 'number', default: 0, step: 1 },
value, value,
previewingHandlers<SceneObject, V>(host, { previewingHandlers<SceneObject, V>(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. // Copied: the snapshot outlives the scrub, the vector in the model does not.
read: (object) => ({ ...spec.read(object) }), read: (object) => ({ ...spec.read(object) }),
write: spec.write, write: spec.write,
@@ -283,7 +283,7 @@ function componentRow<V extends object>(
} }
/** /**
* 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). * 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 * Inside a container a value has two bases – the template it was instantiated from and, one
@@ -293,31 +293,31 @@ function componentRow<V extends object>(
*/ */
function propertyRow( function propertyRow(
host: InspectorHost, host: InspectorHost,
sceneId: string, holderId: string,
objectId: string, objectId: string,
owner: Asset, owner: Asset,
def: PropertyDef, def: PropertyDef,
values: Record<string, PropertyValue>, values: Record<string, PropertyValue>,
): FieldRow { ): FieldRow {
const bases = () => deviations(host.state, sceneId, objectId, def.key); const bases = () => deviations(host.state, holderId, objectId, def.key);
const row = createFieldRow( const row = createFieldRow(
host, host,
def, def,
values[def.key] ?? def.default, values[def.key] ?? def.default,
previewingHandlers<SceneObject>(host, { previewingHandlers<SceneObject>(host, {
target: () => currentObject(host.state, sceneId, objectId), target: () => currentObject(host.state, holderId, objectId),
read: (object) => object.overrides[def.key], read: (object) => object.overrides[def.key],
write: (object, value) => (object.overrides[def.key] = value), write: (object, value) => (object.overrides[def.key] = value),
remove: (object) => delete object.overrides[def.key], 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( extra: aspectActions(
host, host,
owner, owner,
def, def,
() => currentProps(host.state, sceneId, objectId), () => currentProps(host.state, holderId, objectId),
(value) => host.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value)), (value) => host.state.execute(new SetOverrideCommand(holderId, objectId, def.key, value)),
), ),
reset: () => { reset: () => {
const { template, asset } = bases(); const { template, asset } = bases();
@@ -327,7 +327,7 @@ function propertyRow(
{ {
label: 'Reset to container', label: 'Reset to container',
onSelect: () => 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', 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', label: 'Apply to container',
onSelect: () => 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', label: 'Apply to asset',
onSelect: () => 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: <name> ▾" – picks another object of the same scene, or none. The object's own * "Parent: <name> ▾" – picks another object of the same scene or container, or none. The object's own
* subtree is excluded (that would be a cycle). For behaviors the parent is the owner. * subtree is excluded (that would be a cycle). For behaviors the parent is the owner.
*/ */
function parentRow(host: InspectorHost, scene: SceneAsset, object: SceneObject) { function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObject) {
const { row } = labelledRow('Parent'); const { row } = labelledRow('Parent');
const nameOf = (o: SceneObject) => o.name || findAsset(host.state.project, o.assetId)?.name || '?'; 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'); const button = new Dropdown(parent ? nameOf(parent) : 'None');
button.onClick = () => { button.onClick = () => {
const items: MenuItem[] = [ 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)) { for (const { object: candidate, depth } of treeOrder(holder)) {
if (isSelfOrAncestor(scene, object.id, candidate.id)) continue; if (isSelfOrAncestor(holder, object.id, candidate.id)) continue;
const name = `${' '.repeat(depth)}${nameOf(candidate)}`; const name = `${' '.repeat(depth)}${nameOf(candidate)}`;
items.push({ items.push({
label: candidate.id === object.parentId ? `● ${name}` : name, 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); new Menu(items, { searchable: true }).openBelow(button);
}; };
row.add(button, 1); row.add(button, 1);
@@ -431,15 +431,15 @@ function markObjectRows(
*/ */
function deviations( function deviations(
state: InspectorHost['state'], state: InspectorHost['state'],
sceneId: string, holderId: string,
objectId: string, objectId: string,
key: string, key: string,
): { template: boolean; asset: boolean } { ): { template: boolean; asset: boolean } {
const scene = findScene(state.project, sceneId); const holder = findObjectHolder(state.project, holderId);
const object = scene && findObject(scene, objectId); const object = holder && findObject(holder, objectId);
const asset = object && findAsset(state.project, object.assetId); const asset = object && findAsset(state.project, object.assetId);
if (!scene || !object || !asset) return { template: false, asset: false }; if (!holder || !object || !asset) return { template: false, asset: false };
const link = templateLinkOf(state.project, scene, object); const link = templateLinkOf(state.project, holder, object);
return { return {
template: Boolean(link) && deviatesFromTemplate(object, link!.template, key), template: Boolean(link) && deviatesFromTemplate(object, link!.template, key),
asset: deviatesFromAsset(object, asset, 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. */ /** Whether the object sits somewhere else on `axis` than its container template says. */
function positionMoved( function positionMoved(
state: InspectorHost['state'], state: InspectorHost['state'],
sceneId: string, holderId: string,
objectId: string, objectId: string,
axis: Axis, axis: Axis,
): boolean { ): boolean {
const scene = findScene(state.project, sceneId); const holder = findObjectHolder(state.project, holderId);
const object = scene && findObject(scene, objectId); const object = holder && findObject(holder, objectId);
const link = scene && object && templateLinkOf(state.project, scene, object); const link = holder && object && templateLinkOf(state.project, holder, object);
return Boolean(link) && object!.position[axis] !== link!.template.position[axis]; return Boolean(link) && object!.position[axis] !== link!.template.position[axis];
} }
function currentObject( function currentObject(
state: InspectorHost['state'], state: InspectorHost['state'],
sceneId: string, holderId: string,
objectId: string, objectId: string,
): SceneObject | undefined { ): SceneObject | undefined {
const scene = findScene(state.project, sceneId); const holder = findObjectHolder(state.project, holderId);
return scene && findObject(scene, objectId); return holder && findObject(holder, objectId);
} }
/** The values the object shows right now, asset defaults and overrides merged. */ /** The values the object shows right now, asset defaults and overrides merged. */
function currentProps(state: InspectorHost['state'], sceneId: string, objectId: string): PropertyBag | null { function currentProps(state: InspectorHost['state'], holderId: string, objectId: string): PropertyBag | null {
const object = currentObject(state, sceneId, objectId); const object = currentObject(state, holderId, objectId);
const asset = object && findAsset(state.project, object.assetId); const asset = object && findAsset(state.project, object.assetId);
return object && asset ? resolveSceneObjectProps(asset, object) : null; return object && asset ? resolveSceneObjectProps(asset, object) : null;
} }
+25 -12
View File
@@ -3,13 +3,22 @@ import { nextAssetName } from '../../core/commands/naming';
import { PlaceAssetCommand } from '../../core/commands/sceneObjectCommands'; import { PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType, hasColorSwatch } from '../../core/model/AssetTypes'; import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType, hasColorSwatch } from '../../core/model/AssetTypes';
import type { Vec2 } from '../../core/model/Properties'; 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 type { TextureStore } from '../../core/store/TextureStore';
import { Button, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui'; import { Button, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui';
import { confirmDeleteAsset } from '../actions'; import { confirmDeleteAsset } from '../actions';
import { type EditorState, objectSelection } from '../state'; import { type EditorState, objectSelection } from '../state';
import { pickAndImport } from '../textureImport'; 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. */ /** Lists all assets of the project; creates, places and deletes them. */
export class AssetPanel extends Panel { export class AssetPanel extends Panel {
private readonly list = new ListView(); private readonly list = new ListView();
@@ -17,7 +26,7 @@ export class AssetPanel extends Panel {
constructor( constructor(
private readonly state: EditorState, 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. */ /** Where imported images go. Without it the Texture entry has nothing to upload to. */
private readonly textures: TextureStore | null = null, private readonly textures: TextureStore | null = null,
) { ) {
@@ -92,18 +101,19 @@ export class AssetPanel extends Panel {
} }
private place(assetId: string): void { private place(assetId: string): void {
const scene = this.state.activeScene; const target = this.target();
if (!scene) { if (!target) {
this.state.setNotice('Create a scene first, then place assets into it.'); this.state.setNotice('Nothing to place into – create a scene, or open a container first.');
return; return;
} }
const command = new PlaceAssetCommand(scene.id, assetId, this.placementPoint()); const { holder, at } = target;
if (this.state.execute(command)) this.state.select(objectSelection(scene.id, [command.objectId])); 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); const asset = findAsset(this.state.project, id);
if (!asset) return; if (!asset) return undefined;
const items: MenuItem[] = []; const items: MenuItem[] = [];
if (asset.type === 'scene') { if (asset.type === 'scene') {
items.push({ items.push({
@@ -112,13 +122,16 @@ export class AssetPanel extends Panel {
disabled: this.state.project.activeSceneId === id, disabled: this.state.project.activeSceneId === id,
}); });
} else { } else {
const target = this.target();
items.push({ items.push({
label: 'Place in scene', label: target?.holder.type === 'container' ? 'Place in container' : 'Place in scene',
onSelect: () => this.place(id), onSelect: () => this.place(id),
disabled: !this.state.activeScene, disabled: !target,
}); });
} }
items.push({ label: 'Delete…', danger: true, onSelect: () => confirmDeleteAsset(this.state, id) }); 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;
} }
} }
+64 -32
View File
@@ -1,14 +1,14 @@
import { SetParentCommand } from '../../core/commands/sceneObjectCommands'; import { SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { hasColorSwatch } from '../../core/model/AssetTypes'; import { hasColorSwatch } from '../../core/model/AssetTypes';
import { ancestorIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy'; 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 { resolveSceneObjectProps } from '../../core/properties/resolve';
import { ListView, Menu, Panel, ScrollContainer } from '../../ui'; import { ListView, Menu, Panel, ScrollContainer } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions'; import { addCollider, createContainer, removeObjects } from '../actions';
import { type EditorState, objectSelection } from '../state'; 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 * 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 * 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. * top level. The object keeps its world position either way.
@@ -22,24 +22,31 @@ export class HierarchyPanel extends Panel {
private readonly scroll = new ScrollContainer(); private readonly scroll = new ScrollContainer();
/** Ids of the objects whose children are shown. Anything missing is collapsed. */ /** Ids of the objects whose children are shown. Anything missing is collapsed. */
private readonly expanded = new Set<string>(); private readonly expanded = new Set<string>();
/**
* 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) { constructor(private readonly state: EditorState) {
super({ title: 'Scene objects', padding: 0, gap: 0 }); super({ title: 'Scene objects', padding: 0, gap: 0 });
this.minWidth = 160; this.minWidth = 160;
this.list.emptyText = 'Place assets here'; this.list.emptyText = 'Place assets here';
this.list.onSelect = (id, modifiers) => { this.list.onSelect = (id, modifiers) => {
const scene = state.activeScene; const holder = this.holder();
if (!scene) return; if (!holder) return;
if (id && modifiers && (modifiers.ctrl || modifiers.shift)) state.toggleObject(scene.id, id); if (id && modifiers && (modifiers.ctrl || modifiers.shift)) state.toggleObject(holder.id, id);
else state.select(id ? objectSelection(scene.id, [id]) : null); else state.select(id ? objectSelection(holder.id, [id]) : null);
}; };
this.list.onDrop = (draggedId, targetId) => { this.list.onDrop = (draggedId, targetId) => {
const scene = state.activeScene; const holder = this.holder();
const object = scene && findObject(scene, draggedId); const object = holder && findObject(holder, draggedId);
if (!scene || !object || object.parentId === (targetId ?? '')) return; if (!holder || !object || object.parentId === (targetId ?? '')) return;
// Unfold the new parent, otherwise the dragged row would seem to vanish on drop. // Unfold the new parent, otherwise the dragged row would seem to vanish on drop.
if (targetId) this.expanded.add(targetId); 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.onToggle = (id, expand) => this.setExpanded(id, expand);
this.list.onActivate = (id) => this.setExpanded(id, !this.expanded.has(id)); this.list.onActivate = (id) => this.setExpanded(id, !this.expanded.has(id));
@@ -53,14 +60,33 @@ export class HierarchyPanel extends Panel {
this.expanded.clear(); this.expanded.clear();
this.refresh(); 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(); 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). */ /** Ids of the rows currently shown – subtrees of collapsed objects are not among them (test hook). */
rowOrder(): string[] { rowOrder(): string[] {
const scene = this.state.activeScene; const holder = this.holder();
return scene ? visibleTreeOrder(scene, this.expanded).map((e) => e.object.id) : []; 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). */ /** 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 { expandAll(): void {
const scene = this.state.activeScene; for (const object of this.holder()?.objects ?? []) this.expanded.add(object.id);
for (const object of scene?.objects ?? []) this.expanded.add(object.id);
this.refresh(); this.refresh();
} }
@@ -81,16 +106,17 @@ export class HierarchyPanel extends Panel {
this.refresh(); 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 { state } = this;
const scene = state.activeScene; const holder = this.holder();
if (!scene) return; if (!holder) return undefined;
const object = findObject(scene, id); const inScene = holder.type === 'scene';
const object = findObject(holder, id);
const type = object && findAsset(state.project, object.assetId)?.type; const type = object && findAsset(state.project, object.assetId)?.type;
// Inside a multi-selection the menu acts on all selected objects. // Inside a multi-selection the menu acts on all selected objects.
const selected = state.selectedObjectIds.includes(id) ? state.selectedObjectIds : [id]; const selected = state.selectedObjectIds.includes(id) ? state.selectedObjectIds : [id];
const many = selected.length > 1; const many = selected.length > 1;
new Menu([ const menu = new Menu([
{ {
label: 'Select asset', label: 'Select asset',
onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }), onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }),
@@ -98,25 +124,29 @@ export class HierarchyPanel extends Panel {
{ {
label: 'Add collider', label: 'Add collider',
disabled: !type || type === 'collider' || many, 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', 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)', label: 'Unparent (move to top level)',
disabled: !object?.parentId || many, 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: 'Expand all', onSelect: () => this.expandAll() },
{ label: 'Collapse all', onSelect: () => this.collapseAll() }, { 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, 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 { 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. */ /** Unfolds the ancestors of every selected object. Returns whether that changed anything. */
private revealSelection(): boolean { private revealSelection(): boolean {
const scene = this.state.activeScene; const holder = this.holder();
if (!scene) return false; if (!holder) return false;
let changed = false; let changed = false;
for (const id of this.state.selectedObjectIds) { 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; if (this.expanded.has(ancestorId)) continue;
this.expanded.add(ancestorId); this.expanded.add(ancestorId);
changed = true; changed = true;
@@ -141,10 +171,12 @@ export class HierarchyPanel extends Panel {
} }
private refresh(): void { private refresh(): void {
const scene = this.state.activeScene; const holder = this.holder();
this.setTitle(scene ? `Scene: ${scene.name}` : 'Scene objects'); this.shownHolderId = holder?.id ?? '';
const kind = holder?.type === 'container' ? 'Container' : 'Scene';
this.setTitle(holder ? `${kind}: ${holder.name}` : 'Scene objects');
this.list.setItems( 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 asset = findAsset(this.state.project, object.assetId);
const props = asset ? resolveSceneObjectProps(asset, object) : {}; const props = asset ? resolveSceneObjectProps(asset, object) : {};
return { return {
+2 -2
View File
@@ -32,12 +32,12 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions):
} }
if (mod && key === 'g') { if (mod && key === 'g') {
const s = state.selection; 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; return true;
} }
if (event.key === 'Delete' || event.key === 'Backspace') { if (event.key === 'Delete' || event.key === 'Backspace') {
const s = state.selection; 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 === '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 === 'frame') state.execute(new RemoveFrameCommand(s.assetId, s.frameId));
else if (s?.kind === 'asset') confirmDeleteAsset(state, s.assetId); else if (s?.kind === 'asset') confirmDeleteAsset(state, s.assetId);
+45 -19
View File
@@ -1,17 +1,24 @@
import { type Command, CommandError } from '../core/commands/Command'; import { type Command, CommandError } from '../core/commands/Command';
import { CommandStack } from '../core/commands/CommandStack'; 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 { 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'; import { deserializeProject, serializeProject } from '../core/store/serialize';
export type Selection = export type Selection =
| { kind: 'asset'; assetId: string } | { kind: 'asset'; assetId: string }
/** /**
* One or more objects of a scene. `objectId` is the primary one (the last picked – the * One or more objects of a scene or of a container's template (`holderId` names which).
* inspector shows it); `objectIds` holds the whole selection, primary included. * `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. */ /** A node inside a behavior's graph. */
| { kind: 'node'; assetId: string; nodeId: string } | { kind: 'node'; assetId: string; nodeId: string }
/** A frame rectangle inside a texture asset (the Texture tab). */ /** A frame rectangle inside a texture asset (the Texture tab). */
@@ -19,11 +26,11 @@ export type Selection =
| null; | null;
/** Builds an object selection; the last id is the primary one unless given explicitly. */ /** 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)]; const ids = [...new Set(objectIds)];
if (ids.length === 0) return null; if (ids.length === 0) return null;
const objectId = primary && ids.includes(primary) ? primary : ids[ids.length - 1]!; 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'; export type EditorMode = 'edit' | 'play';
@@ -194,23 +201,23 @@ export class EditorState {
// --- selection ----------------------------------------------------------------------------- // --- selection -----------------------------------------------------------------------------
select(selection: Selection): void { 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 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (selection?.kind === 'object' && !selection.objectIds) if (selection?.kind === 'object' && !selection.objectIds)
selection = objectSelection(selection.sceneId, [selection.objectId]); selection = objectSelection(selection.holderId, [selection.objectId]);
if (sameSelection(this.selection, selection)) return; if (sameSelection(this.selection, selection)) return;
this.selection = selection; this.selection = selection;
this.emit('selection'); this.emit('selection');
} }
/** Ctrl/Shift-click: adds the object to the selection or removes it again. */ /** 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 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) const next = current.includes(objectId)
? current.filter((id) => id !== objectId) ? current.filter((id) => id !== objectId)
: [...current, 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). */ /** Ids of the selected scene objects (empty unless objects are selected). */
@@ -224,9 +231,9 @@ export class EditorState {
if (!s) return; if (!s) return;
if (s.kind === 'asset' && !findAsset(this.project, s.assetId)) this.select(null); if (s.kind === 'asset' && !findAsset(this.project, s.assetId)) this.select(null);
if (s.kind === 'object') { if (s.kind === 'object') {
const scene = findScene(this.project, s.sceneId); const holder = findObjectHolder(this.project, s.holderId);
const alive = scene ? s.objectIds.filter((id) => findObject(scene, id)) : []; const alive = holder ? s.objectIds.filter((id) => findObject(holder, id)) : [];
if (alive.length !== s.objectIds.length) this.select(objectSelection(s.sceneId, alive, s.objectId)); if (alive.length !== s.objectIds.length) this.select(objectSelection(s.holderId, alive, s.objectId));
} }
if (s.kind === 'node') { if (s.kind === 'node') {
const asset = findAsset(this.project, s.assetId); const asset = findAsset(this.project, s.assetId);
@@ -259,12 +266,31 @@ export class EditorState {
if (!s) return null; if (!s) return null;
if (s.kind === 'node') return s.assetId; if (s.kind === 'node') return s.assetId;
if (s.kind === 'frame') return null; if (s.kind === 'frame') return null;
const scene = s.kind === 'object' ? findScene(this.project, s.sceneId) : undefined; const asset = this.assetBehind(s);
const assetId = s.kind === 'asset' ? s.assetId : scene && findObject(scene, s.objectId)?.assetId;
const asset = assetId ? findAsset(this.project, assetId) : undefined;
return asset && asset.type === 'behavior' ? asset.id : null; 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<Selection>): 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 -------------------------------------------------------------------------------- // --- status --------------------------------------------------------------------------------
setSaveStatus(status: SaveStatus): void { 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 === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;
if (a.kind === 'object' && b.kind === 'object') { if (a.kind === 'object' && b.kind === 'object') {
return ( return (
a.sceneId === b.sceneId && a.holderId === b.holderId &&
a.objectId === b.objectId && a.objectId === b.objectId &&
a.objectIds.length === b.objectIds.length && a.objectIds.length === b.objectIds.length &&
a.objectIds.every((id, i) => id === b.objectIds[i]) a.objectIds.every((id, i) => id === b.objectIds[i])
+22 -5
View File
@@ -3,6 +3,7 @@ import { centreY } from '../math';
import { Theme, textStyle } from '../Theme'; import { Theme, textStyle } from '../Theme';
import { type Size, Widget } from '../Widget'; import { type Size, Widget } from '../Widget';
import { ListRow } from './ListRow'; import { ListRow } from './ListRow';
import type { Popup } from './Popup';
export interface ListItem { export interface ListItem {
id: string; id: string;
@@ -46,9 +47,12 @@ export class ListView extends Widget {
private rows: ListRow[] = []; private rows: ListRow[] = [];
private selectedId: string | null = null; private selectedId: string | null = null;
private highlighted = new Set<string>(); private highlighted = new Set<string>();
/** 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; onSelect?: (id: string | null, modifiers?: PickModifiers) => void;
onActivate?: (id: string) => 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). */ /** Called when a dragged row is released over another row (`targetId`) or over empty space (null). */
onDrop?: (draggedId: string, targetId: string | null) => void; 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 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.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.layout();
this.redraw(); this.redraw();
} }
@@ -106,6 +111,12 @@ export class ListView extends Widget {
if (!silent) this.onSelect?.(id); 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). */ /** Rows drawn as selected in addition to the selected one (the owner's multi-selection). */
highlight(ids: Iterable<string>): void { highlight(ids: Iterable<string>): void {
this.highlighted = new Set(ids); this.highlighted = new Set(ids);
@@ -153,7 +164,11 @@ export class ListView extends Widget {
override redraw(): void { override redraw(): void {
for (const row of this.rows) { 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.text = this.items.length === 0 ? this.emptyText : '';
this.emptyLabel.position.set(Theme.padding, centreY(this.rowHeight, this.emptyLabel.height)); 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)) { if (e.button === 0 && row.hitsChevron(row.toLocal(e.global).x)) {
this.onToggle?.(item.id, !item.expanded); this.onToggle?.(item.id, !item.expanded);
} else if (e.button === 2) { } else if (e.button === 2) {
// A right-click inside a multi-selection keeps it (the menu acts on all of it). // Marked, not selected: selecting also loads the row into the editor, which would swap the
if (!this.highlighted.has(item.id)) this.select(item.id); // very tab out from under a menu opened to place something into it.
this.onContextMenu?.(item.id, e.global.x, e.global.y); 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) { } else if (modifiers.ctrl || modifiers.shift) {
this.onSelect?.(item.id, modifiers); this.onSelect?.(item.id, modifiers);
} else { } else {
+63 -23
View File
@@ -1,6 +1,7 @@
/** /**
* The editable view of a scene. It shows `state.project`, which in play mode is the runtime * 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 { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { CompoundCommand } from '../core/commands/CompoundCommand'; import { CompoundCommand } from '../core/commands/CompoundCommand';
@@ -8,10 +9,11 @@ import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
import { isSymbolType } from '../core/model/AssetTypes'; import { isSymbolType } from '../core/model/AssetTypes';
import { findObject, subtreeRoots } from '../core/model/hierarchy'; import { findObject, subtreeRoots } from '../core/model/hierarchy';
import type { Position, Vec2 } from '../core/model/Properties'; import type { Position, Vec2 } from '../core/model/Properties';
import type { ObjectHolder } from '../core/model/Project';
import { footprintOf } from '../core/properties/bounds'; import { footprintOf } from '../core/properties/bounds';
import { type EditorState, objectSelection } from '../editor/state'; import { type EditorState, objectSelection } from '../editor/state';
import { clamp, drawGrid, type Size, Theme, textStyle, zoomFactor } from '../ui'; 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 { dashedLine } from './symbols';
import { freeView, screenToWorld, type View, worldToScreen } from './view'; import { freeView, screenToWorld, type View, worldToScreen } from './view';
import { type Frame, WorldViewport } from './WorldViewport'; import { type Frame, WorldViewport } from './WorldViewport';
@@ -24,9 +26,11 @@ const GRID_STEP = 100;
const MIN_GRID_STEP = 12; const MIN_GRID_STEP = 12;
const MIN_ZOOM = 0.1; const MIN_ZOOM = 0.1;
const MAX_ZOOM = 8; const MAX_ZOOM = 8;
/** Breathing room left around the content by `fit()`, in screen pixels. */
const FIT_MARGIN = 40;
interface ObjectDrag { interface ObjectDrag {
sceneId: string; holderId: string;
items: { objectId: string; start: Position }[]; items: { objectId: string; start: Position }[];
pointer: Vec2; pointer: Vec2;
moved: boolean; 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. */ /** World coordinates at the centre of the view. */
worldCenter(): Vec2 { worldCenter(): Vec2 {
return { x: Math.round(this.view.x), y: Math.round(this.view.y) }; 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 ------------------------------------------------------------------------------- // --- the view -------------------------------------------------------------------------------
protected override currentView(): View { protected override currentView(): View {
@@ -82,7 +122,7 @@ export class SceneViewport extends WorldViewport {
} }
protected override collectFrame(): Frame { 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 { 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. */ /** Without a scene there is nothing to frame: the neutral base colour stands alone. */
protected override paintsBackground(): boolean { protected override paintsBackground(): boolean {
return Boolean(this.state.activeScene); return Boolean(this.holder());
} }
protected override get itemsInteractive(): boolean { 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; const dy = (local.y - this.dragging.pointer.y) / this.view.zoom;
if (!this.dragging.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return; if (!this.dragging.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
this.dragging.moved = true; this.dragging.moved = true;
const scene = this.state.activeScene; const holder = this.holder();
if (!scene) return; if (!holder) return;
// A world-space delta is a local-space delta as well (no rotation/scale in the hierarchy). // A world-space delta is a local-space delta as well (no rotation/scale in the hierarchy).
for (const item of this.dragging.items) { for (const item of this.dragging.items) {
const object = findObject(scene, item.objectId); const object = findObject(holder, item.objectId);
if (object) if (object)
object.position = { object.position = {
x: Math.round(item.start.x + dx), x: Math.round(item.start.x + dx),
@@ -167,16 +207,16 @@ export class SceneViewport extends WorldViewport {
const drag = this.dragging; const drag = this.dragging;
this.dragging = null; this.dragging = null;
if (!drag || !drag.moved) return; if (!drag || !drag.moved) return;
const scene = this.state.activeScene; const holder = this.holder();
if (!scene) return; if (!holder) return;
// The preview moved the objects already; put them back so the command records the start. // The preview moved the objects already; put them back so the command records the start.
const moves: MoveSceneObjectCommand[] = []; const moves: MoveSceneObjectCommand[] = [];
for (const item of drag.items) { for (const item of drag.items) {
const object = findObject(scene, item.objectId); const object = findObject(holder, item.objectId);
if (!object) continue; if (!object) continue;
const next = { ...object.position }; const next = { ...object.position };
object.position = { ...item.start }; 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]!); if (moves.length === 1) this.state.execute(moves[0]!);
else if (moves.length > 1) this.state.execute(new CompoundCommand('Move objects', moves)); 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 { protected override onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
if (e.button !== 0) return; if (e.button !== 0) return;
const scene = this.state.activeScene; const holder = this.holder();
const object = scene && findObject(scene, objectId); const object = holder && findObject(holder, objectId);
if (!scene || !object) return; if (!holder || !object) return;
e.stopPropagation(); e.stopPropagation();
if (e.ctrlKey || e.metaKey || e.shiftKey) { if (e.ctrlKey || e.metaKey || e.shiftKey) {
this.state.toggleObject(scene.id, objectId); this.state.toggleObject(holder.id, objectId);
return; return;
} }
// Clicking a selected object keeps the selection so the whole group can be dragged. // Clicking a selected object keeps the selection so the whole group can be dragged.
if (!this.state.selectedObjectIds.includes(objectId)) 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. // Only the tops of the selected subtrees move; descendants follow through the hierarchy.
const items = subtreeRoots(scene, this.state.selectedObjectIds) const items = subtreeRoots(holder, this.state.selectedObjectIds)
.map((id) => findObject(scene, id)) .map((id) => findObject(holder, id))
.filter((o): o is NonNullable<typeof o> => Boolean(o)) .filter((o): o is NonNullable<typeof o> => Boolean(o))
.map((o) => ({ objectId: o.id, start: { ...o.position } })); .map((o) => ({ objectId: o.id, start: { ...o.position } }));
const local = this.toLocal(e.global); 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. */ /** Selects every object whose position – not its bounds – lies inside the band. */
private selectInMarquee(marquee: Marquee): void { private selectInMarquee(marquee: Marquee): void {
const scene = this.state.activeScene; const holder = this.holder();
if (!scene) return; if (!holder) return;
const view = this.currentView(); const view = this.currentView();
const a = screenToWorld(view, marquee.start.x, marquee.start.y); const a = screenToWorld(view, marquee.start.x, marquee.start.y);
const b = screenToWorld(view, marquee.current.x, marquee.current.y); const b = screenToWorld(view, marquee.current.x, marquee.current.y);
@@ -229,7 +269,7 @@ export class SceneViewport extends WorldViewport {
const ids = this.lastItems const ids = this.lastItems
.filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY) .filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY)
.map((i) => i.id); .map((i) => i.id);
this.state.select(objectSelection(scene.id, ids)); this.state.select(objectSelection(holder.id, ids));
} }
// --- decorations ---------------------------------------------------------------------------- // --- decorations ----------------------------------------------------------------------------
+16 -11
View File
@@ -2,11 +2,10 @@ import { isHandleEnabled, worldPositionOf } from '../core/graph';
import { type AssetType, isSymbolType } from '../core/model/AssetTypes'; import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
import { editorPosition, treeOrder } from '../core/model/hierarchy'; import { editorPosition, treeOrder } from '../core/model/hierarchy';
import type { PropertyBag } from '../core/model/Properties'; 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 { resolveTexture, type TextureRef } from '../core/model/texture';
import { enabledAlongChain } from '../core/properties/enabled'; import { enabledAlongChain } from '../core/properties/enabled';
import { resolveSceneObjectProps } from '../core/properties/resolve'; import { resolveSceneObjectProps } from '../core/properties/resolve';
import type { EditorState } from '../editor/state';
import type { World } from '../runtime/World'; import type { World } from '../runtime/World';
/** What the viewport draws: the same shape whether it comes from the model or the 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; 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 { * A scene or a container template, in tree order – the tie-break for equal `z` – at the editor
const scene = state.activeScene; * positions. A container has no background of its own, so it keeps the fallback.
if (!scene) return { items: [], background: fallbackBackground }; */
export function collectHolderItems(
project: Project,
holder: ObjectHolder | undefined,
fallbackBackground: number,
): DrawList {
if (!holder) return { items: [], background: fallbackBackground };
const items: DrawItem[] = []; const items: DrawItem[] = [];
for (const { object } of treeOrder(scene)) { for (const { object } of treeOrder(holder)) {
const asset = findAsset(state.project, object.assetId); const asset = findAsset(project, object.assetId);
if (!asset) continue; if (!asset) continue;
const props = resolveSceneObjectProps(asset, object); const props = resolveSceneObjectProps(asset, object);
items.push({ items.push({
id: object.id, id: object.id,
type: asset.type, type: asset.type,
name: object.name || asset.name, name: object.name || asset.name,
...editorPosition(scene, object), ...editorPosition(holder, object),
props, props,
parentId: object.parentId, parentId: object.parentId,
enabled: true, 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])); 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) => i.props,
(i) => byId.get(i.parentId) ?? null, (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. */ /** The running world: scene content only, since symbols are editor-only. */
+242 -1
View File
@@ -1,16 +1,24 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { DeleteAssetCommand } from '../../src/core/commands/assetCommands'; import { DeleteAssetCommand } from '../../src/core/commands/assetCommands';
import { CommandError } from '../../src/core/commands/Command';
import { CompoundCommand } from '../../src/core/commands/CompoundCommand'; import { CompoundCommand } from '../../src/core/commands/CompoundCommand';
import { CreateContainerCommand } from '../../src/core/commands/containerCommands'; import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
import { import {
MoveSceneObjectCommand, MoveSceneObjectCommand,
PlaceAssetCommand,
RemoveSceneObjectCommand,
RemoveSceneObjectsCommand, RemoveSceneObjectsCommand,
ClearOverrideCommand,
RenameSceneObjectCommand,
SetOverrideCommand,
SetParentCommand, SetParentCommand,
} from '../../src/core/commands/sceneObjectCommands'; } from '../../src/core/commands/sceneObjectCommands';
import { worldPositionOf } from '../../src/core/graph'; import { worldPositionOf } from '../../src/core/graph';
import type { ContainerAsset } from '../../src/core/model/Asset'; 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 { findAsset, findScene } from '../../src/core/model/Project';
import { createSceneObject } from '../../src/core/model/SceneObject';
import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers'; 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)). */ /** 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 }; 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', () => { describe('subtree roots', () => {
it('drops ids that have a selected ancestor', () => { it('drops ids that have a selected ancestor', () => {
const { sceneOf, a, b, c } = setup(); 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', () => { describe('multi-selection commands', () => {
it('removes several subtrees as one step, covering nested ids', () => { it('removes several subtrees as one step, covering nested ids', () => {
const { stack, sceneId, a, b, c, sceneOf, object } = setup(); const { stack, sceneId, a, b, c, sceneOf, object } = setup();
+22 -2
View File
@@ -9,7 +9,13 @@ import { CreateContainerCommand } from '../../src/core/commands/containerCommand
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands'; import { RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
import type { ContainerAsset } from '../../src/core/model/Asset'; 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'; import { makeBehavior, makeSpriteScene, place } from '../helpers';
/** A behavior with a `speed` variable, placed and overridden to 42, then wrapped in a container. */ /** 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]); const container = new CreateContainerCommand(f.sceneId, [objectId]);
f.stack.execute(container); f.stack.execute(container);
const template = () => (findAsset(f.project, container.assetId) as ContainerAsset).objects[0]!; 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', () => { describe('object holders', () => {
@@ -34,6 +40,20 @@ describe('object holders', () => {
).toEqual(['container', 'scene']); ).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', () => { it('finds usages inside container templates, not just in scenes', () => {
const { project, behaviorId, template } = containerWithOverride(); const { project, behaviorId, template } = containerWithOverride();
// The placed object and the template copy the container was built from. // The placed object and the template copy the container was built from.
+1 -1
View File
@@ -31,7 +31,7 @@ export async function hierarchy(t) {
same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), same(await rowIds(), ['o1', 'o2', 'o3', 'o5']),
await rows(), await rows(),
); );
await select({ kind: 'object', sceneId: 'scene1', objectId: 'oc1' }); await select({ kind: 'object', holderId: 'scene1', objectId: 'oc1' });
await sleep(150); await sleep(150);
check( check(
'selecting a nested object unfolds its ancestors', 'selecting a nested object unfolds its ancestors',
+2 -2
View File
@@ -80,7 +80,7 @@ export async function play(t) {
Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) && Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) &&
(await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 7, (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`); await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 400`);
let liveO2 = await entity('o2'); let liveO2 = await entity('o2');
check( check(
@@ -158,7 +158,7 @@ export async function play(t) {
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = ${xBeforePlay}`); 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. // 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 press('Delete');
await sleep(200); await sleep(200);
check('camera object deleted', (await objects()).length === 6); check('camera object deleted', (await objects()).length === 6);
+1 -1
View File
@@ -30,7 +30,7 @@ export async function drawing(t) {
JSON.stringify(await orderOfCrates()) === '["o1","o2"]', JSON.stringify(await orderOfCrates()) === '["o1","o2"]',
JSON.stringify(await orderOfCrates()), JSON.stringify(await orderOfCrates()),
); );
await select({ kind: 'object', sceneId: 'scene1', objectId: 'o1' }); await select({ kind: 'object', holderId: 'scene1', objectId: 'o1' });
await sleep(200); await sleep(200);
const zLabel = await locate('Z'); const zLabel = await locate('Z');
await click(zLabel.x + 160, zLabel.y); // plain click → text editing with everything selected await click(zLabel.x + 160, zLabel.y); // plain click → text editing with everything selected
+3 -3
View File
@@ -146,7 +146,7 @@ export async function containers(t) {
// 16d. Dragging one of several selected objects moves all of them as one undo step. // 16d. Dragging one of several selected objects moves all of them as one undo step.
const redScreen = await json(`__gm.objectScreen('o1')`); 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); await drag(redScreen.x, redScreen.y + 20, redScreen.x + 50, redScreen.y + 20);
o1c = await object('o1'); o1c = await object('o1');
const o2d = await object('o2'); 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. // 16e. Delete with a multi-selection removes all of them (with subtrees) as one step.
const objectsBeforeDelete = (await objects()).length; 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 press('Delete');
await sleep(150); await sleep(150);
check( 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 // ↑ 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. // both back; ↺ puts o1 back to the template value.
const templateA = async () => (await asset(containerAsset.id)).objects.find((o) => o.id === o1c.templateId); 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); await sleep(200);
check('inspector names the container the object is part of', await has(containerAsset.name)); 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('↺'))); check('no apply/revert while the instance matches its template', !(await has('↑')) && !(await has('↺')));
+1 -1
View File
@@ -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). // 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; const cam = __gm.state.activeScene.objects.find(o => o.id === 'o3').position;
__gm.state.activeScene.objects.push({ id: 'otext', assetId: id, name: '', position: { x: cam.x, y: cam.y, z: 5 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', templateId: '' }); __gm.state.activeScene.objects.push({ id: 'otext', assetId: id, name: '', position: { x: cam.x, y: cam.y, z: 5 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', 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); await sleep(100);
check('a placed text object draws its content in the scene', await has('Hello world')); check('a placed text object draws its content in the scene', await has('Hello world'));
await shot('29-text-object'); await shot('29-text-object');
+1 -1
View File
@@ -15,7 +15,7 @@ export async function menus(t) {
copy.parentId = ''; copy.parentId = '';
scene.objects.push(copy); 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); await sleep(150);
// 18. A picker with 40+ entries is capped in height and stays fully on screen instead of // 18. A picker with 40+ entries is capped in height and stays fully on screen instead of
+6 -5
View File
@@ -14,7 +14,7 @@ export async function overflow(t) {
const pickerWidths = async () => { const pickerWidths = async () => {
const out = {}; const out = {};
for (const tab of ['graph', 'texture']) { for (const tab of ['graph', 'texture', 'container']) {
await evaluate(`__gm.showTab(${JSON.stringify(tab)})`); await evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
await sleep(300); await sleep(300);
out[tab] = await json(`__gm.${tab}.pickerWidth()`); out[tab] = await json(`__gm.${tab}.pickerWidth()`);
@@ -25,7 +25,8 @@ export async function overflow(t) {
await mutate(` await mutate(`
for (const asset of __gm.state.project.assets) { 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)`); __gm.state.select(null)`);
await sleep(600); 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 // 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. // picker grew with whatever name it happened to show and the header reflowed on load.
const after = await pickerWidths(); const after = await pickerWidths();
for (const tab of ['graph', 'texture']) { for (const tab of ['graph', 'texture', 'container']) {
check( check(
`the ${tab} picker keeps its width when the name gets long`, `the ${tab} picker keeps its width when the name gets long`,
before[tab] === 200 && after[tab] === 200, 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. // 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 evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
await sleep(400); await sleep(400);
const spills = await json('__gm.textOverflows()'); 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 // 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. // 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 evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
await sleep(400); await sleep(400);
const dropped = await json('__gm.labelsDropped()'); const dropped = await json('__gm.labelsDropped()');
+1 -1
View File
@@ -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. // 30. The same rows on a placed object write an override rather than the asset value.
await mutate(`${spriteProp('texture')} = ${JSON.stringify(sheet)}`); 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 sleep(600);
await mutate("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides.height = 90"); await mutate("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides.height = 90");
await sleep(400); await sleep(400);
+262
View File
@@ -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);
}
+2
View File
@@ -18,6 +18,7 @@ import { textures } from './12-textures.mjs';
import { frames } from './13-frames.mjs'; import { frames } from './13-frames.mjs';
import { overflow } from './14-overflow.mjs'; import { overflow } from './14-overflow.mjs';
import { aspect } from './15-aspect.mjs'; import { aspect } from './15-aspect.mjs';
import { containerTab } from './16-container.mjs';
export const STEPS = [ export const STEPS = [
hierarchy, hierarchy,
@@ -35,4 +36,5 @@ export const STEPS = [
frames, frames,
overflow, overflow,
aspect, aspect,
containerTab,
]; ];