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