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. |
| **Enabled** | Every placeable asset has the switch `enabled` (default on). A disabled object is not drawn, does not collide and its behavior does not run – **and neither does anything parented below it**. Switching one instance off is a normal override, which makes a container a *screen*: switch it on to show it, off to hide it. `Set enabled` / `Is enabled` do it from a graph. |
| **Scene object** | An asset placed into a scene: a link to the asset + a **position** (`x`, `y`, `z`) + sparse **overrides** of the asset's properties + an optional **parent** (another object of the scene). Scenes cannot be placed. |
| **Container** | A reusable group ("prefab"): the asset holds a **template** of objects (asset link, local position, overrides, parent links) centred on its origin. Placing a container creates a container object plus fresh copies of the template below it – ordinary objects, each remembering its template id. **Create container** turns the current selection into such an asset and replaces the selected objects by one placed container at their centre (world positions unchanged). The template is edited from its instances (see *Instance*). |
| **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. |
| **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** for several objects. |
| **Behavior** | Logic as an asset: a **node graph** plus the **variables** it declares. Placed like anything else (a diamond symbol in the scene); its parent is the **owner** the graph acts on. |
| **Collider** | A shape (box or circle) for collision detection, placed as a **child** of the object it belongs to (its *body*). Drawn as a green outline in edit mode only. |
| **Override** | A per-object value that replaces the asset's value for one property. Only changed keys are stored; the inspector marks them bold and offers a reset (↺) and an **apply** (↑) that writes the value into the asset instead. Both appear only where the value really differs from its base – an override carrying the asset's own value is none. |
@@ -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
node or port to delete/disconnect, `Delete` removes the selected node, wheel zooms, dragging
the empty canvas pans. Everything is undoable.
- **Header bar** (the same one the Texture tab has): the behavior picker, **Add node…** (the
palette, dropping the node in the middle of the view – the right-click nobody finds), **Fit**,
and the size of the graph as "N nodes · M links".
- **Header bar** (the same one the Texture and Container tabs have): the behavior picker,
**Add node…** (the palette, dropping the node in the middle of the view – the right-click nobody
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
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
@@ -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
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
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
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:
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
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
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()` /
`__gm.texture.pickerWidth()` catch a picker that resizes itself around its content (e2e step 14).
direction – a button too narrow to draw any of its label – and the `pickerWidth()` of
`__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.
- **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).
@@ -296,7 +339,7 @@ selected and switched back on.
web/index.html Vite source page (dev) index.html built page (generated, served)
src/core/model/ Project (object holders), Asset, SceneObject, hierarchy, walk (generic parent/child walks), container templates
src/core/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/store/ serialize (untrusted JSON → Project), HttpProjectStore
src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, BehaviorSystem
@@ -309,6 +352,7 @@ src/editor/inspector/ Inspector panel, field factory, previewHandlers (scrub
one section per selection kind (asset/object/node)
src/editor/panels/ AssetPanel, HierarchyPanel, Toolbar
src/editor/graph/ GraphEditor (header + picker), GraphCanvas, NodeView, NodePalette
src/editor/container/ ContainerEditor (header + picker), ContainerCanvas (a SceneViewport on a template)
api/ PHP backend data/projects/ persisted projects
tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/), smoke-jumprun, lib/
```
@@ -353,7 +397,8 @@ rewritten by every e2e run and the **jump-run** project by its seeder. The **pla
project is the author's own and is maintained in the editor – every tool refuses to touch it.
The e2e run is a list of steps in `tools/e2e/` (hierarchy, inspector, play, assets, drawing,
graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect) that build
graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect,
container tab) that build
on each other's scene state
and share the CDP driver in `tools/lib/harness.mjs` – `press`, `undo`, `play`, `object(id)`,
`world()`, `mutate(js)` and friends, so a check reads like the interaction it exercises.
@@ -405,11 +450,9 @@ object in the selection (hierarchy rows too).
## Not yet
Editing a container's template directly (today changes reach it only via **Apply to
container** from an instance), reordering the hierarchy, several scenes in one run (a
`Load scene` node – screens are containers today), sound, auth, touch-optimised layout,
graph comments/groups. A frame is always stretched to the sprite's `width` × `height`: no
animation (nothing steps through a sheet's frames yet) and no nine-slice. ↔ / ↕ fit the
Reordering the hierarchy, several scenes in one run (a `Load scene` node – screens are containers
today), sound, auth, touch-optimised layout, graph comments/groups. A frame is always stretched to
the sprite's `width` × `height`: no animation (nothing steps through a sheet's frames yet) and no nine-slice. ↔ / ↕ fit the
proportions, but nothing sets a sprite to the picture's pixel size outright. Slicing cuts a
regular grid only — no detection of where the sprites actually
sit on the sheet. Unreferenced image files are never collected.
+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
* other copies: those still at the template's value follow, those modified on their own do not.
*/
import { instancesOf, type TemplateLink, templateLinkOf } from '../model/container';
import { type TemplateLink, templateLinkOf } from '../model/container';
import { type Axis, type Position, type PropertyValue, setBagValue } from '../model/Properties';
import { findScene, type Project } from '../model/Project';
import { findObjectHolder, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { resolveSceneObjectProps } from '../properties/resolve';
import { type Command, CommandError } from './Command';
import { requireAsset, requireObject, requirePropDef } from './require';
import {
ALL_AXES,
overrideSlot,
positionSlot,
restoreSlots,
type Slot,
type SlotMemo,
syncInstances,
} from './templateSync';
const ALL_AXES: readonly Axis[] = ['x', 'y', 'z'];
function requireLink(project: Project, sceneId: string, object: SceneObject): TemplateLink {
const scene = findScene(project, sceneId);
const link = scene && templateLinkOf(project, scene, object);
function requireLink(project: Project, holderId: string, object: SceneObject): TemplateLink {
const holder = findObjectHolder(project, holderId);
const link = holder && templateLinkOf(project, holder, object);
if (!link) throw new CommandError(`${object.name || object.id} is not part of a placed container`);
return link;
}
/** One value that is applied on its own: an override key, or a single axis of the position. */
interface Slot<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
* it. Returns the memos in application order; `restoreSlots` undoes them.
@@ -60,21 +43,11 @@ function applyToTemplate<T>(
slot,
previous: before[i]!,
}));
for (const { object } of instancesOf(project, link.template.id)) {
slots.forEach((slot, i) => {
if (slot.read(object) !== before[i]) return;
memos.push({ object, slot, previous: slot.read(object) });
slot.write(object, values[i]!);
});
}
memos.push(...syncInstances(project, link.template, slots, before, values));
slots.forEach((slot, i) => slot.write(link.template, values[i]!));
return memos;
}
function restoreSlots<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
* 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>[] = [];
constructor(
private readonly sceneId: string,
private readonly holderId: string,
private readonly objectId: string,
private readonly key: string,
) {
@@ -94,8 +67,8 @@ export class ApplyOverrideToAssetCommand implements Command {
}
execute(project: Project): void {
const scene = findScene(project, this.sceneId);
const object = requireObject(project, this.sceneId, this.objectId);
const holder = findObjectHolder(project, this.holderId);
const object = requireObject(project, this.holderId, this.objectId);
const asset = requireAsset(project, object.assetId);
requirePropDef(asset, this.key);
const value = resolveSceneObjectProps(asset, object)[this.key];
@@ -103,12 +76,12 @@ export class ApplyOverrideToAssetCommand implements Command {
this.previousOverride = object.overrides[this.key];
asset.props[this.key] = value!;
delete object.overrides[this.key];
const link = scene && templateLinkOf(project, scene, object);
const link = holder && templateLinkOf(project, holder, object);
this.templateMemos = link ? applyToTemplate(project, link, [overrideSlot(this.key)], [undefined]) : [];
}
undo(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
const object = requireObject(project, this.holderId, this.objectId);
const asset = requireAsset(project, object.assetId);
restoreSlots(this.templateMemos);
setBagValue(object.overrides, this.key, this.previousOverride);
@@ -121,7 +94,7 @@ export class ApplyOverrideToContainerCommand implements Command {
private memos: SlotMemo<PropertyValue | undefined>[] = [];
constructor(
private readonly sceneId: string,
private readonly holderId: string,
private readonly objectId: string,
private readonly key: string,
) {
@@ -129,8 +102,8 @@ export class ApplyOverrideToContainerCommand implements Command {
}
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
const link = requireLink(project, this.sceneId, object);
const object = requireObject(project, this.holderId, this.objectId);
const link = requireLink(project, this.holderId, object);
this.memos = applyToTemplate(project, link, [overrideSlot(this.key)], [object.overrides[this.key]]);
}
@@ -144,7 +117,7 @@ export class RevertOverrideToTemplateCommand implements Command {
private previous: PropertyValue | undefined;
constructor(
private readonly sceneId: string,
private readonly holderId: string,
private readonly objectId: string,
private readonly key: string,
) {
@@ -152,14 +125,14 @@ export class RevertOverrideToTemplateCommand implements Command {
}
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
const link = requireLink(project, this.sceneId, object);
const object = requireObject(project, this.holderId, this.objectId);
const link = requireLink(project, this.holderId, object);
this.previous = object.overrides[this.key];
setBagValue(object.overrides, this.key, link.template.overrides[this.key]);
}
undo(project: Project): void {
setBagValue(requireObject(project, this.sceneId, this.objectId).overrides, this.key, this.previous);
setBagValue(requireObject(project, this.holderId, this.objectId).overrides, this.key, this.previous);
}
}
@@ -169,7 +142,7 @@ export class ApplyPositionToContainerCommand implements Command {
private memos: SlotMemo<number>[] = [];
constructor(
private readonly sceneId: string,
private readonly holderId: string,
private readonly objectId: string,
private readonly axis?: Axis,
) {
@@ -177,8 +150,8 @@ export class ApplyPositionToContainerCommand implements Command {
}
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
const link = requireLink(project, this.sceneId, object);
const object = requireObject(project, this.holderId, this.objectId);
const link = requireLink(project, this.holderId, object);
const axes = this.axis ? [this.axis] : ALL_AXES;
this.memos = applyToTemplate(
project,
@@ -198,7 +171,7 @@ export class RevertPositionToTemplateCommand implements Command {
private previous: Position = { x: 0, y: 0, z: 0 };
constructor(
private readonly sceneId: string,
private readonly holderId: string,
private readonly objectId: string,
private readonly axis?: Axis,
) {
@@ -206,13 +179,13 @@ export class RevertPositionToTemplateCommand implements Command {
}
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
const link = requireLink(project, this.sceneId, object);
const object = requireObject(project, this.holderId, this.objectId);
const link = requireLink(project, this.holderId, object);
this.previous = { ...object.position };
for (const a of this.axis ? [this.axis] : ALL_AXES) object.position[a] = link.template.position[a];
}
undo(project: Project): void {
requireObject(project, this.sceneId, this.objectId).position = { ...this.previous };
requireObject(project, this.holderId, this.objectId).position = { ...this.previous };
}
}
+7 -7
View File
@@ -6,7 +6,7 @@ import { removeById } from '../util/list';
import { buildAsset } from './assetCommands';
import { type Command, CommandError } from './Command';
import { nextAssetName } from './naming';
import { requireAsset, requireObject, requireScene } from './require';
import { requireAsset, requireObject, requireHolder } from './require';
/**
* Creates a collider asset sized like the object (sprites: width × height; anything else: schema
@@ -18,13 +18,13 @@ export class AddColliderCommand implements Command {
readonly objectId = newId();
constructor(
private readonly sceneId: string,
private readonly holderId: string,
private readonly targetId: string,
) {}
execute(project: Project): void {
const scene = requireScene(project, this.sceneId);
const target = requireObject(project, this.sceneId, this.targetId);
const holder = requireHolder(project, this.holderId);
const target = requireObject(project, this.holderId, this.targetId);
const targetAsset = requireAsset(project, target.assetId);
if (targetAsset.type === 'collider') throw new CommandError('A collider cannot have a collider');
@@ -39,12 +39,12 @@ export class AddColliderCommand implements Command {
asset.props.height = Number(props.height);
}
project.assets.push(asset);
scene.objects.push(createSceneObject(this.objectId, this.assetId, { x: 0, y: 0, z: 0 }, this.targetId));
holder.objects.push(createSceneObject(this.objectId, this.assetId, { x: 0, y: 0, z: 0 }, this.targetId));
}
undo(project: Project): void {
const scene = requireScene(project, this.sceneId);
scene.objects = removeById(scene.objects, this.objectId);
const holder = requireHolder(project, this.holderId);
holder.objects = removeById(holder.objects, this.objectId);
project.assets = removeById(project.assets, this.assetId);
}
}
+18 -18
View File
@@ -9,13 +9,13 @@ import {
worldPosition,
} from '../model/hierarchy';
import type { Position } from '../model/Properties';
import { findScene, type Project } from '../model/Project';
import { findObjectHolder, type Project } from '../model/Project';
import { createSceneObject } from '../model/SceneObject';
import { removeById } from '../util/list';
import { buildAsset } from './assetCommands';
import { type Command, CommandError } from './Command';
import { nextAssetName } from './naming';
import { requireObject, requireScene } from './require';
import { requireObject, requireHolder } from './require';
/**
* Turns the selected objects, with everything parented below them, into a container asset placed
@@ -30,16 +30,16 @@ export class CreateContainerCommand implements Command {
private previous: { id: string; parentId: string; position: Position; templateId: string }[] = [];
constructor(
private readonly sceneId: string,
private readonly holderId: string,
private readonly objectIds: readonly string[],
) {}
execute(project: Project): void {
const scene = requireScene(project, this.sceneId);
const selected = [...new Set(this.objectIds)].map((id) => requireObject(project, this.sceneId, id));
const holder = requireHolder(project, this.holderId);
const selected = [...new Set(this.objectIds)].map((id) => requireObject(project, this.holderId, id));
if (selected.length === 0) throw new CommandError('Select at least one object to create a container');
const worlds = selected.map((o) => worldPosition(scene, o));
const worlds = selected.map((o) => worldPosition(holder, o));
const centre: Position = {
x: Math.round((Math.min(...worlds.map((p) => p.x)) + Math.max(...worlds.map((p) => p.x))) / 2),
y: Math.round((Math.min(...worlds.map((p) => p.y)) + Math.max(...worlds.map((p) => p.y))) / 2),
@@ -49,15 +49,15 @@ export class CreateContainerCommand implements Command {
// Members are collected in tree order, so a parent is always built before its children.
const roots = new Set(
subtreeRoots(
scene,
holder,
selected.map((o) => o.id),
),
);
const memberIds = subtreeIds(scene, roots);
const members = treeOrder(scene)
const memberIds = subtreeIds(holder, roots);
const members = treeOrder(holder)
.map((e) => e.object)
.filter((o) => memberIds.has(o.id));
const rootParents = new Set([...roots].map((id) => findObject(scene, id)!.parentId));
const rootParents = new Set([...roots].map((id) => findObject(holder, id)!.parentId));
const containerParentId = rootParents.size === 1 ? [...rootParents][0]! : '';
const asset = buildAsset(
@@ -68,7 +68,7 @@ export class CreateContainerCommand implements Command {
const templateIds = new Map(members.map((o) => [o.id, newId()]));
for (const object of members) {
const isRoot = roots.has(object.id);
const world = worldPosition(scene, object);
const world = worldPosition(holder, object);
asset.objects.push({
id: templateIds.get(object.id)!,
assetId: object.assetId,
@@ -93,32 +93,32 @@ export class CreateContainerCommand implements Command {
const container = createSceneObject(
this.objectId,
this.assetId,
localPositionFor(scene, containerParentId, centre),
localPositionFor(holder, containerParentId, centre),
containerParentId,
);
for (const object of members) {
if (roots.has(object.id)) {
const world = worldPosition(scene, object);
const world = worldPosition(holder, object);
object.parentId = this.objectId;
object.position = { x: world.x - centre.x, y: world.y - centre.y, z: world.z };
}
object.templateId = templateIds.get(object.id)!;
}
project.assets.push(asset);
scene.objects.push(container);
holder.objects.push(container);
}
undo(project: Project): void {
const scene = findScene(project, this.sceneId);
if (scene) {
const holder = findObjectHolder(project, this.holderId);
if (holder) {
for (const entry of this.previous) {
const object = findObject(scene, entry.id);
const object = findObject(holder, entry.id);
if (!object) continue;
object.parentId = entry.parentId;
object.position = { ...entry.position };
object.templateId = entry.templateId;
}
scene.objects = removeById(scene.objects, this.objectId);
holder.objects = removeById(holder.objects, this.objectId);
}
project.assets = removeById(project.assets, this.assetId);
}
+9 -3
View File
@@ -3,7 +3,7 @@ import { findNode, type GraphNode } from '../graph/Graph';
import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
import type { Asset, BehaviorAsset, SceneAsset } from '../model/Asset';
import { findObject } from '../model/hierarchy';
import { findAsset, findScene, type Project } from '../model/Project';
import { findAsset, findObjectHolder, findScene, type ObjectHolder, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { propDef, type PropertyDef } from '../properties/PropertySchema';
import { CommandError } from './Command';
@@ -20,8 +20,14 @@ export function requireScene(project: Project, sceneId: string): SceneAsset {
return scene;
}
export function requireObject(project: Project, sceneId: string, objectId: string): SceneObject {
const object = findObject(requireScene(project, sceneId), objectId);
export function requireHolder(project: Project, holderId: string): ObjectHolder {
const holder = findObjectHolder(project, holderId);
if (!holder) throw new CommandError(`Scene or container ${holderId} not found`);
return holder;
}
export function requireObject(project: Project, holderId: string, objectId: string): SceneObject {
const object = findObject(requireHolder(project, holderId), objectId);
if (!object) throw new CommandError(`Scene object ${objectId} not found`);
return object;
}
+89 -40
View File
@@ -1,6 +1,6 @@
import { newId } from '../ids';
import { isPlaceableType } from '../model/AssetTypes';
import { instantiateContainer } from '../model/container';
import { containerReaches, instantiateContainer } from '../model/container';
import { isSelfOrAncestor, localPositionFor, removeSubtrees, worldPosition } from '../model/hierarchy';
import {
type Position,
@@ -14,12 +14,23 @@ import { createSceneObject, type SceneObject } from '../model/SceneObject';
import { coerceValue } from '../properties/PropertySchema';
import { type Removed, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { requireAsset, requireObject, requirePropDef, requireScene } from './require';
import { requireAsset, requireObject, requirePropDef, requireHolder } from './require';
import { SetBagValueCommand } from './SetBagValueCommand';
import {
ALL_AXES,
nameSlot,
overrideSlot,
positionSlot,
restoreSlots,
type SlotMemo,
syncTemplateEdit,
} from './templateSync';
/**
* The position is a world position; with a parent it is converted to the parent's local space.
* Placing a container also creates its content (see `instantiateContainer`).
* Placing a container into a scene also creates its content (see `instantiateContainer`); into
* a container template it does not, because the template spells out one object per placed copy
* and `instantiateContainer` expands the nested one when the copy is made.
*/
export class PlaceAssetCommand implements Command {
readonly label = 'Place asset';
@@ -27,28 +38,35 @@ export class PlaceAssetCommand implements Command {
private createdIds: string[] = [];
constructor(
private readonly sceneId: string,
private readonly holderId: string,
private readonly assetId: string,
private readonly position: Vec2 | Position,
private readonly parentId = '',
) {}
execute(project: Project): void {
const scene = requireScene(project, this.sceneId);
const holder = requireHolder(project, this.holderId);
const asset = requireAsset(project, this.assetId);
if (!isPlaceableType(asset.type)) throw new CommandError('A scene cannot be placed into a scene');
if (this.parentId) requireObject(project, this.sceneId, this.parentId);
const local = localPositionFor(scene, this.parentId, toPosition(this.position));
if (
holder.type === 'container' &&
asset.type === 'container' &&
containerReaches(project, asset, holder.id)
)
throw new CommandError(`${asset.name} cannot contain ${holder.name}: that nests forever`);
if (this.parentId) requireObject(project, this.holderId, this.parentId);
const local = localPositionFor(holder, this.parentId, toPosition(this.position));
const created = [createSceneObject(this.objectId, this.assetId, local, this.parentId)];
if (asset.type === 'container') created.push(...instantiateContainer(project, asset, this.objectId));
if (asset.type === 'container' && holder.type === 'scene')
created.push(...instantiateContainer(project, asset, this.objectId));
this.createdIds = created.map((o) => o.id);
scene.objects.push(...created);
holder.objects.push(...created);
}
undo(project: Project): void {
const scene = requireScene(project, this.sceneId);
const holder = requireHolder(project, this.holderId);
const ids = new Set(this.createdIds);
scene.objects = scene.objects.filter((o) => !ids.has(o.id));
holder.objects = holder.objects.filter((o) => !ids.has(o.id));
}
}
@@ -58,25 +76,25 @@ export class RemoveSceneObjectsCommand implements Command {
private removed: Removed<SceneObject>[] = [];
constructor(
private readonly sceneId: string,
private readonly holderId: string,
private readonly objectIds: readonly string[],
) {
this.label = objectIds.length === 1 ? 'Remove object' : 'Remove objects';
}
execute(project: Project): void {
for (const id of this.objectIds) requireObject(project, this.sceneId, id);
this.removed = removeSubtrees(requireScene(project, this.sceneId), this.objectIds);
for (const id of this.objectIds) requireObject(project, this.holderId, id);
this.removed = removeSubtrees(requireHolder(project, this.holderId), this.objectIds);
}
undo(project: Project): void {
restoreAt(requireScene(project, this.sceneId).objects, this.removed);
restoreAt(requireHolder(project, this.holderId).objects, this.removed);
}
}
export class RemoveSceneObjectCommand extends RemoveSceneObjectsCommand {
constructor(sceneId: string, objectId: string) {
super(sceneId, [objectId]);
constructor(holderId: string, objectId: string) {
super(holderId, [objectId]);
}
}
@@ -87,21 +105,30 @@ export class RemoveSceneObjectCommand extends RemoveSceneObjectsCommand {
export class MoveSceneObjectCommand implements Command {
readonly label = 'Move object';
private previous: Position = { x: 0, y: 0, z: 0 };
private memos: SlotMemo<number>[] = [];
constructor(
private readonly sceneId: string,
private readonly holderId: string,
private readonly objectId: string,
private readonly position: Position,
) {}
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
const object = requireObject(project, this.holderId, this.objectId);
this.previous = { ...object.position };
object.position = { ...this.position };
this.memos = syncTemplateEdit(
project,
this.holderId,
object,
ALL_AXES.map(positionSlot),
ALL_AXES.map((axis) => this.previous[axis]),
);
}
undo(project: Project): void {
requireObject(project, this.sceneId, this.objectId).position = { ...this.previous };
restoreSlots(this.memos);
requireObject(project, this.holderId, this.objectId).position = { ...this.previous };
}
}
@@ -111,46 +138,51 @@ export class SetEditorOffsetCommand implements Command {
private previous: Vec2 = { x: 0, y: 0 };
constructor(
private readonly sceneId: string,
private readonly holderId: string,
private readonly objectId: string,
private readonly offset: Vec2,
) {}
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
const object = requireObject(project, this.holderId, this.objectId);
this.previous = { ...object.editorOffset };
object.editorOffset = { x: this.offset.x, y: this.offset.y };
}
undo(project: Project): void {
requireObject(project, this.sceneId, this.objectId).editorOffset = { ...this.previous };
requireObject(project, this.holderId, this.objectId).editorOffset = { ...this.previous };
}
}
export class RenameSceneObjectCommand implements Command {
readonly label = 'Rename object';
private previous = '';
private memos: SlotMemo<string>[] = [];
constructor(
private readonly sceneId: string,
private readonly holderId: string,
private readonly objectId: string,
private readonly name: string,
) {}
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
const object = requireObject(project, this.holderId, this.objectId);
this.previous = object.name;
object.name = this.name;
this.memos = syncTemplateEdit(project, this.holderId, object, [nameSlot], [this.previous]);
}
undo(project: Project): void {
requireObject(project, this.sceneId, this.objectId).name = this.previous;
restoreSlots(this.memos);
requireObject(project, this.holderId, this.objectId).name = this.previous;
}
}
abstract class OverrideCommand extends SetBagValueCommand<SceneObject> {
private memos: SlotMemo<PropertyValue | undefined>[] = [];
protected constructor(
private readonly sceneId: string,
private readonly holderId: string,
private readonly objectId: string,
key: string,
label: string,
@@ -159,22 +191,39 @@ abstract class OverrideCommand extends SetBagValueCommand<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 {
return object.overrides;
}
override execute(project: Project): void {
const before = this.resolveTarget(project).overrides[this.key];
super.execute(project);
this.memos = syncTemplateEdit(
project,
this.holderId,
this.resolveTarget(project),
[overrideSlot(this.key)],
[before],
);
}
override undo(project: Project): void {
restoreSlots(this.memos);
super.undo(project);
}
}
export class SetOverrideCommand extends OverrideCommand {
constructor(
sceneId: string,
holderId: string,
objectId: string,
key: string,
private readonly value: PropertyValue,
) {
super(sceneId, objectId, key, `Override ${key}`);
super(holderId, objectId, key, `Override ${key}`);
}
protected override computeNext(object: SceneObject, project: Project): PropertyValue {
@@ -183,8 +232,8 @@ export class SetOverrideCommand extends OverrideCommand {
}
export class ClearOverrideCommand extends OverrideCommand {
constructor(sceneId: string, objectId: string, key: string) {
super(sceneId, objectId, key, `Reset ${key}`);
constructor(holderId: string, objectId: string, key: string) {
super(holderId, objectId, key, `Reset ${key}`);
}
// No schema check, unlike setting one: a key the asset no longer declares is exactly what one
@@ -206,28 +255,28 @@ export class SetParentCommand implements Command {
};
constructor(
private readonly sceneId: string,
private readonly holderId: string,
private readonly objectId: string,
private readonly parentId: string,
) {}
execute(project: Project): void {
const scene = requireScene(project, this.sceneId);
const object = requireObject(project, this.sceneId, this.objectId);
const holder = requireHolder(project, this.holderId);
const object = requireObject(project, this.holderId, this.objectId);
if (this.parentId) {
if (this.parentId === this.objectId) throw new CommandError('An object cannot be its own parent');
requireObject(project, this.sceneId, this.parentId);
if (isSelfOrAncestor(scene, this.objectId, this.parentId))
requireObject(project, this.holderId, this.parentId);
if (isSelfOrAncestor(holder, this.objectId, this.parentId))
throw new CommandError('An object cannot be parented to one of its own children');
}
this.previous = { parentId: object.parentId, position: { ...object.position } };
const world = worldPosition(scene, object);
const world = worldPosition(holder, object);
object.parentId = this.parentId;
object.position = localPositionFor(scene, this.parentId, world);
object.position = localPositionFor(holder, this.parentId, world);
}
undo(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
const object = requireObject(project, this.holderId, this.objectId);
object.parentId = this.previous.parentId;
object.position = { ...this.previous.position };
}
+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 function isObjectHolder(asset: Asset): asset is ObjectHolder {
return asset.type === 'scene' || asset.type === 'container';
}
export function objectHolders(project: Project): ObjectHolder[] {
return project.assets.filter((a): a is ObjectHolder => a.type === 'scene' || a.type === 'container');
return project.assets.filter(isObjectHolder);
}
/**
* The scene or container asset an object belongs to. Editing a container's template is the same
* work as editing a scene, so the object commands resolve their target through this rather than
* through `findScene`.
*/
export function findObjectHolder(project: Project, holderId: string): ObjectHolder | undefined {
const asset = findAsset(project, holderId);
return asset && isObjectHolder(asset) ? asset : undefined;
}
export function listContainers(project: Project): ContainerAsset[] {
return project.assets.filter((a): a is ContainerAsset => a.type === 'container');
}
export interface SceneObjectRef {
+21
View File
@@ -37,6 +37,27 @@ export function instancesOf(project: Project, templateId: string): SceneObjectRe
return objectsWhere(project, (o) => o.templateId === templateId);
}
/**
* Whether `wantedId` is that container or sits somewhere in its content. Editing a container's
* template can place containers into it, and a cycle would expand into thousands of objects the
* moment one of them is placed.
*/
export function containerReaches(project: Project, asset: ContainerAsset, wantedId: string): boolean {
if (asset.id === wantedId) return true;
const seen = new Set([asset.id]);
const queue = [asset];
for (const current of queue) {
for (const template of current.objects) {
const nested = findAsset(project, template.assetId);
if (nested?.type !== 'container' || seen.has(nested.id)) continue;
if (nested.id === wantedId) return true;
seen.add(nested.id);
queue.push(nested);
}
}
return false;
}
const MAX_NESTING = 8;
/**
+44 -10
View File
@@ -1,13 +1,15 @@
import type { Application } from 'pixi.js';
import { findAsset, findObjectHolder } from '../core/model/Project';
import type { ProjectStore } from '../core/store/ProjectStore';
import type { TextureStore } from '../core/store/TextureStore';
import { Button, HStack, Splitter, Theme, ui, VStack } from '../ui';
import { GameViewport } from '../viewport/GameViewport';
import { SceneViewport } from '../viewport/SceneViewport';
import { attachAutosave } from './autosave';
import { ContainerEditor } from './container/ContainerEditor';
import { GraphEditor } from './graph/GraphEditor';
import { Inspector } from './inspector/Inspector';
import { AssetPanel } from './panels/AssetPanel';
import { AssetPanel, type PlacementTarget } from './panels/AssetPanel';
import { HierarchyPanel } from './panels/HierarchyPanel';
import { Toolbar } from './panels/Toolbar';
import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession';
@@ -17,9 +19,9 @@ import type { EditorState } from './state';
import { TextureEditor } from './texture/TextureEditor';
import { installTextureImport } from './textureImport';
export type CentreTab = 'scene' | 'graph' | 'game' | 'texture';
export type CentreTab = 'scene' | 'graph' | 'game' | 'texture' | 'container';
const TAB_ORDER = ['scene', 'game', 'graph', 'texture'] as const;
const TAB_ORDER = ['scene', 'game', 'graph', 'texture', 'container'] as const;
const MIN_LEFT_WIDTH = 160;
const MAX_LEFT_WIDTH = 500;
@@ -38,6 +40,7 @@ export class Editor implements PlayHost {
readonly gameView: GameViewport;
readonly graphEditor: GraphEditor;
readonly textureEditor: TextureEditor;
readonly containerEditor: ContainerEditor;
readonly hierarchy: HierarchyPanel;
tab: CentreTab = 'scene';
/** Frame rate, refreshed twice a second; null until the first measurement. */
@@ -60,7 +63,7 @@ export class Editor implements PlayHost {
constructor(
app: Application,
root: VStack,
state: EditorState,
private readonly state: EditorState,
store: ProjectStore,
textures: TextureStore,
) {
@@ -68,6 +71,7 @@ export class Editor implements PlayHost {
this.gameView = new GameViewport(state);
this.graphEditor = new GraphEditor(state);
this.textureEditor = new TextureEditor(state);
this.containerEditor = new ContainerEditor(state);
this.hierarchy = new HierarchyPanel(state);
this.inspector = new Inspector(state);
this.play = new PlaySession(state, this);
@@ -87,7 +91,7 @@ export class Editor implements PlayHost {
this.workspace = new HStack();
this.leftColumn = new VStack({ gap: 1 });
this.leftColumn.add(new AssetPanel(state, () => this.viewport.worldCenter(), textures), 1);
this.leftColumn.add(new AssetPanel(state, () => this.placementTarget(), textures), 1);
installTextureImport(app.canvas, { state, textures });
this.leftColumn.add(this.hierarchy, 1);
@@ -103,6 +107,11 @@ export class Editor implements PlayHost {
width: 90,
onClick: () => this.showTab('texture'),
}),
container: new Button('Container', {
variant: 'ghost',
width: 90,
onClick: () => this.showTab('container'),
}),
};
for (const tab of TAB_ORDER) tabs.add(this.tabButtons[tab]);
this.centre.add(tabs);
@@ -110,9 +119,11 @@ export class Editor implements PlayHost {
this.centre.add(this.graphEditor, 1);
this.centre.add(this.gameView, 1);
this.centre.add(this.textureEditor, 1);
this.centre.add(this.containerEditor, 1);
this.graphEditor.visible = false;
this.gameView.visible = false;
this.textureEditor.visible = false;
this.containerEditor.visible = false;
this.tabButtons.scene.setToggled(true);
this.leftColumn.minWidth = this.leftWidth;
@@ -138,12 +149,18 @@ export class Editor implements PlayHost {
attachAutosave(state, store);
installShortcuts(state, { play: () => this.play.play(), stop: () => this.play.stop() });
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
// A node and a frame exist only in their own tab, so selecting one switches to it;
// selecting the behavior or texture asset itself does not.
// A node, a frame and a template object exist only in their own tab, so selecting one
// switches to it; selecting the behavior, texture or container asset itself does not.
state.on('selection', () => {
const kind = state.selection?.kind;
if (kind === 'node' && this.tab !== 'graph') this.showTab('graph');
if (kind === 'frame' && this.tab !== 'texture') this.showTab('texture');
const s = state.selection;
if (s?.kind === 'node' && this.tab !== 'graph') this.showTab('graph');
if (s?.kind === 'frame' && this.tab !== 'texture') this.showTab('texture');
if (
s?.kind === 'object' &&
this.tab !== 'container' &&
findAsset(state.project, s.holderId)?.type === 'container'
)
this.showTab('container');
});
state.on('project', () => this.play.syncFromModel());
}
@@ -154,11 +171,28 @@ export class Editor implements PlayHost {
this.graphEditor.visible = tab === 'graph';
this.gameView.visible = tab === 'game';
this.textureEditor.visible = tab === 'texture';
this.containerEditor.visible = tab === 'container';
// The hierarchy lists what the visible tab edits; every other tab is about the scene.
this.hierarchy.showObjectsOf(
tab === 'container'
? () => findObjectHolder(this.state.project, this.containerEditor.currentAssetId)
: () => this.state.activeScene,
);
for (const key of TAB_ORDER) this.tabButtons[key].setToggled(key === tab);
this.centre.relayout();
ui().focus.validate();
}
/** Where the Assets panel places: into the container being edited, otherwise into the scene. */
private placementTarget(): PlacementTarget | undefined {
if (this.tab === 'container') {
const holder = findObjectHolder(this.state.project, this.containerEditor.currentAssetId);
return holder ? { holder, at: this.containerEditor.worldCenter() } : undefined;
}
const scene = this.state.activeScene;
return scene ? { holder: scene, at: this.viewport.worldCenter() } : undefined;
}
boot(): Promise<void> {
return this.projects.boot();
}
+8 -8
View File
@@ -12,24 +12,24 @@ import { Dialog } from '../ui';
import { type EditorState, objectSelection } from './state';
/** Groups the objects into a new container asset placed in their stead; selects the container. */
export function createContainer(state: EditorState, sceneId: string, objectIds: readonly string[]): boolean {
const command = new CreateContainerCommand(sceneId, objectIds);
export function createContainer(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
const command = new CreateContainerCommand(holderId, objectIds);
if (!state.execute(command)) return false;
state.select(objectSelection(sceneId, [command.objectId]));
state.select(objectSelection(holderId, [command.objectId]));
return true;
}
/** Gives the object a collider child sized like it; selects the collider. */
export function addCollider(state: EditorState, sceneId: string, objectId: string): boolean {
const command = new AddColliderCommand(sceneId, objectId);
export function addCollider(state: EditorState, holderId: string, objectId: string): boolean {
const command = new AddColliderCommand(holderId, objectId);
if (!state.execute(command)) return false;
state.select(objectSelection(sceneId, [command.objectId]));
state.select(objectSelection(holderId, [command.objectId]));
return true;
}
/** Removes the objects (with their subtrees) as one undo step. */
export function removeObjects(state: EditorState, sceneId: string, objectIds: readonly string[]): boolean {
return state.execute(new RemoveSceneObjectsCommand(sceneId, objectIds));
export function removeObjects(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
return state.execute(new RemoveSceneObjectsCommand(holderId, objectIds));
}
/** Asks before deleting an asset, spelling out what goes with it. */
+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.
*/
export function installDebugHook(app: Application, state: EditorState, editor: Editor): void {
const { viewport, gameView, graphEditor, textureEditor, hierarchy } = editor;
const { viewport, gameView, graphEditor, textureEditor, containerEditor, hierarchy } = editor;
const centreOf = (widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }) => {
const p = widget.getGlobalPosition();
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
@@ -135,6 +135,14 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
/** Where an image pixel of the shown sheet sits on screen (frames are dragged in image coordinates). */
pointAt: (x: number, y: number) => textureEditor.imageToScreen(x, y),
},
container: {
current: () => containerEditor.currentAssetId,
pickerWidth: () => containerEditor.pickerWidth,
center: () => centreOf(containerEditor),
rect: () => containerEditor.canvasRect(),
/** Where a template object is drawn, so a check can click or sample it. */
objectScreen: (id: string) => containerEditor.objectScreen(id),
},
world: () => editor.worldSnapshot(),
/** Rows the hierarchy shows – collapsed subtrees are not in here. */
hierarchyOrder: () => hierarchy.rowOrder(),
+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 === 'node') buildNodeSection(this.host, s.assetId, s.nodeId);
else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId);
else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.sceneId, s.objectIds);
else buildObjectSection(this.host, s.sceneId, s.objectId);
else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.holderId, s.objectIds);
else buildObjectSection(this.host, s.holderId, s.objectId);
this.scroll.setContent(this.host.body);
}
@@ -83,7 +83,7 @@ export class Inspector extends Panel {
if (s.kind === 'asset') return assetSignature(this.state.project, s.assetId);
if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId);
if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId);
return objectSignature(this.state.project, s.sceneId, s.objectIds, s.objectId);
return objectSignature(this.state.project, s.holderId, s.objectIds, s.objectId);
}
/** Pushes current model values into the widgets (after undo, drag in viewport, …). */
@@ -101,7 +101,7 @@ export class Inspector extends Panel {
? refreshNodeSection(this.host, s.assetId, s.nodeId)
: s.kind === 'frame'
? refreshFrameSection(this.host, s.assetId, s.frameId)
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.sceneId, s.objectId);
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
if (!ok) this.rebuild();
}
}
+73 -73
View File
@@ -14,11 +14,11 @@ import {
SetOverrideCommand,
SetParentCommand,
} from '../../core/commands/sceneObjectCommands';
import type { Asset, SceneAsset } from '../../core/model/Asset';
import type { Asset } from '../../core/model/Asset';
import { type TemplateLink, templateLinkOf } from '../../core/model/container';
import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
import type { Axis, PropertyBag, PropertyValue } from '../../core/model/Properties';
import { findAsset, findScene, type Project } from '../../core/model/Project';
import { findAsset, findObjectHolder, type ObjectHolder, type Project } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject';
import {
COMMON_PROPS,
@@ -46,13 +46,13 @@ const offsetKey = (axis: 'x' | 'y') => `editorOffset${axis.toUpperCase()}`;
/** Structure-defining facts of an object selection: parent, template link and schema. */
export function objectSignature(
project: Project,
sceneId: string,
holderId: string,
objectIds: readonly string[],
objectId: string,
): string {
if (objectIds.length > 1) return `multi|${objectIds.join(',')}`;
const scene = findScene(project, sceneId);
const object = scene && findObject(scene, objectId);
const holder = findObjectHolder(project, holderId);
const object = holder && findObject(holder, objectId);
const asset = object && findAsset(project, object.assetId);
if (!object || !asset) return 'missing';
return (
@@ -64,44 +64,44 @@ export function objectSignature(
}
/** Several objects: what they are, and the actions that apply to all of them at once. */
export function buildObjectsSection(host: InspectorHost, sceneId: string, objectIds: string[]): void {
const scene = findScene(host.state.project, sceneId);
if (!scene) return;
export function buildObjectsSection(host: InspectorHost, holderId: string, objectIds: string[]): void {
const holder = findObjectHolder(host.state.project, holderId);
if (!holder) return;
addTitle(host, `${objectIds.length} OBJECTS SELECTED`);
for (const id of objectIds) {
const object = findObject(scene, id);
const object = findObject(holder, id);
const asset = object && findAsset(host.state.project, object.assetId);
if (object && asset) addNote(host, `${object.name || asset.name} · ${asset.type}`);
}
addGap(host);
addNote(host, 'Ctrl-click adds or removes objects.', 'small');
addNote(host, 'Dragging one in the scene moves all of them.', 'small');
addNote(host, 'Dragging one in the viewport moves all of them.', 'small');
addGap(host);
host.body.add(
new Button('Create container', { onClick: () => createContainer(host.state, sceneId, objectIds) }),
new Button('Create container', { onClick: () => createContainer(host.state, holderId, objectIds) }),
);
addNote(host, 'Groups the selection into a reusable container', 'small');
addNote(host, 'asset placed at its centre (Ctrl+G).', 'small');
addGap(host);
host.body.add(
new Button('Remove from scene', {
new Button('Remove', {
variant: 'danger',
onClick: () => removeObjects(host.state, sceneId, objectIds),
onClick: () => removeObjects(host.state, holderId, objectIds),
}),
);
}
/** One object: name, asset, parent, container link, the switch, position, editor offset, properties, collider button. */
export function buildObjectSection(host: InspectorHost, sceneId: string, objectId: string): void {
const scene = findScene(host.state.project, sceneId);
const object = scene && findObject(scene, objectId);
export function buildObjectSection(host: InspectorHost, holderId: string, objectId: string): void {
const holder = findObjectHolder(host.state.project, holderId);
const object = holder && findObject(holder, objectId);
const asset = object && findAsset(host.state.project, object.assetId);
if (!scene || !object || !asset) return;
if (!holder || !object || !asset) return;
addTitle(host, `${asset.type.toUpperCase()} OBJECT`);
host.nameField = new TextField(object.name, {
placeholder: asset.name,
onChange: (name) => host.state.execute(new RenameSceneObjectCommand(sceneId, objectId, name.trim())),
onChange: (name) => host.state.execute(new RenameSceneObjectCommand(holderId, objectId, name.trim())),
});
const nameRow = labelledRow('Name');
nameRow.row.add(host.nameField, 1);
@@ -117,10 +117,10 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
);
host.body.add(assetRow.row);
host.body.add(parentRow(host, scene, object));
host.body.add(parentRow(host, holder, object));
// Part of a placed container: values compare against the template, not just the asset.
const link = templateLinkOf(host.state.project, scene, object);
const link = templateLinkOf(host.state.project, holder, object);
if (link) {
const linkRow = labelledRow('Part of');
linkRow.row.add(
@@ -135,25 +135,25 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
// The master switch, right where it is looked for: above everything type-specific.
for (const def of COMMON_PROPS)
propertyRow(host, sceneId, objectId, asset, def, resolveSceneObjectProps(asset, object));
propertyRow(host, holderId, objectId, asset, def, resolveSceneObjectProps(asset, object));
addTitle(host, object.parentId ? 'POSITION (LOCAL TO PARENT)' : 'POSITION');
for (const axis of POSITION_AXES) {
const moved = () => positionMoved(host.state, sceneId, objectId, axis);
componentRow(host, sceneId, objectId, object.position[axis], {
const moved = () => positionMoved(host.state, holderId, objectId, axis);
componentRow(host, holderId, objectId, object.position[axis], {
key: axis,
label: axis.toUpperCase(),
axis,
read: (o) => o.position,
write: (o, v) => (o.position = v),
command: (v) => new MoveSceneObjectCommand(sceneId, objectId, v),
command: (v) => new MoveSceneObjectCommand(holderId, objectId, v),
reset: () =>
moved()
? [
{
label: 'Reset to container',
onSelect: () =>
host.state.execute(new RevertPositionToTemplateCommand(sceneId, objectId, axis)),
host.state.execute(new RevertPositionToTemplateCommand(holderId, objectId, axis)),
},
]
: [],
@@ -163,33 +163,33 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
{
label: 'Apply to container',
onSelect: () =>
host.state.execute(new ApplyPositionToContainerCommand(sceneId, objectId, axis)),
host.state.execute(new ApplyPositionToContainerCommand(holderId, objectId, axis)),
},
]
: [],
});
}
// Where the object is *drawn* in the scene view, nothing else – for pulling stacked objects apart.
// Where the object is *drawn* in the viewport, nothing else – for pulling stacked objects apart.
addTitle(host, 'EDITOR OFFSET (SCENE VIEW ONLY)');
for (const axis of OFFSET_AXES) {
componentRow(host, sceneId, objectId, object.editorOffset[axis], {
componentRow(host, holderId, objectId, object.editorOffset[axis], {
key: offsetKey(axis),
label: `Offset ${axis.toUpperCase()}`,
axis,
read: (o) => o.editorOffset,
write: (o, v) => (o.editorOffset = v),
command: (v) => new SetEditorOffsetCommand(sceneId, objectId, v),
command: (v) => new SetEditorOffsetCommand(holderId, objectId, v),
// No apply: the offset never leaves the placed object.
reset: () => {
const current = currentObject(host.state, sceneId, objectId);
const current = currentObject(host.state, holderId, objectId);
if (!current || current.editorOffset[axis] === 0) return [];
return [
{
label: 'Reset to 0',
onSelect: () =>
host.state.execute(
new SetEditorOffsetCommand(sceneId, objectId, { ...current.editorOffset, [axis]: 0 }),
new SetEditorOffsetCommand(holderId, objectId, { ...current.editorOffset, [axis]: 0 }),
),
},
];
@@ -204,7 +204,7 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
if (own.length > 0 || asset.type === 'behavior')
addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.');
for (const def of own) propertyRow(host, sceneId, objectId, asset, def, values);
for (const def of own) propertyRow(host, holderId, objectId, asset, def, values);
markObjectRows(host, object, asset, link);
if (asset.type === 'container') {
@@ -219,24 +219,24 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
host.body.add(
new Button('+ Add collider', {
variant: 'ghost',
onClick: () => addCollider(host.state, sceneId, objectId),
onClick: () => addCollider(host.state, holderId, objectId),
}),
);
}
}
/** Pushes the current model values into the rows. False when the object is gone (rebuild). */
export function refreshObjectSection(host: InspectorHost, sceneId: string, objectId: string): boolean {
const scene = findScene(host.state.project, sceneId);
const object = scene && findObject(scene, objectId);
export function refreshObjectSection(host: InspectorHost, holderId: string, objectId: string): boolean {
const holder = findObjectHolder(host.state.project, holderId);
const object = holder && findObject(holder, objectId);
const asset = object && findAsset(host.state.project, object.assetId);
if (!scene || !object || !asset) return false;
if (!holder || !object || !asset) return false;
host.nameField?.setValue(object.name);
for (const axis of POSITION_AXES) host.fields.get(axis)?.setValue(object.position[axis]);
for (const axis of OFFSET_AXES) host.fields.get(offsetKey(axis))?.setValue(object.editorOffset[axis]);
const values = resolveSceneObjectProps(asset, object);
for (const def of schemaForAsset(asset)) host.fields.get(def.key)?.setValue(values[def.key] ?? def.default);
markObjectRows(host, object, asset, templateLinkOf(host.state.project, scene, object));
markObjectRows(host, object, asset, templateLinkOf(host.state.project, holder, object));
return true;
}
@@ -258,7 +258,7 @@ interface ComponentSpec<V extends object> {
*/
function componentRow<V extends object>(
host: InspectorHost,
sceneId: string,
holderId: string,
objectId: string,
value: number,
spec: ComponentSpec<V>,
@@ -269,7 +269,7 @@ function componentRow<V extends object>(
{ key: spec.key, label: spec.label, kind: 'number', default: 0, step: 1 },
value,
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.
read: (object) => ({ ...spec.read(object) }),
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).
*
* 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(
host: InspectorHost,
sceneId: string,
holderId: string,
objectId: string,
owner: Asset,
def: PropertyDef,
values: Record<string, PropertyValue>,
): FieldRow {
const bases = () => deviations(host.state, sceneId, objectId, def.key);
const bases = () => deviations(host.state, holderId, objectId, def.key);
const row = createFieldRow(
host,
def,
values[def.key] ?? def.default,
previewingHandlers<SceneObject>(host, {
target: () => currentObject(host.state, sceneId, objectId),
target: () => currentObject(host.state, holderId, objectId),
read: (object) => object.overrides[def.key],
write: (object, value) => (object.overrides[def.key] = value),
remove: (object) => delete object.overrides[def.key],
commit: (value) => new SetOverrideCommand(sceneId, objectId, def.key, value),
commit: (value) => new SetOverrideCommand(holderId, objectId, def.key, value),
}),
{
extra: aspectActions(
host,
owner,
def,
() => currentProps(host.state, sceneId, objectId),
(value) => host.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value)),
() => currentProps(host.state, holderId, objectId),
(value) => host.state.execute(new SetOverrideCommand(holderId, objectId, def.key, value)),
),
reset: () => {
const { template, asset } = bases();
@@ -327,7 +327,7 @@ function propertyRow(
{
label: 'Reset to container',
onSelect: () =>
host.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)),
host.state.execute(new RevertOverrideToTemplateCommand(holderId, objectId, def.key)),
},
]
: []),
@@ -335,7 +335,7 @@ function propertyRow(
? [
{
label: 'Reset to asset',
onSelect: () => host.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)),
onSelect: () => host.state.execute(new ClearOverrideCommand(holderId, objectId, def.key)),
},
]
: []),
@@ -349,7 +349,7 @@ function propertyRow(
{
label: 'Apply to container',
onSelect: () =>
host.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)),
host.state.execute(new ApplyOverrideToContainerCommand(holderId, objectId, def.key)),
},
]
: []),
@@ -358,7 +358,7 @@ function propertyRow(
{
label: 'Apply to asset',
onSelect: () =>
host.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)),
host.state.execute(new ApplyOverrideToAssetCommand(holderId, objectId, def.key)),
},
]
: []),
@@ -372,27 +372,27 @@ function propertyRow(
}
/**
* "Parent: <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.
*/
function parentRow(host: InspectorHost, scene: SceneAsset, object: SceneObject) {
function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObject) {
const { row } = labelledRow('Parent');
const nameOf = (o: SceneObject) => o.name || findAsset(host.state.project, o.assetId)?.name || '?';
const parent = findObject(scene, object.parentId);
const parent = findObject(holder, object.parentId);
const button = new Dropdown(parent ? nameOf(parent) : 'None');
button.onClick = () => {
const items: MenuItem[] = [
{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, '')) },
{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(holder.id, object.id, '')) },
];
for (const { object: candidate, depth } of treeOrder(scene)) {
if (isSelfOrAncestor(scene, object.id, candidate.id)) continue;
for (const { object: candidate, depth } of treeOrder(holder)) {
if (isSelfOrAncestor(holder, object.id, candidate.id)) continue;
const name = `${' '.repeat(depth)}${nameOf(candidate)}`;
items.push({
label: candidate.id === object.parentId ? `● ${name}` : name,
onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, candidate.id)),
onSelect: () => host.state.execute(new SetParentCommand(holder.id, object.id, candidate.id)),
});
}
// Searchable: this lists every object of the scene, which grows with the project.
// Searchable: this lists every object of the scene or container, which grows with the project.
new Menu(items, { searchable: true }).openBelow(button);
};
row.add(button, 1);
@@ -431,15 +431,15 @@ function markObjectRows(
*/
function deviations(
state: InspectorHost['state'],
sceneId: string,
holderId: string,
objectId: string,
key: string,
): { template: boolean; asset: boolean } {
const scene = findScene(state.project, sceneId);
const object = scene && findObject(scene, objectId);
const holder = findObjectHolder(state.project, holderId);
const object = holder && findObject(holder, objectId);
const asset = object && findAsset(state.project, object.assetId);
if (!scene || !object || !asset) return { template: false, asset: false };
const link = templateLinkOf(state.project, scene, object);
if (!holder || !object || !asset) return { template: false, asset: false };
const link = templateLinkOf(state.project, holder, object);
return {
template: Boolean(link) && deviatesFromTemplate(object, link!.template, key),
asset: deviatesFromAsset(object, asset, key),
@@ -449,28 +449,28 @@ function deviations(
/** Whether the object sits somewhere else on `axis` than its container template says. */
function positionMoved(
state: InspectorHost['state'],
sceneId: string,
holderId: string,
objectId: string,
axis: Axis,
): boolean {
const scene = findScene(state.project, sceneId);
const object = scene && findObject(scene, objectId);
const link = scene && object && templateLinkOf(state.project, scene, object);
const holder = findObjectHolder(state.project, holderId);
const object = holder && findObject(holder, objectId);
const link = holder && object && templateLinkOf(state.project, holder, object);
return Boolean(link) && object!.position[axis] !== link!.template.position[axis];
}
function currentObject(
state: InspectorHost['state'],
sceneId: string,
holderId: string,
objectId: string,
): SceneObject | undefined {
const scene = findScene(state.project, sceneId);
return scene && findObject(scene, objectId);
const holder = findObjectHolder(state.project, holderId);
return holder && findObject(holder, objectId);
}
/** The values the object shows right now, asset defaults and overrides merged. */
function currentProps(state: InspectorHost['state'], sceneId: string, objectId: string): PropertyBag | null {
const object = currentObject(state, sceneId, objectId);
function currentProps(state: InspectorHost['state'], holderId: string, objectId: string): PropertyBag | null {
const object = currentObject(state, holderId, objectId);
const asset = object && findAsset(state.project, object.assetId);
return object && asset ? resolveSceneObjectProps(asset, object) : null;
}
+25 -12
View File
@@ -3,13 +3,22 @@ import { nextAssetName } from '../../core/commands/naming';
import { PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType, hasColorSwatch } from '../../core/model/AssetTypes';
import type { Vec2 } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import { findAsset, type ObjectHolder } from '../../core/model/Project';
import type { TextureStore } from '../../core/store/TextureStore';
import { Button, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui';
import { confirmDeleteAsset } from '../actions';
import { type EditorState, objectSelection } from '../state';
import { pickAndImport } from '../textureImport';
/**
* Where a placed asset lands. The tab in front decides, so it appears where the user is
* looking: the Scene tab places into the active scene, the Container tab into the template.
*/
export interface PlacementTarget {
holder: ObjectHolder;
at: Vec2;
}
/** Lists all assets of the project; creates, places and deletes them. */
export class AssetPanel extends Panel {
private readonly list = new ListView();
@@ -17,7 +26,7 @@ export class AssetPanel extends Panel {
constructor(
private readonly state: EditorState,
private readonly placementPoint: () => Vec2,
private readonly target: () => PlacementTarget | undefined,
/** Where imported images go. Without it the Texture entry has nothing to upload to. */
private readonly textures: TextureStore | null = null,
) {
@@ -92,18 +101,19 @@ export class AssetPanel extends Panel {
}
private place(assetId: string): void {
const scene = this.state.activeScene;
if (!scene) {
this.state.setNotice('Create a scene first, then place assets into it.');
const target = this.target();
if (!target) {
this.state.setNotice('Nothing to place into – create a scene, or open a container first.');
return;
}
const command = new PlaceAssetCommand(scene.id, assetId, this.placementPoint());
if (this.state.execute(command)) this.state.select(objectSelection(scene.id, [command.objectId]));
const { holder, at } = target;
const command = new PlaceAssetCommand(holder.id, assetId, at);
if (this.state.execute(command)) this.state.select(objectSelection(holder.id, [command.objectId]));
}
private openContextMenu(id: string, x: number, y: number): void {
private openContextMenu(id: string, x: number, y: number): Menu | undefined {
const asset = findAsset(this.state.project, id);
if (!asset) return;
if (!asset) return undefined;
const items: MenuItem[] = [];
if (asset.type === 'scene') {
items.push({
@@ -112,13 +122,16 @@ export class AssetPanel extends Panel {
disabled: this.state.project.activeSceneId === id,
});
} else {
const target = this.target();
items.push({
label: 'Place in scene',
label: target?.holder.type === 'container' ? 'Place in container' : 'Place in scene',
onSelect: () => this.place(id),
disabled: !this.state.activeScene,
disabled: !target,
});
}
items.push({ label: 'Delete…', danger: true, onSelect: () => confirmDeleteAsset(this.state, id) });
new Menu(items).open(x, y);
const menu = new Menu(items);
menu.open(x, y);
return menu;
}
}
+64 -32
View File
@@ -1,14 +1,14 @@
import { SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { hasColorSwatch } from '../../core/model/AssetTypes';
import { ancestorIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy';
import { findAsset } from '../../core/model/Project';
import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { resolveSceneObjectProps } from '../../core/properties/resolve';
import { ListView, Menu, Panel, ScrollContainer } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions';
import { type EditorState, objectSelection } from '../state';
/**
* Objects placed in the active scene as a tree (children indented below their parent).
* Objects of the scene or container being edited, as a tree (children indented below their parent).
* Click selects, Ctrl/Shift-click adds to or removes from the selection. Drag a row onto
* another to parent it there; drop it on the empty space below the rows to move it to the
* top level. The object keeps its world position either way.
@@ -22,24 +22,31 @@ export class HierarchyPanel extends Panel {
private readonly scroll = new ScrollContainer();
/** Ids of the objects whose children are shown. Anything missing is collapsed. */
private readonly expanded = new Set<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) {
super({ title: 'Scene objects', padding: 0, gap: 0 });
this.minWidth = 160;
this.list.emptyText = 'Place assets here';
this.list.onSelect = (id, modifiers) => {
const scene = state.activeScene;
if (!scene) return;
if (id && modifiers && (modifiers.ctrl || modifiers.shift)) state.toggleObject(scene.id, id);
else state.select(id ? objectSelection(scene.id, [id]) : null);
const holder = this.holder();
if (!holder) return;
if (id && modifiers && (modifiers.ctrl || modifiers.shift)) state.toggleObject(holder.id, id);
else state.select(id ? objectSelection(holder.id, [id]) : null);
};
this.list.onDrop = (draggedId, targetId) => {
const scene = state.activeScene;
const object = scene && findObject(scene, draggedId);
if (!scene || !object || object.parentId === (targetId ?? '')) return;
const holder = this.holder();
const object = holder && findObject(holder, draggedId);
if (!holder || !object || object.parentId === (targetId ?? '')) return;
// Unfold the new parent, otherwise the dragged row would seem to vanish on drop.
if (targetId) this.expanded.add(targetId);
state.execute(new SetParentCommand(scene.id, draggedId, targetId ?? ''));
state.execute(new SetParentCommand(holder.id, draggedId, targetId ?? ''));
};
this.list.onToggle = (id, expand) => this.setExpanded(id, expand);
this.list.onActivate = (id) => this.setExpanded(id, !this.expanded.has(id));
@@ -53,14 +60,33 @@ export class HierarchyPanel extends Panel {
this.expanded.clear();
this.refresh();
});
state.on('selection', () => (this.revealSelection() ? this.refresh() : this.syncSelection()));
state.on('selection', () => {
// The Container tab's picker only changes the selection, which swaps the holder under this
// panel; without the id check the title and rows would keep naming the previous one.
const swapped = (this.holder()?.id ?? '') !== this.shownHolderId;
if (this.revealSelection() || swapped) this.refresh();
else this.syncSelection();
});
this.refresh();
}
/**
* Lists another holder's objects – the Container tab hands in its template, the Scene tab the
* active scene again.
*/
showObjectsOf(source: () => ObjectHolder | undefined): void {
this.source = source;
this.refresh();
}
private holder(): ObjectHolder | undefined {
return this.source();
}
/** Ids of the rows currently shown – subtrees of collapsed objects are not among them (test hook). */
rowOrder(): string[] {
const scene = this.state.activeScene;
return scene ? visibleTreeOrder(scene, this.expanded).map((e) => e.object.id) : [];
const holder = this.holder();
return holder ? visibleTreeOrder(holder, this.expanded).map((e) => e.object.id) : [];
}
/** Global centre of a row's chevron, so tests can click the real hit area (test hook). */
@@ -69,8 +95,7 @@ export class HierarchyPanel extends Panel {
}
expandAll(): void {
const scene = this.state.activeScene;
for (const object of scene?.objects ?? []) this.expanded.add(object.id);
for (const object of this.holder()?.objects ?? []) this.expanded.add(object.id);
this.refresh();
}
@@ -81,16 +106,17 @@ export class HierarchyPanel extends Panel {
this.refresh();
}
private openContextMenu(id: string, x: number, y: number): void {
private openContextMenu(id: string, x: number, y: number): Menu | undefined {
const { state } = this;
const scene = state.activeScene;
if (!scene) return;
const object = findObject(scene, id);
const holder = this.holder();
if (!holder) return undefined;
const inScene = holder.type === 'scene';
const object = findObject(holder, id);
const type = object && findAsset(state.project, object.assetId)?.type;
// Inside a multi-selection the menu acts on all selected objects.
const selected = state.selectedObjectIds.includes(id) ? state.selectedObjectIds : [id];
const many = selected.length > 1;
new Menu([
const menu = new Menu([
{
label: 'Select asset',
onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }),
@@ -98,25 +124,29 @@ export class HierarchyPanel extends Panel {
{
label: 'Add collider',
disabled: !type || type === 'collider' || many,
onSelect: () => addCollider(state, scene.id, id),
onSelect: () => addCollider(state, holder.id, id),
},
{
label: many ? `Create container from ${selected.length} objects` : 'Create container',
onSelect: () => createContainer(state, scene.id, selected),
onSelect: () => createContainer(state, holder.id, selected),
},
{
label: 'Unparent (move to top level)',
disabled: !object?.parentId || many,
onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')),
onSelect: () => state.execute(new SetParentCommand(holder.id, id, '')),
},
{ label: 'Expand all', onSelect: () => this.expandAll() },
{ label: 'Collapse all', onSelect: () => this.collapseAll() },
{
label: many ? `Remove ${selected.length} objects from scene` : 'Remove from scene',
label: many
? `Remove ${selected.length} objects from ${inScene ? 'scene' : 'container'}`
: `Remove from ${inScene ? 'scene' : 'container'}`,
danger: true,
onSelect: () => removeObjects(state, scene.id, selected),
onSelect: () => removeObjects(state, holder.id, selected),
},
]).open(x, y);
]);
menu.open(x, y);
return menu;
}
private setExpanded(id: string, expand: boolean): void {
@@ -127,11 +157,11 @@ export class HierarchyPanel extends Panel {
/** Unfolds the ancestors of every selected object. Returns whether that changed anything. */
private revealSelection(): boolean {
const scene = this.state.activeScene;
if (!scene) return false;
const holder = this.holder();
if (!holder) return false;
let changed = false;
for (const id of this.state.selectedObjectIds) {
for (const ancestorId of ancestorIdsOf(scene, id)) {
for (const ancestorId of ancestorIdsOf(holder, id)) {
if (this.expanded.has(ancestorId)) continue;
this.expanded.add(ancestorId);
changed = true;
@@ -141,10 +171,12 @@ export class HierarchyPanel extends Panel {
}
private refresh(): void {
const scene = this.state.activeScene;
this.setTitle(scene ? `Scene: ${scene.name}` : 'Scene objects');
const holder = this.holder();
this.shownHolderId = holder?.id ?? '';
const kind = holder?.type === 'container' ? 'Container' : 'Scene';
this.setTitle(holder ? `${kind}: ${holder.name}` : 'Scene objects');
this.list.setItems(
(scene ? visibleTreeOrder(scene, this.expanded) : []).map(({ object, depth, hasChildren }) => {
(holder ? visibleTreeOrder(holder, this.expanded) : []).map(({ object, depth, hasChildren }) => {
const asset = findAsset(this.state.project, object.assetId);
const props = asset ? resolveSceneObjectProps(asset, object) : {};
return {
+2 -2
View File
@@ -32,12 +32,12 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions):
}
if (mod && key === 'g') {
const s = state.selection;
if (s?.kind === 'object') createContainer(state, s.sceneId, s.objectIds);
if (s?.kind === 'object') createContainer(state, s.holderId, s.objectIds);
return true;
}
if (event.key === 'Delete' || event.key === 'Backspace') {
const s = state.selection;
if (s?.kind === 'object') removeObjects(state, s.sceneId, s.objectIds);
if (s?.kind === 'object') removeObjects(state, s.holderId, s.objectIds);
else if (s?.kind === 'node') state.execute(new RemoveNodeCommand(s.assetId, s.nodeId));
else if (s?.kind === 'frame') state.execute(new RemoveFrameCommand(s.assetId, s.frameId));
else if (s?.kind === 'asset') confirmDeleteAsset(state, s.assetId);
+45 -19
View File
@@ -1,17 +1,24 @@
import { type Command, CommandError } from '../core/commands/Command';
import { CommandStack } from '../core/commands/CommandStack';
import type { SceneAsset } from '../core/model/Asset';
import type { Asset, SceneAsset } from '../core/model/Asset';
import { findObject } from '../core/model/hierarchy';
import { createEmptyProject, findAsset, findScene, type Project } from '../core/model/Project';
import {
createEmptyProject,
findAsset,
findObjectHolder,
findScene,
type Project,
} from '../core/model/Project';
import { deserializeProject, serializeProject } from '../core/store/serialize';
export type Selection =
| { kind: 'asset'; assetId: string }
/**
* One or more objects of a scene. `objectId` is the primary one (the last picked – the
* inspector shows it); `objectIds` holds the whole selection, primary included.
* One or more objects of a scene or of a container's template (`holderId` names which).
* `objectId` is the primary one (the last picked – the inspector shows it); `objectIds`
* holds the whole selection, primary included.
*/
| { kind: 'object'; sceneId: string; objectId: string; objectIds: string[] }
| { kind: 'object'; holderId: string; objectId: string; objectIds: string[] }
/** A node inside a behavior's graph. */
| { kind: 'node'; assetId: string; nodeId: string }
/** A frame rectangle inside a texture asset (the Texture tab). */
@@ -19,11 +26,11 @@ export type Selection =
| null;
/** Builds an object selection; the last id is the primary one unless given explicitly. */
export function objectSelection(sceneId: string, objectIds: readonly string[], primary?: string): Selection {
export function objectSelection(holderId: string, objectIds: readonly string[], primary?: string): Selection {
const ids = [...new Set(objectIds)];
if (ids.length === 0) return null;
const objectId = primary && ids.includes(primary) ? primary : ids[ids.length - 1]!;
return { kind: 'object', sceneId, objectId, objectIds: ids };
return { kind: 'object', holderId, objectId, objectIds: ids };
}
export type EditorMode = 'edit' | 'play';
@@ -194,23 +201,23 @@ export class EditorState {
// --- selection -----------------------------------------------------------------------------
select(selection: Selection): void {
// Test hooks pass the short form `{ kind: 'object', sceneId, objectId }` without `objectIds`.
// Test hooks pass the short form `{ kind: 'object', holderId, objectId }` without `objectIds`.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (selection?.kind === 'object' && !selection.objectIds)
selection = objectSelection(selection.sceneId, [selection.objectId]);
selection = objectSelection(selection.holderId, [selection.objectId]);
if (sameSelection(this.selection, selection)) return;
this.selection = selection;
this.emit('selection');
}
/** Ctrl/Shift-click: adds the object to the selection or removes it again. */
toggleObject(sceneId: string, objectId: string): void {
toggleObject(holderId: string, objectId: string): void {
const s = this.selection;
const current = s?.kind === 'object' && s.sceneId === sceneId ? s.objectIds : [];
const current = s?.kind === 'object' && s.holderId === holderId ? s.objectIds : [];
const next = current.includes(objectId)
? current.filter((id) => id !== objectId)
: [...current, objectId];
this.select(objectSelection(sceneId, next, current.includes(objectId) ? undefined : objectId));
this.select(objectSelection(holderId, next, current.includes(objectId) ? undefined : objectId));
}
/** Ids of the selected scene objects (empty unless objects are selected). */
@@ -224,9 +231,9 @@ export class EditorState {
if (!s) return;
if (s.kind === 'asset' && !findAsset(this.project, s.assetId)) this.select(null);
if (s.kind === 'object') {
const scene = findScene(this.project, s.sceneId);
const alive = scene ? s.objectIds.filter((id) => findObject(scene, id)) : [];
if (alive.length !== s.objectIds.length) this.select(objectSelection(s.sceneId, alive, s.objectId));
const holder = findObjectHolder(this.project, s.holderId);
const alive = holder ? s.objectIds.filter((id) => findObject(holder, id)) : [];
if (alive.length !== s.objectIds.length) this.select(objectSelection(s.holderId, alive, s.objectId));
}
if (s.kind === 'node') {
const asset = findAsset(this.project, s.assetId);
@@ -259,12 +266,31 @@ export class EditorState {
if (!s) return null;
if (s.kind === 'node') return s.assetId;
if (s.kind === 'frame') return null;
const scene = s.kind === 'object' ? findScene(this.project, s.sceneId) : undefined;
const assetId = s.kind === 'asset' ? s.assetId : scene && findObject(scene, s.objectId)?.assetId;
const asset = assetId ? findAsset(this.project, assetId) : undefined;
const asset = this.assetBehind(s);
return asset && asset.type === 'behavior' ? asset.id : null;
}
/**
* The container the current selection refers to: the asset itself, a placed container, or the
* template being edited – selecting one of its objects keeps the Container tab on it.
*/
get selectedContainerId(): string | null {
const s = this.selection;
if (!s) return null;
if (s.kind === 'object' && findAsset(this.project, s.holderId)?.type === 'container') return s.holderId;
const asset = this.assetBehind(s);
return asset && asset.type === 'container' ? asset.id : null;
}
/** The asset a selection stands for: itself, or the one a selected object was placed from. */
private assetBehind(s: NonNullable<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 --------------------------------------------------------------------------------
setSaveStatus(status: SaveStatus): void {
@@ -286,7 +312,7 @@ export function sameSelection(a: Selection, b: Selection): boolean {
if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;
if (a.kind === 'object' && b.kind === 'object') {
return (
a.sceneId === b.sceneId &&
a.holderId === b.holderId &&
a.objectId === b.objectId &&
a.objectIds.length === b.objectIds.length &&
a.objectIds.every((id, i) => id === b.objectIds[i])
+22 -5
View File
@@ -3,6 +3,7 @@ import { centreY } from '../math';
import { Theme, textStyle } from '../Theme';
import { type Size, Widget } from '../Widget';
import { ListRow } from './ListRow';
import type { Popup } from './Popup';
export interface ListItem {
id: string;
@@ -46,9 +47,12 @@ export class ListView extends Widget {
private rows: ListRow[] = [];
private selectedId: string | null = null;
private highlighted = new Set<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;
onActivate?: (id: string) => void;
onContextMenu?: (id: string, globalX: number, globalY: number) => void;
/** Returning the opened menu keeps its row marked for as long as it is up. */
onContextMenu?: (id: string, globalX: number, globalY: number) => Popup | void;
/** Called when a dragged row is released over another row (`targetId`) or over empty space (null). */
onDrop?: (draggedId: string, targetId: string | null) => void;
/**
@@ -94,6 +98,7 @@ export class ListView extends Widget {
});
this.addChild(this.dropMarker); // keep the marker above the rows
if (this.selectedId && !items.some((i) => i.id === this.selectedId)) this.selectedId = null;
if (this.contextId && !items.some((i) => i.id === this.contextId)) this.contextId = null;
this.layout();
this.redraw();
}
@@ -106,6 +111,12 @@ export class ListView extends Widget {
if (!silent) this.onSelect?.(id);
}
private setContext(id: string | null): void {
if (id === this.contextId) return;
this.contextId = id;
this.redraw();
}
/** Rows drawn as selected in addition to the selected one (the owner's multi-selection). */
highlight(ids: Iterable<string>): void {
this.highlighted = new Set(ids);
@@ -153,7 +164,11 @@ export class ListView extends Widget {
override redraw(): void {
for (const row of this.rows) {
row.setSelected(row.item.id === this.selectedId || this.highlighted.has(row.item.id), this.focused);
const id = row.item.id;
row.setSelected(
id === this.selectedId || id === this.contextId || this.highlighted.has(id),
this.focused,
);
}
this.emptyLabel.text = this.items.length === 0 ? this.emptyText : '';
this.emptyLabel.position.set(Theme.padding, centreY(this.rowHeight, this.emptyLabel.height));
@@ -169,9 +184,11 @@ export class ListView extends Widget {
if (e.button === 0 && row.hitsChevron(row.toLocal(e.global).x)) {
this.onToggle?.(item.id, !item.expanded);
} else if (e.button === 2) {
// A right-click inside a multi-selection keeps it (the menu acts on all of it).
if (!this.highlighted.has(item.id)) this.select(item.id);
this.onContextMenu?.(item.id, e.global.x, e.global.y);
// Marked, not selected: selecting also loads the row into the editor, which would swap the
// very tab out from under a menu opened to place something into it.
const menu = this.onContextMenu?.(item.id, e.global.x, e.global.y);
this.setContext(menu ? item.id : null);
if (menu) menu.onClose = () => this.setContext(null);
} else if (modifiers.ctrl || modifiers.shift) {
this.onSelect?.(item.id, modifiers);
} else {
+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
* copy, so live positions are visible and stay editable.
* copy, so live positions are visible and stay editable. A container's template is the same
* work on the same shape of data, so the Container tab subclasses this and swaps `holder()`.
*/
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { CompoundCommand } from '../core/commands/CompoundCommand';
@@ -8,10 +9,11 @@ import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
import { isSymbolType } from '../core/model/AssetTypes';
import { findObject, subtreeRoots } from '../core/model/hierarchy';
import type { Position, Vec2 } from '../core/model/Properties';
import type { ObjectHolder } from '../core/model/Project';
import { footprintOf } from '../core/properties/bounds';
import { type EditorState, objectSelection } from '../editor/state';
import { clamp, drawGrid, type Size, Theme, textStyle, zoomFactor } from '../ui';
import { collectSceneItems, type DrawItem } from './items';
import { collectHolderItems, type DrawItem } from './items';
import { dashedLine } from './symbols';
import { freeView, screenToWorld, type View, worldToScreen } from './view';
import { type Frame, WorldViewport } from './WorldViewport';
@@ -24,9 +26,11 @@ const GRID_STEP = 100;
const MIN_GRID_STEP = 12;
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 8;
/** Breathing room left around the content by `fit()`, in screen pixels. */
const FIT_MARGIN = 40;
interface ObjectDrag {
sceneId: string;
holderId: string;
items: { objectId: string; start: Position }[];
pointer: Vec2;
moved: boolean;
@@ -70,11 +74,47 @@ export class SceneViewport extends WorldViewport {
});
}
/** What this view shows and edits; the Container tab returns its container asset instead. */
protected holder(): ObjectHolder | undefined {
return this.state.activeScene;
}
/** World coordinates at the centre of the view. */
worldCenter(): Vec2 {
return { x: Math.round(this.view.x), y: Math.round(this.view.y) };
}
/**
* Frames everything the holder contains. The origin is always included: it is where a container
* is grabbed when placed, so a template drawn far off to one side would otherwise hide it.
*/
fit(): void {
const { items } = this.collectFrame();
let minX = 0;
let minY = 0;
let maxX = 0;
let maxY = 0;
for (const item of items) {
const { w, h } = footprintOf(item.type, item.props);
minX = Math.min(minX, item.x - w / 2);
maxX = Math.max(maxX, item.x + w / 2);
minY = Math.min(minY, item.y - h / 2);
maxY = Math.max(maxY, item.y + h / 2);
}
const width = Math.max(maxX - minX, 1);
const height = Math.max(maxY - minY, 1);
this.view = {
x: (minX + maxX) / 2,
y: (minY + maxY) / 2,
zoom: clamp(
Math.min((this.w - FIT_MARGIN * 2) / width, (this.h - FIT_MARGIN * 2) / height),
MIN_ZOOM,
MAX_ZOOM,
),
};
this.redraw();
}
// --- the view -------------------------------------------------------------------------------
protected override currentView(): View {
@@ -82,7 +122,7 @@ export class SceneViewport extends WorldViewport {
}
protected override collectFrame(): Frame {
return collectSceneItems(this.state, Theme.colors.viewport);
return collectHolderItems(this.state.project, this.holder(), Theme.colors.viewport);
}
protected override get baseColor(): number {
@@ -91,7 +131,7 @@ export class SceneViewport extends WorldViewport {
/** Without a scene there is nothing to frame: the neutral base colour stands alone. */
protected override paintsBackground(): boolean {
return Boolean(this.state.activeScene);
return Boolean(this.holder());
}
protected override get itemsInteractive(): boolean {
@@ -129,11 +169,11 @@ export class SceneViewport extends WorldViewport {
const dy = (local.y - this.dragging.pointer.y) / this.view.zoom;
if (!this.dragging.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
this.dragging.moved = true;
const scene = this.state.activeScene;
if (!scene) return;
const holder = this.holder();
if (!holder) return;
// A world-space delta is a local-space delta as well (no rotation/scale in the hierarchy).
for (const item of this.dragging.items) {
const object = findObject(scene, item.objectId);
const object = findObject(holder, item.objectId);
if (object)
object.position = {
x: Math.round(item.start.x + dx),
@@ -167,16 +207,16 @@ export class SceneViewport extends WorldViewport {
const drag = this.dragging;
this.dragging = null;
if (!drag || !drag.moved) return;
const scene = this.state.activeScene;
if (!scene) return;
const holder = this.holder();
if (!holder) return;
// The preview moved the objects already; put them back so the command records the start.
const moves: MoveSceneObjectCommand[] = [];
for (const item of drag.items) {
const object = findObject(scene, item.objectId);
const object = findObject(holder, item.objectId);
if (!object) continue;
const next = { ...object.position };
object.position = { ...item.start };
moves.push(new MoveSceneObjectCommand(drag.sceneId, item.objectId, next));
moves.push(new MoveSceneObjectCommand(drag.holderId, item.objectId, next));
}
if (moves.length === 1) this.state.execute(moves[0]!);
else if (moves.length > 1) this.state.execute(new CompoundCommand('Move objects', moves));
@@ -195,30 +235,30 @@ export class SceneViewport extends WorldViewport {
protected override onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
if (e.button !== 0) return;
const scene = this.state.activeScene;
const object = scene && findObject(scene, objectId);
if (!scene || !object) return;
const holder = this.holder();
const object = holder && findObject(holder, objectId);
if (!holder || !object) return;
e.stopPropagation();
if (e.ctrlKey || e.metaKey || e.shiftKey) {
this.state.toggleObject(scene.id, objectId);
this.state.toggleObject(holder.id, objectId);
return;
}
// Clicking a selected object keeps the selection so the whole group can be dragged.
if (!this.state.selectedObjectIds.includes(objectId))
this.state.select(objectSelection(scene.id, [objectId]));
this.state.select(objectSelection(holder.id, [objectId]));
// Only the tops of the selected subtrees move; descendants follow through the hierarchy.
const items = subtreeRoots(scene, this.state.selectedObjectIds)
.map((id) => findObject(scene, id))
const items = subtreeRoots(holder, this.state.selectedObjectIds)
.map((id) => findObject(holder, id))
.filter((o): o is NonNullable<typeof o> => Boolean(o))
.map((o) => ({ objectId: o.id, start: { ...o.position } }));
const local = this.toLocal(e.global);
this.dragging = { sceneId: scene.id, items, pointer: { x: local.x, y: local.y }, moved: false };
this.dragging = { holderId: holder.id, items, pointer: { x: local.x, y: local.y }, moved: false };
}
/** Selects every object whose position – not its bounds – lies inside the band. */
private selectInMarquee(marquee: Marquee): void {
const scene = this.state.activeScene;
if (!scene) return;
const holder = this.holder();
if (!holder) return;
const view = this.currentView();
const a = screenToWorld(view, marquee.start.x, marquee.start.y);
const b = screenToWorld(view, marquee.current.x, marquee.current.y);
@@ -229,7 +269,7 @@ export class SceneViewport extends WorldViewport {
const ids = this.lastItems
.filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY)
.map((i) => i.id);
this.state.select(objectSelection(scene.id, ids));
this.state.select(objectSelection(holder.id, ids));
}
// --- decorations ----------------------------------------------------------------------------
+16 -11
View File
@@ -2,11 +2,10 @@ import { isHandleEnabled, worldPositionOf } from '../core/graph';
import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
import { editorPosition, treeOrder } from '../core/model/hierarchy';
import type { PropertyBag } from '../core/model/Properties';
import { findAsset } from '../core/model/Project';
import { findAsset, type ObjectHolder, type Project } from '../core/model/Project';
import { resolveTexture, type TextureRef } from '../core/model/texture';
import { enabledAlongChain } from '../core/properties/enabled';
import { resolveSceneObjectProps } from '../core/properties/resolve';
import type { EditorState } from '../editor/state';
import type { World } from '../runtime/World';
/** What the viewport draws: the same shape whether it comes from the model or the world. */
@@ -32,24 +31,30 @@ interface DrawList {
background: number;
}
/** The model's scene, in tree order – the tie-break for equal `z` – at the editor positions. */
export function collectSceneItems(state: EditorState, fallbackBackground: number): DrawList {
const scene = state.activeScene;
if (!scene) return { items: [], background: fallbackBackground };
/**
* A scene or a container template, in tree order – the tie-break for equal `z` – at the editor
* positions. A container has no background of its own, so it keeps the fallback.
*/
export function collectHolderItems(
project: Project,
holder: ObjectHolder | undefined,
fallbackBackground: number,
): DrawList {
if (!holder) return { items: [], background: fallbackBackground };
const items: DrawItem[] = [];
for (const { object } of treeOrder(scene)) {
const asset = findAsset(state.project, object.assetId);
for (const { object } of treeOrder(holder)) {
const asset = findAsset(project, object.assetId);
if (!asset) continue;
const props = resolveSceneObjectProps(asset, object);
items.push({
id: object.id,
type: asset.type,
name: object.name || asset.name,
...editorPosition(scene, object),
...editorPosition(holder, object),
props,
parentId: object.parentId,
enabled: true,
texture: asset.type === 'sprite' ? resolveTexture(state.project, props) : null,
texture: asset.type === 'sprite' ? resolveTexture(project, props) : null,
});
}
const byId = new Map(items.map((i) => [i.id, i]));
@@ -59,7 +64,7 @@ export function collectSceneItems(state: EditorState, fallbackBackground: number
(i) => i.props,
(i) => byId.get(i.parentId) ?? null,
);
return { items, background: Number(scene.props.backgroundColor ?? fallbackBackground) };
return { items, background: Number(holder.props.backgroundColor ?? fallbackBackground) };
}
/** The running world: scene content only, since symbols are editor-only. */
+242 -1
View File
@@ -1,16 +1,24 @@
import { describe, expect, it } from 'vitest';
import { DeleteAssetCommand } from '../../src/core/commands/assetCommands';
import { CommandError } from '../../src/core/commands/Command';
import { CompoundCommand } from '../../src/core/commands/CompoundCommand';
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
import {
MoveSceneObjectCommand,
PlaceAssetCommand,
RemoveSceneObjectCommand,
RemoveSceneObjectsCommand,
ClearOverrideCommand,
RenameSceneObjectCommand,
SetOverrideCommand,
SetParentCommand,
} from '../../src/core/commands/sceneObjectCommands';
import { worldPositionOf } from '../../src/core/graph';
import type { ContainerAsset } from '../../src/core/model/Asset';
import { subtreeRoots, treeOrder } from '../../src/core/model/hierarchy';
import { containerReaches } from '../../src/core/model/container';
import { descendantsOf, subtreeRoots, treeOrder } from '../../src/core/model/hierarchy';
import { findAsset, findScene } from '../../src/core/model/Project';
import { createSceneObject } from '../../src/core/model/SceneObject';
import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
/** Three boxes: a at (100,100,z1), b at (10,0) and c at (0,5) parented under b (local (-10,5)). */
@@ -23,6 +31,27 @@ function setup() {
return { ...f, a, b, c };
}
/** The container asset built from a and b (c comes along below b), plus lookups into its template. */
function templateSetup() {
const f = setup();
const create = new CreateContainerCommand(f.sceneId, [f.a, f.b]);
f.stack.execute(create);
const asset = () => findAsset(f.project, create.assetId) as ContainerAsset;
return { ...f, containerId: create.assetId, asset, templateIdOf: (id: string) => f.object(id).templateId };
}
/** Three container assets holding one box each, none of them nested yet. */
function makeContainers() {
const f = makeSpriteScene();
const build = () => {
const command = new CreateContainerCommand(f.sceneId, [place(f, f.spriteId, { x: 0, y: 0 })]);
f.stack.execute(command);
return command.assetId;
};
const assetOf = (id: string) => findAsset(f.project, id) as ContainerAsset;
return { ...f, outerId: build(), innerId: build(), deepId: build(), assetOf };
}
describe('subtree roots', () => {
it('drops ids that have a selected ancestor', () => {
const { sceneOf, a, b, c } = setup();
@@ -164,6 +193,218 @@ describe('placing a container', () => {
});
});
describe('editing a container template', () => {
it('moves, renames, overrides and removes template objects addressed by the container id', () => {
const { stack, containerId, a, asset, object, templateIdOf } = templateSetup();
const templateA = templateIdOf(a);
const template = () => asset().objects.find((t) => t.id === templateA);
stack.execute(new MoveSceneObjectCommand(containerId, templateA, { x: 7, y: 8, z: 9 }));
stack.execute(new RenameSceneObjectCommand(containerId, templateA, 'Hero'));
stack.execute(new SetOverrideCommand(containerId, templateA, 'width', 55));
expect(template()!.position).toEqual({ x: 7, y: 8, z: 9 });
expect(template()!.name).toBe('Hero');
expect(template()!.overrides.width).toBe(55);
// a is a copy of that template and held its old values, so it follows all three.
expect(object(a).position).toEqual({ x: 7, y: 8, z: 9 });
expect(object(a).name).toBe('Hero');
expect(object(a).overrides.width).toBe(55);
stack.execute(new RemoveSceneObjectCommand(containerId, templateA));
expect(template()).toBeUndefined();
expect(asset().objects).toHaveLength(2);
stack.undo();
expect(template()!.overrides.width).toBe(55);
stack.undo();
expect(template()!.overrides.width).toBeUndefined();
stack.undo();
expect(template()!.name).toBe('');
stack.undo();
expect(template()!.position).toEqual({ x: 45, y: 50, z: 1 });
});
it('restores the template order when a removal is undone', () => {
const { stack, containerId, a, b, c, asset, templateIdOf } = templateSetup();
const ids = [templateIdOf(a), templateIdOf(b), templateIdOf(c)];
expect(asset().objects.map((t) => t.id)).toEqual(ids);
stack.execute(new RemoveSceneObjectsCommand(containerId, [ids[1]!]));
expect(asset().objects.map((t) => t.id)).toEqual([ids[0]]);
stack.undo();
expect(asset().objects.map((t) => t.id)).toEqual(ids);
expect(asset().objects[2]!.parentId).toBe(ids[1]);
});
it('rejects an object id that belongs to the scene rather than to the template', () => {
const { stack, containerId, a } = templateSetup();
expect(() => stack.execute(new RenameSceneObjectCommand(containerId, a, 'Hero'))).toThrow(/not found/);
});
});
describe('a template edit reaching the placed copies', () => {
/** The container placed a second time, with a lookup from a template object to that fresh copy. */
function placedSetup() {
const f = templateSetup();
const before = new Set(f.sceneOf().objects.map((o) => o.id));
place(f, f.containerId, { x: 1000, y: 0 });
return {
...f,
copyOf: (id: string) =>
f.sceneOf().objects.find((o) => !before.has(o.id) && o.templateId === f.templateIdOf(id))!,
};
}
it('carries an override to the copies that agreed and leaves a modified one alone', () => {
const { stack, sceneId, containerId, a, object, templateIdOf, copyOf } = placedSetup();
const modified = copyOf(a).id;
stack.execute(new SetOverrideCommand(sceneId, modified, 'width', 12));
stack.execute(new SetOverrideCommand(containerId, templateIdOf(a), 'width', 55));
expect(object(a).overrides.width).toBe(55);
expect(object(modified).overrides.width).toBe(12);
stack.undo();
expect(object(a).overrides.width).toBeUndefined();
expect(object(modified).overrides.width).toBe(12);
});
it('clears an override on the copies that shared it', () => {
const { stack, containerId, a, object, templateIdOf } = placedSetup();
const templateA = templateIdOf(a);
stack.execute(new SetOverrideCommand(containerId, templateA, 'width', 55));
expect(object(a).overrides.width).toBe(55);
stack.execute(new ClearOverrideCommand(containerId, templateA, 'width'));
expect(object(a).overrides.width).toBeUndefined();
stack.undo();
expect(object(a).overrides.width).toBe(55);
});
it('follows per axis, so a copy dragged sideways keeps only its x', () => {
const { stack, sceneId, containerId, a, object, templateIdOf, copyOf } = placedSetup();
const moved = copyOf(a).id;
stack.execute(new MoveSceneObjectCommand(sceneId, moved, { x: 999, y: 50, z: 1 }));
stack.execute(new MoveSceneObjectCommand(containerId, templateIdOf(a), { x: 7, y: 8, z: 9 }));
expect(object(a).position).toEqual({ x: 7, y: 8, z: 9 });
expect(object(moved).position).toEqual({ x: 999, y: 8, z: 9 });
stack.undo();
expect(object(moved).position).toEqual({ x: 999, y: 50, z: 1 });
});
it('does not leak an edit made in the scene to the other copies', () => {
const { stack, sceneId, a, object, copyOf } = placedSetup();
const copy = copyOf(a).id;
stack.execute(new SetOverrideCommand(sceneId, copy, 'width', 12));
stack.execute(new RenameSceneObjectCommand(sceneId, copy, 'Only me'));
expect(object(a).overrides.width).toBeUndefined();
expect(object(a).name).toBe('');
});
it('reaches the content of a container nested in another one', () => {
const f = makeContainers();
const { stack, outerId, innerId, sceneOf, assetOf } = f;
stack.execute(new PlaceAssetCommand(outerId, innerId, { x: 20, y: 0 }));
const placed = place(f, outerId, { x: 500, y: 0 });
const innerTemplate = assetOf(innerId).objects[0]!;
const deepCopy = sceneOf().objects.find((o) => o.templateId === innerTemplate.id)!;
stack.execute(new RenameSceneObjectCommand(innerId, innerTemplate.id, 'Deep'));
expect(deepCopy.name).toBe('Deep');
expect(descendantsOf(sceneOf(), placed)).toHaveLength(3);
stack.undo();
expect(deepCopy.name).toBe('');
});
});
describe('placing into a container template', () => {
it('creates a single object, without expanding a placed container', () => {
const { stack, outerId, innerId, assetOf } = makeContainers();
const command = new PlaceAssetCommand(outerId, innerId, { x: 20, y: 0 });
stack.execute(command);
const objects = assetOf(outerId).objects;
expect(objects).toHaveLength(2);
const placed = objects.find((o) => o.id === command.objectId)!;
expect(placed.assetId).toBe(innerId);
expect(placed.position).toEqual({ x: 20, y: 0, z: 0 });
expect(objects.some((o) => o.parentId === command.objectId)).toBe(false);
stack.undo();
expect(assetOf(outerId).objects).toHaveLength(1);
});
it('expands the same container when it goes into a scene instead', () => {
const f = makeContainers();
const before = f.sceneOf().objects.length;
const placed = place(f, f.innerId, { x: 20, y: 0 });
expect(f.sceneOf().objects).toHaveLength(before + 2);
expect(descendantsOf(f.sceneOf(), placed)).toHaveLength(1);
});
it('expands the nested container once the outer one is placed into a scene', () => {
const f = makeContainers();
const { stack, outerId, innerId, spriteId, sceneOf, world } = f;
const nest = new PlaceAssetCommand(outerId, innerId, { x: 20, y: 0 });
stack.execute(nest);
const placed = place(f, outerId, { x: 500, y: 0 });
const children = sceneOf().objects.filter((o) => o.parentId === placed);
expect(children.map((o) => o.assetId).sort()).toEqual([innerId, spriteId].sort());
const nested = children.find((o) => o.assetId === innerId)!;
expect(nested.templateId).toBe(nest.objectId);
expect(world(nested.id)).toEqual({ x: 520, y: 0, z: 0 });
const content = sceneOf().objects.filter((o) => o.parentId === nested.id);
expect(content).toHaveLength(1);
expect(content[0]!.assetId).toBe(spriteId);
expect(world(content[0]!.id)).toEqual({ x: 520, y: 0, z: 0 });
});
it('refuses a placement that would nest a container inside itself', () => {
const { stack, outerId, innerId, deepId, assetOf } = makeContainers();
expect(() => stack.execute(new PlaceAssetCommand(outerId, outerId, { x: 0, y: 0 }))).toThrow(
/nests forever/,
);
expect(assetOf(outerId).objects).toHaveLength(1);
stack.execute(new PlaceAssetCommand(outerId, innerId, { x: 0, y: 0 }));
expect(() => stack.execute(new PlaceAssetCommand(innerId, outerId, { x: 0, y: 0 }))).toThrow(
CommandError,
);
stack.execute(new PlaceAssetCommand(innerId, deepId, { x: 0, y: 0 }));
expect(() => stack.execute(new PlaceAssetCommand(deepId, outerId, { x: 0, y: 0 }))).toThrow(CommandError);
expect(assetOf(deepId).objects).toHaveLength(1);
});
});
describe('containerReaches', () => {
it('sees the container itself and its content, transitively', () => {
const { project, stack, outerId, innerId, deepId, spriteId, assetOf } = makeContainers();
expect(containerReaches(project, assetOf(outerId), outerId)).toBe(true);
expect(containerReaches(project, assetOf(outerId), innerId)).toBe(false);
stack.execute(new PlaceAssetCommand(outerId, innerId, { x: 0, y: 0 }));
stack.execute(new PlaceAssetCommand(innerId, deepId, { x: 0, y: 0 }));
expect(containerReaches(project, assetOf(outerId), innerId)).toBe(true);
expect(containerReaches(project, assetOf(outerId), deepId)).toBe(true);
expect(containerReaches(project, assetOf(deepId), outerId)).toBe(false);
expect(containerReaches(project, assetOf(outerId), spriteId)).toBe(false);
});
it('terminates on a cycle the commands would have refused to create', () => {
const { project, stack, outerId, innerId, deepId, assetOf } = makeContainers();
stack.execute(new PlaceAssetCommand(outerId, innerId, { x: 0, y: 0 }));
assetOf(innerId).objects.push(createSceneObject('cycle', outerId, { x: 0, y: 0 }));
expect(containerReaches(project, assetOf(outerId), deepId)).toBe(false);
expect(containerReaches(project, assetOf(innerId), outerId)).toBe(true);
});
});
describe('multi-selection commands', () => {
it('removes several subtrees as one step, covering nested ids', () => {
const { stack, sceneId, a, b, c, sceneOf, object } = setup();
+22 -2
View File
@@ -9,7 +9,13 @@ import { CreateContainerCommand } from '../../src/core/commands/containerCommand
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
import type { ContainerAsset } from '../../src/core/model/Asset';
import { findAsset, findUsages, objectHolders } from '../../src/core/model/Project';
import {
findAsset,
findObjectHolder,
findUsages,
listContainers,
objectHolders,
} from '../../src/core/model/Project';
import { makeBehavior, makeSpriteScene, place } from '../helpers';
/** A behavior with a `speed` variable, placed and overridden to 42, then wrapped in a container. */
@@ -21,7 +27,7 @@ function containerWithOverride() {
const container = new CreateContainerCommand(f.sceneId, [objectId]);
f.stack.execute(container);
const template = () => (findAsset(f.project, container.assetId) as ContainerAsset).objects[0]!;
return { ...f, behaviorId: behavior.id, objectId, template };
return { ...f, behaviorId: behavior.id, containerId: container.assetId, objectId, template };
}
describe('object holders', () => {
@@ -34,6 +40,20 @@ describe('object holders', () => {
).toEqual(['container', 'scene']);
});
it('resolves a scene or a container by id, but no other asset', () => {
const { project, sceneId, containerId, spriteId, behaviorId } = containerWithOverride();
expect(findObjectHolder(project, sceneId)?.type).toBe('scene');
expect(findObjectHolder(project, containerId)?.type).toBe('container');
expect(findObjectHolder(project, spriteId)).toBeUndefined();
expect(findObjectHolder(project, behaviorId)).toBeUndefined();
expect(findObjectHolder(project, 'nope')).toBeUndefined();
});
it('lists the container assets on their own', () => {
const { project, containerId } = containerWithOverride();
expect(listContainers(project).map((c) => c.id)).toEqual([containerId]);
});
it('finds usages inside container templates, not just in scenes', () => {
const { project, behaviorId, template } = containerWithOverride();
// The placed object and the template copy the container was built from.
+1 -1
View File
@@ -31,7 +31,7 @@ export async function hierarchy(t) {
same(await rowIds(), ['o1', 'o2', 'o3', 'o5']),
await rows(),
);
await select({ kind: 'object', sceneId: 'scene1', objectId: 'oc1' });
await select({ kind: 'object', holderId: 'scene1', objectId: 'oc1' });
await sleep(150);
check(
'selecting a nested object unfolds its ancestors',
+2 -2
View File
@@ -80,7 +80,7 @@ export async function play(t) {
Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) &&
(await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 7,
);
await select({ kind: 'object', sceneId: 'scene1', objectId: 'o2' });
await select({ kind: 'object', holderId: 'scene1', objectId: 'o2' });
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 400`);
let liveO2 = await entity('o2');
check(
@@ -158,7 +158,7 @@ export async function play(t) {
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = ${xBeforePlay}`);
// 7c. Without a camera nothing is rendered: delete the camera object, play, then undo.
await select({ kind: 'object', sceneId: 'scene1', objectId: 'o3' });
await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' });
await press('Delete');
await sleep(200);
check('camera object deleted', (await objects()).length === 6);
+1 -1
View File
@@ -30,7 +30,7 @@ export async function drawing(t) {
JSON.stringify(await orderOfCrates()) === '["o1","o2"]',
JSON.stringify(await orderOfCrates()),
);
await select({ kind: 'object', sceneId: 'scene1', objectId: 'o1' });
await select({ kind: 'object', holderId: 'scene1', objectId: 'o1' });
await sleep(200);
const zLabel = await locate('Z');
await click(zLabel.x + 160, zLabel.y); // plain click → text editing with everything selected
+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.
const redScreen = await json(`__gm.objectScreen('o1')`);
await select({ kind: 'object', sceneId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
await drag(redScreen.x, redScreen.y + 20, redScreen.x + 50, redScreen.y + 20);
o1c = await object('o1');
const o2d = await object('o2');
@@ -161,7 +161,7 @@ export async function containers(t) {
// 16e. Delete with a multi-selection removes all of them (with subtrees) as one step.
const objectsBeforeDelete = (await objects()).length;
await select({ kind: 'object', sceneId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
await press('Delete');
await sleep(150);
check(
@@ -226,7 +226,7 @@ export async function containers(t) {
// ↑ and ↺ on the X row; ↑ writes x into the template and the copy follows; undo takes
// both back; ↺ puts o1 back to the template value.
const templateA = async () => (await asset(containerAsset.id)).objects.find((o) => o.id === o1c.templateId);
await select({ kind: 'object', sceneId: 'scene1', objectId: 'o1' });
await select({ kind: 'object', holderId: 'scene1', objectId: 'o1' });
await sleep(200);
check('inspector names the container the object is part of', await has(containerAsset.name));
check('no apply/revert while the instance matches its template', !(await has('↑')) && !(await has('↺')));
+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).
const cam = __gm.state.activeScene.objects.find(o => o.id === 'o3').position;
__gm.state.activeScene.objects.push({ id: 'otext', assetId: id, name: '', position: { x: cam.x, y: cam.y, z: 5 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', templateId: '' });
__gm.state.select({ kind: 'object', sceneId: 'scene1', objectIds: ['otext'], objectId: 'otext' })`);
__gm.state.select({ kind: 'object', holderId: 'scene1', objectIds: ['otext'], objectId: 'otext' })`);
await sleep(100);
check('a placed text object draws its content in the scene', await has('Hello world'));
await shot('29-text-object');
+1 -1
View File
@@ -15,7 +15,7 @@ export async function menus(t) {
copy.parentId = '';
scene.objects.push(copy);
}
__gm.state.select({ kind: 'object', sceneId: 'scene1', objectIds: ['otext'], objectId: 'otext' })`);
__gm.state.select({ kind: 'object', holderId: 'scene1', objectIds: ['otext'], objectId: 'otext' })`);
await sleep(150);
// 18. A picker with 40+ entries is capped in height and stays fully on screen instead of
+6 -5
View File
@@ -14,7 +14,7 @@ export async function overflow(t) {
const pickerWidths = async () => {
const out = {};
for (const tab of ['graph', 'texture']) {
for (const tab of ['graph', 'texture', 'container']) {
await evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
await sleep(300);
out[tab] = await json(`__gm.${tab}.pickerWidth()`);
@@ -25,7 +25,8 @@ export async function overflow(t) {
await mutate(`
for (const asset of __gm.state.project.assets) {
if (asset.type === 'behavior' || asset.type === 'texture') asset.name = ${JSON.stringify(LONG_NAME)};
if (asset.type === 'behavior' || asset.type === 'texture' || asset.type === 'container')
asset.name = ${JSON.stringify(LONG_NAME)};
}
__gm.state.select(null)`);
await sleep(600);
@@ -33,7 +34,7 @@ export async function overflow(t) {
// 21. The pickers keep the width they were given. `width` used to set only a minimum, so a
// picker grew with whatever name it happened to show and the header reflowed on load.
const after = await pickerWidths();
for (const tab of ['graph', 'texture']) {
for (const tab of ['graph', 'texture', 'container']) {
check(
`the ${tab} picker keeps its width when the name gets long`,
before[tab] === 200 && after[tab] === 200,
@@ -42,7 +43,7 @@ export async function overflow(t) {
}
// 21a. Every tab, because each one owns a different set of controls.
for (const tab of ['scene', 'graph', 'texture', 'game']) {
for (const tab of ['scene', 'graph', 'texture', 'game', 'container']) {
await evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
await sleep(400);
const spills = await json('__gm.textOverflows()');
@@ -75,7 +76,7 @@ export async function overflow(t) {
// 21d. The other direction: a control narrow enough to swallow its whole label draws nothing
// at all, which the overflow walk cannot see – an empty text spills over no edge.
for (const tab of ['scene', 'graph', 'texture']) {
for (const tab of ['scene', 'graph', 'texture', 'container']) {
await evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
await sleep(400);
const dropped = await json('__gm.labelsDropped()');
+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.
await mutate(`${spriteProp('texture')} = ${JSON.stringify(sheet)}`);
await select({ kind: 'object', sceneId: 'scene1', objectIds: ['otex'], objectId: 'otex' });
await select({ kind: 'object', holderId: 'scene1', objectIds: ['otex'], objectId: 'otex' });
await sleep(600);
await mutate("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides.height = 90");
await sleep(400);
+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 { overflow } from './14-overflow.mjs';
import { aspect } from './15-aspect.mjs';
import { containerTab } from './16-container.mjs';
export const STEPS = [
hierarchy,
@@ -35,4 +36,5 @@ export const STEPS = [
frames,
overflow,
aspect,
containerTab,
];