From 26d5edc496cc7e037b8b01d6ac1e087954d3e42f Mon Sep 17 00:00:00 2001 From: root Date: Mon, 7 Sep 2026 15:04:56 +0200 Subject: [PATCH] Scene hierarchy: parentId with local positions, tree panel, reparenting and Parent node Co-Authored-By: Claude Fable 5.1 --- README.md | 30 ++++- src/core/commands/assetCommands.ts | 46 +++---- src/core/commands/sceneObjectCommands.ts | 108 +++++++++------ src/core/graph/NodeRegistry.ts | 18 ++- src/core/graph/nodes.ts | 21 ++- src/core/model/SceneObject.ts | 16 ++- src/core/model/hierarchy.ts | 93 +++++++++++++ src/core/store/serialize.ts | 21 ++- src/editor/App.ts | 45 +++++-- src/editor/panels/HierarchyPanel.ts | 30 ++++- src/editor/panels/Inspector.ts | 36 ++--- src/editor/panels/Toolbar.ts | 9 ++ src/runtime/Entity.ts | 7 +- src/runtime/World.ts | 23 +++- src/runtime/systems/BehaviorSystem.ts | 2 +- src/ui/widgets/ListView.ts | 71 +++++++++- src/viewport/Viewport.ts | 40 +++--- tests/core/graph.test.ts | 25 ++-- tests/core/hierarchy.test.ts | 163 +++++++++++++++++++++++ tests/core/serialize.test.ts | 2 +- tools/e2e.mjs | 80 ++++++++++- tools/seed-demo.mjs | 9 +- 22 files changed, 729 insertions(+), 166 deletions(-) create mode 100644 src/core/model/hierarchy.ts create mode 100644 tests/core/hierarchy.test.ts diff --git a/README.md b/README.md index 366588cf..aa716c17 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ Live: · Widget playground: a.id === this.assetId); const asset = project.assets[index]; if (!asset) throw new CommandError(`Asset ${this.assetId} not found`); - this.removedUsages = findUsages(project, this.assetId).map(({ scene, object }) => ({ - sceneId: scene.id, - object, - index: scene.objects.indexOf(object), - })); - this.clearedOwners = []; - for (const usage of this.removedUsages) { - const scene = findAsset(project, usage.sceneId); - if (!scene || scene.type !== 'scene') continue; - scene.objects = scene.objects.filter((o) => o.id !== usage.object.id); - // Objects that pointed at a removed object as their owner lose that reference. - for (const other of scene.objects) { - if (other.ownerId === usage.object.id) { - this.clearedOwners.push({ sceneId: scene.id, objectId: other.id, ownerId: other.ownerId }); - other.ownerId = ''; - } - } + this.removedObjects = []; + for (const scene of listScenes(project)) { + const roots = scene.objects.filter((o) => o.assetId === this.assetId).map((o) => o.id); + if (roots.length === 0) continue; + this.removedObjects.push({ sceneId: scene.id, removed: removeSubtrees(scene, roots) }); } this.removed = { asset, index }; project.assets.splice(index, 1); @@ -91,15 +81,9 @@ export class DeleteAssetCommand implements Command { undo(project: Project): void { if (!this.removed) return; project.assets.splice(this.removed.index, 0, this.removed.asset); - // Restore usages in ascending index order so the original positions come back. - for (const usage of [...this.removedUsages].sort((a, b) => a.index - b.index)) { - const scene = findAsset(project, usage.sceneId); - if (scene && scene.type === 'scene') scene.objects.splice(usage.index, 0, usage.object); - } - for (const cleared of this.clearedOwners) { - const scene = findAsset(project, cleared.sceneId); - const object = scene && scene.type === 'scene' ? scene.objects.find((o) => o.id === cleared.objectId) : undefined; - if (object) object.ownerId = cleared.ownerId; + for (const entry of this.removedObjects) { + const scene = findScene(project, entry.sceneId); + if (scene) restoreObjects(scene, entry.removed); } project.activeSceneId = this.previousActiveScene; } diff --git a/src/core/commands/sceneObjectCommands.ts b/src/core/commands/sceneObjectCommands.ts index 8ecf4b52..ef9c2da7 100644 --- a/src/core/commands/sceneObjectCommands.ts +++ b/src/core/commands/sceneObjectCommands.ts @@ -1,6 +1,8 @@ import { isPlaceableType } from '../model/AssetTypes'; +import { descendantsOf, isSelfOrAncestor, localPositionFor, worldPosition } from '../model/hierarchy'; import type { Position, PropertyValue, Vec2 } from '../model/Properties'; import { findScene, findSceneObject, type Project } from '../model/Project'; +import type { SceneAsset } from '../model/Asset'; import { createSceneObject, type SceneObject } from '../model/SceneObject'; import { coerceValue, schemaForAsset } from '../properties/PropertySchema'; import { newId } from '../ids'; @@ -19,18 +21,57 @@ export function requireObject(project: Project, sceneId: string, objectId: strin return object; } -/** Places an asset into a scene, creating a scene object. Scenes cannot be placed. */ +/** An object taken out of a scene together with the index it had, so undo can put it back. */ +export interface RemovedObject { + object: SceneObject; + index: number; +} + +/** + * Removes the given objects and all their descendants from the scene (a subtree goes as a + * whole). Returns what was removed in ascending index order. + */ +export function removeSubtrees(scene: SceneAsset, rootIds: string[]): RemovedObject[] { + const ids = new Set(); + for (const rootId of rootIds) { + ids.add(rootId); + for (const child of descendantsOf(scene, rootId)) ids.add(child.id); + } + const removed: RemovedObject[] = []; + scene.objects.forEach((object, index) => { + if (ids.has(object.id)) removed.push({ object, index }); + }); + scene.objects = scene.objects.filter((o) => !ids.has(o.id)); + return removed; +} + +/** Puts removed objects back at their former indices (ascending order restores them exactly). */ +export function restoreObjects(scene: SceneAsset, removed: RemovedObject[]): void { + for (const entry of [...removed].sort((a, b) => a.index - b.index)) scene.objects.splice(entry.index, 0, entry.object); +} + +/** + * Places an asset into a scene, creating a scene object. Scenes cannot be placed. The + * position is a world position; with a parent it is converted to the parent's local space. + */ export class PlaceAssetCommand implements Command { readonly label = 'Place asset'; readonly objectId = newId(); - constructor(private readonly sceneId: string, private readonly assetId: string, private readonly position: Vec2 | Position) {} + constructor( + private readonly sceneId: string, + private readonly assetId: string, + private readonly position: Vec2 | Position, + private readonly parentId = '', + ) {} execute(project: Project): void { const scene = requireScene(project, this.sceneId); const asset = requireAsset(project, this.assetId); if (!isPlaceableType(asset.type)) throw new CommandError('A scene cannot be placed into a scene'); - scene.objects.push(createSceneObject(this.objectId, this.assetId, this.position)); + if (this.parentId) requireObject(project, this.sceneId, this.parentId); + const world = { x: this.position.x, y: this.position.y, z: 'z' in this.position ? this.position.z : 0 }; + scene.objects.push(createSceneObject(this.objectId, this.assetId, localPositionFor(scene, this.parentId, world), this.parentId)); } undo(project: Project): void { @@ -39,42 +80,24 @@ export class PlaceAssetCommand implements Command { } } +/** Removes an object and everything parented to it. */ export class RemoveSceneObjectCommand implements Command { readonly label = 'Remove object'; - private removed?: { object: SceneObject; index: number }; - private clearedOwners: string[] = []; + private removed: RemovedObject[] = []; constructor(private readonly sceneId: string, private readonly objectId: string) {} execute(project: Project): void { - const scene = requireScene(project, this.sceneId); - const index = scene.objects.findIndex((o) => o.id === this.objectId); - const object = scene.objects[index]; - if (!object) throw new CommandError(`Scene object ${this.objectId} not found`); - this.removed = { object, index }; - scene.objects.splice(index, 1); - // Objects owned by the removed one lose their owner reference. - this.clearedOwners = []; - for (const other of scene.objects) { - if (other.ownerId === this.objectId) { - other.ownerId = ''; - this.clearedOwners.push(other.id); - } - } + requireObject(project, this.sceneId, this.objectId); + this.removed = removeSubtrees(requireScene(project, this.sceneId), [this.objectId]); } undo(project: Project): void { - if (!this.removed) return; - const scene = requireScene(project, this.sceneId); - scene.objects.splice(this.removed.index, 0, this.removed.object); - for (const id of this.clearedOwners) { - const other = findSceneObject(scene, id); - if (other) other.ownerId = this.objectId; - } + restoreObjects(requireScene(project, this.sceneId), this.removed); } } -/** Sets the full position (x, y and draw order z) of a scene object. */ +/** Sets the full local position (x, y and draw order z) of a scene object. */ export class MoveSceneObjectCommand implements Command { readonly label = 'Move object'; private previous: Position = { x: 0, y: 0, z: 0 }; @@ -167,24 +190,33 @@ export class ClearOverrideCommand implements Command { } } -/** Points an object (typically a behavior) at another object of the same scene, or at nothing (''). */ -export class SetOwnerCommand implements Command { - readonly label = 'Set owner'; - private previous = ''; +/** + * Re-parents an object ('' = top level). The object keeps its world position: its local + * position is recomputed for the new parent. Cycles (an object below itself) are rejected. + */ +export class SetParentCommand implements Command { + readonly label = 'Set parent'; + private previous: { parentId: string; position: Position } = { parentId: '', position: { x: 0, y: 0, z: 0 } }; - constructor(private readonly sceneId: string, private readonly objectId: string, private readonly ownerId: string) {} + constructor(private readonly sceneId: 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); - if (this.ownerId) { - if (this.ownerId === this.objectId) throw new CommandError('An object cannot own itself'); - requireObject(project, this.sceneId, this.ownerId); + 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)) throw new CommandError('An object cannot be parented to one of its own children'); } - this.previous = object.ownerId; - object.ownerId = this.ownerId; + this.previous = { parentId: object.parentId, position: { ...object.position } }; + const world = worldPosition(scene, object); + object.parentId = this.parentId; + object.position = localPositionFor(scene, this.parentId, world); } undo(project: Project): void { - requireObject(project, this.sceneId, this.objectId).ownerId = this.previous; + const object = requireObject(project, this.sceneId, this.objectId); + object.parentId = this.previous.parentId; + object.position = { ...this.previous.position }; } } diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 0fc74c78..8617b4b8 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -12,10 +12,26 @@ export type Value = PropertyValue | EntityHandle | null; export interface EntityHandle { id: string; name: string; + /** Position local to `parent` (world position for top-level entities). */ x: number; y: number; z: number; props: PropertyBag; + /** The entity this one is parented to; children move with their parent. */ + parent: EntityHandle | null; +} + +/** World position of a handle: its local position plus those of all ancestors. */ +export function worldPositionOf(handle: EntityHandle): { x: number; y: number; z: number } { + const result = { x: handle.x, y: handle.y, z: handle.z }; + const seen = new Set([handle]); + for (let p = handle.parent; p && !seen.has(p); p = p.parent) { + result.x += p.x; + result.y += p.y; + result.z += p.z; + seen.add(p); + } + return result; } /** @@ -26,7 +42,7 @@ export interface EntityHandle { export interface NodeContext { /** The behavior entity itself. */ self: EntityHandle; - /** The scene object the behavior was attached to, if any. */ + /** The behavior's parent object (the one the graph acts on), if any. */ owner: EntityHandle | null; /** Mutable variable state of this behavior instance. */ variables: PropertyBag; diff --git a/src/core/graph/nodes.ts b/src/core/graph/nodes.ts index 5dbb4c37..c60dcfb2 100644 --- a/src/core/graph/nodes.ts +++ b/src/core/graph/nodes.ts @@ -1,5 +1,5 @@ import type { PropertyDef } from '../properties/PropertySchema'; -import { asBoolean, asEntity, asNumber, asString, type PortDef, registerNode } from './NodeRegistry'; +import { asBoolean, asEntity, asNumber, asString, type PortDef, registerNode, worldPositionOf } from './NodeRegistry'; /** * The built-in node set. Each entry is data + a `run` function; everything else (palette, @@ -59,7 +59,17 @@ registerNode({ // --- data: references -------------------------------------------------------------------- registerNode({ type: 'Self', label: 'Self', category: 'data', description: 'This behavior object.', inputs: [], outputs: [obj('object')], params: [], run: (ctx) => ({ outputs: { object: ctx.self } }) }); -registerNode({ type: 'Owner', label: 'Owner', category: 'data', description: 'The object this behavior is attached to.', inputs: [], outputs: [obj('object')], params: [], run: (ctx) => ({ outputs: { object: ctx.owner } }) }); +registerNode({ type: 'Owner', label: 'Owner', category: 'data', description: 'The object this behavior is parented to.', inputs: [], outputs: [obj('object')], params: [], run: (ctx) => ({ outputs: { object: ctx.owner } }) }); +registerNode({ + type: 'Parent', + label: 'Parent', + category: 'data', + description: 'The parent of an object (none for top-level objects).', + inputs: [obj('object')], + outputs: [obj('parent')], + params: [], + run: (_ctx, inputs) => ({ outputs: { parent: asEntity(inputs.object)?.parent ?? null } }), +}); registerNode({ type: 'ObjectByName', label: 'Object by name', @@ -141,13 +151,14 @@ registerNode({ type: 'GetPosition', label: 'Get position', category: 'data', - description: 'The x/y position of an object.', + description: 'The position of an object: local to its parent (x/y) and in world units (world x/y).', inputs: [obj('object')], - outputs: [num('x'), num('y')], + outputs: [num('x'), num('y'), num('worldX', 'world x'), num('worldY', 'world y')], params: [], run: (_ctx, inputs) => { const target = asEntity(inputs.object); - return { outputs: { x: target?.x ?? 0, y: target?.y ?? 0 } }; + const world = target ? worldPositionOf(target) : { x: 0, y: 0 }; + return { outputs: { x: target?.x ?? 0, y: target?.y ?? 0, worldX: world.x, worldY: world.y } }; }, }); diff --git a/src/core/model/SceneObject.ts b/src/core/model/SceneObject.ts index 7e4f2658..cab5496e 100644 --- a/src/core/model/SceneObject.ts +++ b/src/core/model/SceneObject.ts @@ -10,24 +10,28 @@ export interface SceneObject { assetId: string; /** Optional display name; falls back to the asset name when empty. */ name: string; - /** World position plus draw order (`z`). */ + /** + * Position **relative to the parent** (world position when there is no parent) plus the + * draw order `z`, which is relative as well. See `hierarchy.ts` for world coordinates. + */ position: Position; overrides: PropertyBag; /** - * Another object of the same scene this one acts on (used by behaviors: "the owner"). - * Empty string = none. Any object may carry one, only behaviors interpret it. + * Parent object of the same scene, empty string = top level. Children move with their + * parent and are removed with it. Behaviors treat their parent as the "owner" the graph + * acts on. */ - ownerId: string; + parentId: string; } /** Creates a scene object; `z` defaults to 0 when the caller only has a 2D point. */ -export function createSceneObject(id: string, assetId: string, position: Vec2 | Position): SceneObject { +export function createSceneObject(id: string, assetId: string, position: Vec2 | Position, parentId = ''): SceneObject { return { id, assetId, name: '', position: { x: position.x, y: position.y, z: 'z' in position ? position.z : 0 }, overrides: {}, - ownerId: '', + parentId, }; } diff --git a/src/core/model/hierarchy.ts b/src/core/model/hierarchy.ts new file mode 100644 index 00000000..7c292fb4 --- /dev/null +++ b/src/core/model/hierarchy.ts @@ -0,0 +1,93 @@ +import type { SceneAsset } from './Asset'; +import type { Position } from './Properties'; +import type { SceneObject } from './SceneObject'; + +/** + * Parent/child structure of a scene. `scene.objects` stays a flat list (storage order = + * creation order); the tree is implied by `SceneObject.parentId`. Positions are local to + * the parent, so the world position is the sum along the chain – there is no rotation or + * scale yet, which keeps this trivially invertible. + */ + +export function findObject(scene: SceneAsset, id: string): SceneObject | undefined { + return scene.objects.find((o) => o.id === id); +} + +export function childrenOf(scene: SceneAsset, parentId: string): SceneObject[] { + return scene.objects.filter((o) => o.parentId === parentId); +} + +/** All objects below `id`, depth-first, parents before children. */ +export function descendantsOf(scene: SceneAsset, id: string): SceneObject[] { + const result: SceneObject[] = []; + const visit = (parentId: string) => { + for (const child of childrenOf(scene, parentId)) { + result.push(child); + visit(child.id); + } + }; + visit(id); + return result; +} + +/** Whether `ancestorId` is `objectId` itself or one of its ancestors (guards against cycles). */ +export function isSelfOrAncestor(scene: SceneAsset, ancestorId: string, objectId: string): boolean { + let current: SceneObject | undefined = findObject(scene, objectId); + const seen = new Set(); + while (current && !seen.has(current.id)) { + if (current.id === ancestorId) return true; + seen.add(current.id); + current = current.parentId ? findObject(scene, current.parentId) : undefined; + } + return false; +} + +/** Position of an object in world units (all local positions along the chain summed up). */ +export function worldPosition(scene: SceneAsset, object: SceneObject): Position { + const result = { ...object.position }; + let parent = object.parentId ? findObject(scene, object.parentId) : undefined; + const seen = new Set([object.id]); + while (parent && !seen.has(parent.id)) { + result.x += parent.position.x; + result.y += parent.position.y; + result.z += parent.position.z; + seen.add(parent.id); + parent = parent.parentId ? findObject(scene, parent.parentId) : undefined; + } + return result; +} + +/** The local position that puts an object at `world` under the given parent ('' = top level). */ +export function localPositionFor(scene: SceneAsset, parentId: string, world: Position): Position { + const parent = parentId ? findObject(scene, parentId) : undefined; + if (!parent) return { ...world }; + const origin = worldPosition(scene, parent); + return { x: world.x - origin.x, y: world.y - origin.y, z: world.z - origin.z }; +} + +export interface TreeEntry { + object: SceneObject; + depth: number; +} + +/** + * Objects in tree order: top-level objects in storage order, each followed by its subtree. + * This is the order the hierarchy panel shows and the tie-break order for equal `z`. + */ +export function treeOrder(scene: SceneAsset): TreeEntry[] { + const result: TreeEntry[] = []; + const ids = new Set(scene.objects.map((o) => o.id)); + const visit = (parentId: string, depth: number) => { + for (const object of scene.objects) { + if (object.parentId !== parentId) continue; + result.push({ object, depth }); + visit(object.id, depth + 1); + } + }; + visit('', 0); + // Objects whose parent is missing (should not happen after loading) are listed at the top level. + for (const object of scene.objects) { + if (object.parentId && !ids.has(object.parentId) && !result.some((e) => e.object === object)) result.push({ object, depth: 0 }); + } + return result; +} diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 228d4225..161e264a 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -83,12 +83,25 @@ function normalizeObjects(raw: unknown, schemas: Map): S name: typeof o.name === 'string' ? o.name : '', position: { x: toNumber(p.x), y: toNumber(p.y), z: toNumber(p.z) }, overrides: normalizeBag(o.overrides, schema, true), - ownerId: typeof o.ownerId === 'string' ? o.ownerId : '', + // `ownerId` is the pre-hierarchy name of the same link (documents saved before parenting). + parentId: typeof o.parentId === 'string' ? o.parentId : typeof o.ownerId === 'string' ? o.ownerId : '', }); } - // Owners must exist in the same scene and differ from the object itself. - const ids = new Set(objects.map((o) => o.id)); - for (const o of objects) if (o.ownerId && (o.ownerId === o.id || !ids.has(o.ownerId))) o.ownerId = ''; + // Parents must exist in the same scene, differ from the object itself and not form cycles. + const byId = new Map(objects.map((o) => [o.id, o])); + for (const o of objects) if (o.parentId && (o.parentId === o.id || !byId.has(o.parentId))) o.parentId = ''; + for (const o of objects) { + const seen = new Set([o.id]); + let current = o.parentId ? byId.get(o.parentId) : undefined; + while (current) { + if (seen.has(current.id)) { + o.parentId = ''; + break; + } + seen.add(current.id); + current = current.parentId ? byId.get(current.parentId) : undefined; + } + } return objects; } diff --git a/src/editor/App.ts b/src/editor/App.ts index 1636e5ea..6dd3bdac 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -41,10 +41,11 @@ function installDebugHook(app: Application, state: EditorState, editor: Editor): const p = viewport.getGlobalPosition(); return { x: p.x + viewport.w / 2, y: p.y + viewport.h / 2 }; }; - const locate = (text: string): { x: number; y: number } | null => { + /** Centre of the first (or, with `last`, the topmost/last-drawn) visible text equal to `text`. */ + const locate = (text: string, last = false): { x: number; y: number } | null => { let found: Text | null = null; const walk = (node: Container): void => { - if (found || !node.visible) return; + if ((found && !last) || !node.visible) return; if (node instanceof Text && node.text === text) { found = node; return; @@ -74,6 +75,8 @@ function installDebugHook(app: Application, state: EditorState, editor: Editor): }, }, world: () => editor.worldSnapshot(), + hierarchyOrder: () => editor.hierarchy.rowOrder(), + fps: () => editor.fps, tab: () => editor.tab, showTab: (tab: CentreTab) => editor.showTab(tab), }; @@ -82,6 +85,7 @@ function installDebugHook(app: Application, state: EditorState, editor: Editor): class Editor { readonly viewport: Viewport; readonly graphEditor: GraphEditor; + readonly hierarchy: HierarchyPanel; tab: CentreTab = 'scene'; private readonly leftColumn: VStack; private readonly inspector: Inspector; @@ -93,16 +97,21 @@ class Editor { private world: World | null = null; private behaviors: BehaviorSystem | null = null; private readonly input = new Input(); + private readonly toolbar: Toolbar; + /** Frame-rate meter: frames counted over a sliding ~0.5 s window, shown in the toolbar. */ + private fpsFrames = 0; + private fpsElapsed = 0; + fps: number | null = null; private leftWidth = 240; private rightWidth = 300; constructor(app: Application, root: VStack, private readonly state: EditorState, private readonly store: ProjectStore) { - const toolbar = new Toolbar(state, { + this.toolbar = new Toolbar(state, { openProjectMenu: (anchor) => this.openProjectMenu(anchor), openSceneMenu: (anchor) => this.openSceneMenu(anchor), togglePlay: () => (state.mode === 'edit' ? this.play() : this.stop()), }); - root.add(toolbar); + root.add(this.toolbar); this.workspace = new HStack(); this.viewport = new Viewport(state); @@ -111,7 +120,8 @@ class Editor { this.graphEditor.timeProvider = () => this.world?.time ?? 0; this.leftColumn = new VStack({ gap: 1 }); this.leftColumn.add(new AssetPanel(state, () => this.viewport.worldCenter()), 1); - this.leftColumn.add(new HierarchyPanel(state), 1); + this.hierarchy = new HierarchyPanel(state); + this.leftColumn.add(this.hierarchy, 1); this.inspector = new Inspector(state); // Centre: a tab strip (Scene | Graph) above the viewport or the graph editor. @@ -275,6 +285,7 @@ class Editor { } private tick(dt: number): void { + this.measureFps(dt); if (!this.world) return; this.world.tick(Math.min(dt, 0.1)); this.input.endFrame(); @@ -286,10 +297,28 @@ class Editor { if (this.graphEditor.visible) this.graphEditor.redraw(); } + /** Counts rendered frames (edit and play alike) and refreshes the toolbar readout twice a second. */ + private measureFps(dt: number): void { + this.fpsFrames += 1; + this.fpsElapsed += dt; + if (this.fpsElapsed < 0.5) return; + this.fps = Math.round(this.fpsFrames / this.fpsElapsed); + this.fpsFrames = 0; + this.fpsElapsed = 0; + this.toolbar.setFps(this.fps); + } + /** Read-only view of the running world for tests. */ - worldSnapshot(): { entities: { objectId: string; x: number; y: number; props: Record }[]; log: string[] } | null { - if (!this.world) return null; - return { entities: this.world.entities.map((e) => ({ objectId: e.objectId, x: e.x, y: e.y, props: { ...e.props } })), log: [...this.world.log] }; + worldSnapshot(): { entities: { objectId: string; x: number; y: number; worldX: number; worldY: number; parent: string; props: Record }[]; log: string[] } | null { + const world = this.world; + if (!world) return null; + return { + entities: world.entities.map((e) => { + const w = world.worldPosition(e); + return { objectId: e.objectId, x: e.x, y: e.y, worldX: w.x, worldY: w.y, parent: e.parent?.objectId ?? '', props: { ...e.props } }; + }), + log: [...world.log], + }; } private applyMode(): void { diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts index c2ecf1d9..44547b82 100644 --- a/src/editor/panels/HierarchyPanel.ts +++ b/src/editor/panels/HierarchyPanel.ts @@ -1,10 +1,15 @@ -import { RemoveSceneObjectCommand } from '../../core/commands/sceneObjectCommands'; +import { RemoveSceneObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands'; +import { treeOrder } from '../../core/model/hierarchy'; import { findAsset } from '../../core/model/Project'; import { resolveSceneObjectProps } from '../../core/properties/resolve'; import { ListView, Menu, Panel, ScrollContainer } from '../../ui'; import type { EditorState } from '../state'; -/** Objects placed in the active scene. */ +/** + * Objects placed in the active scene as a tree (children indented below their parent). + * 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. + */ export class HierarchyPanel extends Panel { private readonly list = new ListView(); private readonly scroll = new ScrollContainer(); @@ -17,12 +22,20 @@ export class HierarchyPanel extends Panel { const scene = state.activeScene; state.select(id && scene ? { kind: 'object', sceneId: scene.id, objectId: id } : null); }; + this.list.onDrop = (draggedId, targetId) => { + const scene = state.activeScene; + if (!scene) return; + const object = scene.objects.find((o) => o.id === draggedId); + if (!object || object.parentId === (targetId ?? '')) return; + state.execute(new SetParentCommand(scene.id, draggedId, targetId ?? '')); + }; this.list.onContextMenu = (id, x, y) => { const scene = state.activeScene; if (!scene) return; const object = scene.objects.find((o) => o.id === id); new Menu([ { label: 'Select asset', onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }) }, + { label: 'Unparent (move to top level)', disabled: !object?.parentId, onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')) }, { label: 'Remove from scene', danger: true, onSelect: () => state.execute(new RemoveSceneObjectCommand(scene.id, id)) }, ]).open(x, y); }; @@ -36,19 +49,24 @@ export class HierarchyPanel extends Panel { this.refresh(); } + /** Ids in the order the rows are shown (test hook). */ + rowOrder(): string[] { + const scene = this.state.activeScene; + return scene ? treeOrder(scene).map((e) => e.object.id) : []; + } + private refresh(): void { const scene = this.state.activeScene; this.setTitle(scene ? `Scene: ${scene.name}` : 'Scene objects'); this.list.setItems( - (scene?.objects ?? []).map((object) => { + (scene ? treeOrder(scene) : []).map(({ object, depth }) => { const asset = findAsset(this.state.project, object.assetId); const props = asset ? resolveSceneObjectProps(asset, object) : {}; - const owner = object.ownerId ? scene?.objects.find((o) => o.id === object.ownerId) : undefined; - const ownerName = owner ? owner.name || findAsset(this.state.project, owner.assetId)?.name || '?' : ''; return { id: object.id, label: object.name || asset?.name || '?', - hint: owner ? `→ ${ownerName}` : asset?.type ?? '', + hint: asset?.type ?? '', + depth, swatch: asset?.type === 'sprite' ? Number(props.color ?? 0) : undefined, }; }), diff --git a/src/editor/panels/Inspector.ts b/src/editor/panels/Inspector.ts index ee21b520..b8005d84 100644 --- a/src/editor/panels/Inspector.ts +++ b/src/editor/panels/Inspector.ts @@ -5,11 +5,12 @@ import { MoveSceneObjectCommand, RenameSceneObjectCommand, SetOverrideCommand, - SetOwnerCommand, + SetParentCommand, } from '../../core/commands/sceneObjectCommands'; import { AddVariableCommand, nextVariableName, RemoveVariableCommand, RenameVariableCommand } from '../../core/commands/variableCommands'; import { edgesInto, inlineInputDef, inlineInputs, nodeDef } from '../../core/graph'; -import type { Asset, BehaviorAsset } from '../../core/model/Asset'; +import type { Asset, BehaviorAsset, SceneAsset } from '../../core/model/Asset'; +import { isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy'; import type { Position, PropertyValue } from '../../core/model/Properties'; import { findAsset, findScene, findSceneObject } from '../../core/model/Project'; import type { SceneObject } from '../../core/model/SceneObject'; @@ -80,7 +81,7 @@ export class Inspector extends Panel { if (s.kind === 'object') { const object = this.currentObject(s.sceneId, s.objectId); const asset = object && findAsset(this.state.project, object.assetId); - return asset ? `${object.ownerId}|` + schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing'; + return asset ? `${object.parentId}|` + schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing'; } const asset = findAsset(this.state.project, s.assetId); if (!asset || asset.type !== 'behavior') return 'missing'; @@ -206,10 +207,11 @@ export class Inspector extends Panel { assetRow.add(new Button(asset.name, { variant: 'ghost', onClick: () => this.state.select({ kind: 'asset', assetId: asset.id }) }), 1); this.body.add(assetRow); - if (asset.type === 'behavior') this.body.add(this.ownerRow(scene.objects, object, sceneId)); + this.body.add(this.parentRow(scene, object, sceneId)); - // Position – belongs to the scene object only. Z is the draw order (higher = on top). - this.body.add(new Label('POSITION', { style: 'title' })); + // Position – belongs to the scene object only, local to the parent. Z is the draw order (higher = on top). + const parent = object.parentId ? findSceneObject(scene, object.parentId) : undefined; + this.body.add(new Label(parent ? 'POSITION (LOCAL TO PARENT)' : 'POSITION', { style: 'title' })); for (const axis of ['x', 'y', 'z'] as const) { let original: Position | undefined; const row = createFieldRow({ key: axis, label: axis.toUpperCase(), kind: 'number', default: 0, step: 1 }, object.position[axis], { @@ -275,22 +277,26 @@ export class Inspector extends Panel { } } - /** "Owner: ▾" – picks another object of the same scene, or none. */ - private ownerRow(objects: SceneObject[], object: SceneObject, sceneId: string): HStack { + /** + * "Parent: ▾" – picks another object of the same scene, or none. The object's own + * subtree is excluded (that would be a cycle). For behaviors the parent is the owner. + */ + private parentRow(scene: SceneAsset, object: SceneObject, sceneId: string): HStack { const row = new HStack({ gap: Theme.gap, align: 'center' }); row.minHeight = Theme.rowHeight; - const label = new Label('Owner', { style: 'muted' }); + const label = new Label('Parent', { style: 'muted' }); label.minWidth = FIELD_LABEL_WIDTH; row.add(label); const nameOf = (o: SceneObject) => o.name || findAsset(this.state.project, o.assetId)?.name || '?'; - const owner = objects.find((o) => o.id === object.ownerId); - const button = new Button(`${owner ? nameOf(owner) : 'None'} ▾`); + const parent = scene.objects.find((o) => o.id === object.parentId); + const button = new Button(`${parent ? nameOf(parent) : 'None'} ▾`); button.onClick = () => { const g = button.getGlobalPosition(); - const items: MenuItem[] = [{ label: 'None', onSelect: () => this.state.execute(new SetOwnerCommand(sceneId, object.id, '')) }]; - for (const candidate of objects) { - if (candidate.id === object.id) continue; - items.push({ label: candidate.id === object.ownerId ? `● ${nameOf(candidate)}` : nameOf(candidate), onSelect: () => this.state.execute(new SetOwnerCommand(sceneId, object.id, candidate.id)) }); + const items: MenuItem[] = [{ label: 'None', onSelect: () => this.state.execute(new SetParentCommand(sceneId, object.id, '')) }]; + for (const { object: candidate, depth } of treeOrder(scene)) { + if (isSelfOrAncestor(scene, object.id, candidate.id)) continue; + const name = `${' '.repeat(depth)}${nameOf(candidate)}`; + items.push({ label: candidate.id === object.parentId ? `● ${name}` : name, onSelect: () => this.state.execute(new SetParentCommand(sceneId, object.id, candidate.id)) }); } new Menu(items).open(g.x, g.y + button.h + 2); }; diff --git a/src/editor/panels/Toolbar.ts b/src/editor/panels/Toolbar.ts index d94927c8..e5164466 100644 --- a/src/editor/panels/Toolbar.ts +++ b/src/editor/panels/Toolbar.ts @@ -28,6 +28,7 @@ export class Toolbar extends HStack { private readonly playButton: Button; private readonly status: Label; private readonly notice: Label; + private readonly fps: Label; constructor(private readonly state: EditorState, actions: ToolbarActions) { super({ gap: Theme.gap, padding: Theme.padding, align: 'center' }); @@ -68,6 +69,9 @@ export class Toolbar extends HStack { this.status = new Label('', { style: 'small', align: 'right' }); this.status.minWidth = 110; this.add(this.status); + this.fps = new Label('– fps', { style: 'small', align: 'right' }); + this.fps.minWidth = 56; + this.add(this.fps); state.on('project', () => this.refresh()); state.on('load', () => this.refresh()); @@ -77,6 +81,11 @@ export class Toolbar extends HStack { this.refresh(); } + /** Frame rate readout; `null` while no measurement is available yet. */ + setFps(fps: number | null): void { + this.fps.setText(fps === null ? '– fps' : `${fps} fps`); + } + private refresh(): void { const { state } = this; this.projectName.setValue(state.project.name); diff --git a/src/runtime/Entity.ts b/src/runtime/Entity.ts index a54406fc..f2135378 100644 --- a/src/runtime/Entity.ts +++ b/src/runtime/Entity.ts @@ -13,12 +13,13 @@ export interface Entity { assetId: string; type: AssetType; name: string; + /** Position local to `parent` (world position for top-level entities). */ x: number; y: number; - /** Draw order; higher is drawn on top. */ + /** Draw order, relative to the parent as well; higher is drawn on top. */ z: number; /** For behaviors: the resolved variables (mutable state). For others: resolved properties. */ props: PropertyBag; - /** Scene object id of the owner (behaviors), empty when none. */ - ownerObjectId: string; + /** Parent entity (mirrors `SceneObject.parentId`); children move and die with it. */ + parent: Entity | null; } diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 23e9386c..182b9972 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -1,3 +1,4 @@ +import { worldPositionOf } from '../core/graph'; import { findAsset, findScene, type Project } from '../core/model/Project'; import { resolveSceneObjectProps } from '../core/properties/resolve'; import type { Entity } from './Entity'; @@ -27,10 +28,11 @@ export class World { if (!scene) throw new Error(`Scene ${sceneId} not found`); const background = scene.props.backgroundColor; const world = new World(sceneId, typeof background === 'number' ? background : 0x000000); + const parentIds = new Map(); for (const object of scene.objects) { const asset = findAsset(project, object.assetId); if (!asset || asset.type === 'scene') continue; - world.entities.push({ + const entity: Entity = { id: `e-${object.id}`, objectId: object.id, assetId: asset.id, @@ -40,12 +42,25 @@ export class World { y: object.position.y, z: object.position.z, props: resolveSceneObjectProps(asset, object), - ownerObjectId: object.ownerId, - }); + parent: null, + }; + world.entities.push(entity); + if (object.parentId) parentIds.set(entity, object.parentId); } + // Link parents once every entity exists (storage order is not guaranteed to be parent-first). + for (const [entity, parentId] of parentIds) entity.parent = world.findByObjectId(parentId) ?? null; return world; } + /** World position of an entity: local position plus those of all ancestors. */ + worldPosition(entity: Entity): { x: number; y: number; z: number } { + return worldPositionOf(entity); + } + + childrenOf(entity: Entity): Entity[] { + return this.entities.filter((e) => e.parent === entity); + } + addSystem(system: System): this { this.systems.push(system); system.start?.(this); @@ -74,7 +89,9 @@ export class World { return this.entities.find((e) => e.type === type); } + /** Removes an entity and, recursively, everything parented to it. */ remove(entity: Entity): void { + for (const child of this.childrenOf(entity)) this.remove(child); const index = this.entities.indexOf(entity); if (index >= 0) this.entities.splice(index, 1); } diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index db8f1820..20004ca9 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -99,7 +99,7 @@ export class BehaviorSystem implements System { private context(entity: Entity, world: World, dt: number): NodeContext { return { self: entity, - owner: entity.ownerObjectId ? world.findByObjectId(entity.ownerObjectId) ?? null : null, + owner: entity.parent, variables: entity.props, time: world.time, dt, diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts index ade77fea..df4bdc31 100644 --- a/src/ui/widgets/ListView.ts +++ b/src/ui/widgets/ListView.ts @@ -14,9 +14,13 @@ export interface ListItem { swatch?: number; } +/** Pixels the pointer must travel before a press turns into a drag. */ +const DRAG_THRESHOLD = 4; + /** * Single-select list of rows. Rows are rebuilt from `setItems()`; selection is by id so * it survives rebuilds. Emits select, activate (double-click / Enter) and context events. + * With `onDrop` set, rows can be dragged onto other rows (or onto the empty area below). */ export class ListView extends Widget { private items: ListItem[] = []; @@ -25,23 +29,37 @@ export class ListView extends Widget { onSelect?: (id: string | null) => void; onActivate?: (id: string) => void; onContextMenu?: (id: string, globalX: number, globalY: number) => void; + /** Called when a dragged row is released over another row (`targetId`) or over empty space (null). */ + onDrop?: (draggedId: string, targetId: string | null) => void; rowHeight: number = Theme.rowHeight; emptyText = 'Nothing here yet'; private readonly emptyLabel = new Text({ text: '', style: textStyle('muted') }); + private readonly dropMarker = new Graphics(); + private drag: { id: string; startX: number; startY: number; active: boolean; targetId: string | null } | null = null; constructor() { super(); this.focusable = true; - this.addChild(this.emptyLabel); + this.dropMarker.eventMode = 'none'; + this.addChild(this.emptyLabel, this.dropMarker); this.on('pointerdown', (e: FederatedPointerEvent) => { if (e.target === this) this.select(null); }); + this.on('globalpointermove', (e: FederatedPointerEvent) => this.onDragMove(e)); + const finish = () => this.onDragEnd(); + this.on('pointerup', finish); + this.on('pointerupoutside', finish); } get selected(): string | null { return this.selectedId; } + /** Id of the row currently hovered by a drag, for tests and styling. */ + get dropTarget(): string | null { + return this.drag?.active ? this.drag.targetId : null; + } + setItems(items: ListItem[]): void { this.items = items; for (const row of this.rows) row.destroy({ children: true }); @@ -54,12 +72,14 @@ export class ListView extends Widget { } else { this.select(item.id); if (e.detail === 2) this.onActivate?.(item.id); + else if (this.onDrop) this.drag = { id: item.id, startX: e.global.x, startY: e.global.y, active: false, targetId: null }; } }); row.position.set(0, index * this.rowHeight); this.addChild(row); return row; }); + this.addChild(this.dropMarker); // keep the marker above the rows if (this.selectedId && !items.some((i) => i.id === this.selectedId)) this.selectedId = null; this.layout(); this.redraw(); @@ -103,6 +123,47 @@ export class ListView extends Widget { for (const row of this.rows) row.setSelected(row.item.id === this.selectedId, this.focused); this.emptyLabel.text = this.items.length === 0 ? this.emptyText : ''; this.emptyLabel.position.set(Theme.padding, Math.round((this.rowHeight - this.emptyLabel.height) / 2)); + this.drawDropMarker(); + } + + // --- drag & drop ------------------------------------------------------------------------- + + private onDragMove(e: FederatedPointerEvent): void { + const drag = this.drag; + if (!drag) return; + if (!drag.active) { + if (Math.hypot(e.global.x - drag.startX, e.global.y - drag.startY) < DRAG_THRESHOLD) return; + drag.active = true; + this.cursor = 'grabbing'; + } + const local = this.toLocal(e.global); + const index = Math.floor(local.y / this.rowHeight); + const target = local.x >= 0 && local.x <= this.w && index >= 0 ? this.items[index] : undefined; + drag.targetId = target && target.id !== drag.id ? target.id : null; + this.invalidate(); + } + + private onDragEnd(): void { + const drag = this.drag; + this.drag = null; + this.cursor = 'default'; + if (!drag?.active) return; + this.invalidate(); + this.onDrop?.(drag.id, drag.targetId); + } + + /** Highlights the row a drag would drop onto, or underlines the list for "drop at top level". */ + private drawDropMarker(): void { + this.dropMarker.clear(); + const drag = this.drag; + if (!drag?.active) return; + const index = this.items.findIndex((i) => i.id === drag.targetId); + if (index >= 0) { + this.dropMarker.rect(1, index * this.rowHeight + 1, this.w - 2, this.rowHeight - 2).stroke({ width: 2, color: Theme.colors.accent }); + } else { + const y = Math.min(this.h, this.items.length * this.rowHeight) - 1; + this.dropMarker.rect(1, Math.max(0, y - 1), this.w - 2, 2).fill(Theme.colors.accent); + } } } @@ -132,10 +193,16 @@ class Row extends Widget { override redraw(): void { const c = Theme.colors; - const indent = Theme.padding + (this.item.depth ?? 0) * 14; + const depth = this.item.depth ?? 0; + const indent = Theme.padding + depth * 14; this.bg.clear(); if (this.selected) this.bg.rect(0, 0, this.w, this.h).fill(this.listFocused ? c.selection : c.controlActive); else if (this.hovered) this.bg.rect(0, 0, this.w, this.h).fill(c.controlHover); + // Tree guide: a short "└" mark in front of nested rows. + if (depth > 0) { + const gx = indent - 8; + this.bg.moveTo(gx, 0).lineTo(gx, this.h / 2).lineTo(indent - 2, this.h / 2).stroke({ width: 1, color: c.border }); + } let x = indent; if (this.item.swatch !== undefined) { this.bg.roundRect(x, (this.h - 12) / 2, 12, 12, 2).fill(this.item.swatch); diff --git a/src/viewport/Viewport.ts b/src/viewport/Viewport.ts index 33d20f06..f169818d 100644 --- a/src/viewport/Viewport.ts +++ b/src/viewport/Viewport.ts @@ -2,6 +2,7 @@ import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphi import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands'; import { type AssetType, isSymbolType } from '../core/model/AssetTypes'; import { type Position, type PropertyBag, sortByZ, type Vec2 } from '../core/model/Properties'; +import { treeOrder, worldPosition } from '../core/model/hierarchy'; import { findAsset } from '../core/model/Project'; import { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera'; import { resolveSceneObjectProps } from '../core/properties/resolve'; @@ -16,11 +17,11 @@ interface DrawItem { name: string; x: number; y: number; - /** Draw order (see `Position.z`). */ + /** Draw order (see `Position.z`). `x`/`y`/`z` are world values (parents already applied). */ z: number; props: PropertyBag; - /** Id of the owner item (behaviors), empty when none. */ - ownerId: string; + /** Id of the parent item, empty when top-level. */ + parentId: string; } /** World-unit size of the behavior symbol (a diamond). */ @@ -106,6 +107,7 @@ export class Viewport extends Widget { this.dragging.moved = true; const object = this.state.activeScene?.objects.find((o) => o.id === this.dragging?.objectId); if (!object) return; + // A world-space delta is a local-space delta as well (no rotation/scale in the hierarchy). object.position = { x: Math.round(this.dragging.start.x + dx), y: Math.round(this.dragging.start.y + dy), z: this.dragging.start.z }; this.state.preview(); } @@ -214,18 +216,20 @@ export class Viewport extends Widget { private collectItems(): { items: DrawItem[]; background: number } { if (this.playWorld) { // Symbols (camera, behaviors) are editor-only; the running game shows scene content. + const world = this.playWorld; return { - background: this.playWorld.backgroundColor, - items: this.playWorld.entities.filter((e) => !isSymbolType(e.type)).map((e) => ({ id: e.objectId, type: e.type, name: e.name, x: e.x, y: e.y, z: e.z, props: e.props, ownerId: '' })), + background: world.backgroundColor, + items: world.entities.filter((e) => !isSymbolType(e.type)).map((e) => ({ id: e.objectId, type: e.type, name: e.name, ...world.worldPosition(e), props: e.props, parentId: e.parent?.objectId ?? '' })), }; } const scene = this.state.activeScene; if (!scene) return { items: [], background: Theme.colors.viewport }; const items: DrawItem[] = []; - for (const object of scene.objects) { + // Tree order (parents before children) is the tie-break for equal z. + for (const { object } of treeOrder(scene)) { const asset = findAsset(this.state.project, object.assetId); if (!asset) continue; - items.push({ id: object.id, type: asset.type, name: object.name || asset.name, x: object.position.x, y: object.position.y, z: object.position.z, props: resolveSceneObjectProps(asset, object), ownerId: object.ownerId }); + items.push({ id: object.id, type: asset.type, name: object.name || asset.name, ...worldPosition(scene, object), props: resolveSceneObjectProps(asset, object), parentId: object.parentId }); } return { items, background: Number(scene.props.backgroundColor ?? Theme.colors.viewport) }; } @@ -296,7 +300,7 @@ export class Viewport extends Widget { node.circle(6, 3, 2).fill(Theme.colors.accentText); } } - this.drawOwnerLinks(items, editing, view.scale); + this.drawParentLinks(items, editing, view.scale); for (const [id, node] of this.nodes) { if (!seen.has(id)) { node.destroy(); @@ -329,18 +333,22 @@ export class Viewport extends Widget { return isSymbolType(item.type); } - /** Dashed line from every behavior symbol to its owner (edit mode only). */ - private drawOwnerLinks(items: DrawItem[], editing: boolean, scale: number): void { + /** + * Dashed line from every parented symbol (behavior, camera) to its parent (edit mode only). + * Sprites show their nesting in the hierarchy panel instead, to keep the scene uncluttered. + */ + private drawParentLinks(items: DrawItem[], editing: boolean, scale: number): void { this.links.clear(); if (!editing) return; const byId = new Map(items.map((i) => [i.id, i])); for (const item of items) { - if (item.type !== 'behavior' || !item.ownerId) continue; - const owner = byId.get(item.ownerId); - if (!owner) continue; - dashedLine(this.links, item.x, item.y, owner.x, owner.y, 8 / scale, 6 / scale); - this.links.stroke({ width: 1.5 / scale, color: Theme.colors.behavior, alpha: 0.8 }); - this.links.circle(owner.x, owner.y, 3 / scale).fill(Theme.colors.behavior); + if (!this.isSymbol(item) || !item.parentId) continue; + const parent = byId.get(item.parentId); + if (!parent) continue; + const color = item.type === 'behavior' ? Theme.colors.behavior : Theme.colors.textMuted; + dashedLine(this.links, item.x, item.y, parent.x, parent.y, 8 / scale, 6 / scale); + this.links.stroke({ width: 1.5 / scale, color, alpha: 0.8 }); + this.links.circle(parent.x, parent.y, 3 / scale).fill(color); } } diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index 4b79d42b..baba9f58 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { buildAsset } from '../../src/core/commands/assetCommands'; import { CommandStack } from '../../src/core/commands/CommandStack'; import { AddNodeCommand, ConnectCommand, DisconnectCommand, RemoveNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands'; -import { PlaceAssetCommand, SetOwnerCommand } from '../../src/core/commands/sceneObjectCommands'; +import { PlaceAssetCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands'; import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands'; import { type EntityHandle, GraphInterpreter, type NodeContext, allNodeDefs, portsCompatible } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; @@ -10,7 +10,7 @@ import { createEmptyProject } from '../../src/core/model/Project'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; function entity(name: string, x = 0): EntityHandle { - return { id: name, name, x, y: 0, z: 0, props: {} }; + return { id: name, name, x, y: 0, z: 0, props: {}, parent: null }; } function context(overrides: Partial = {}): NodeContext & { logs: string[]; stopped: boolean } { @@ -337,7 +337,7 @@ describe('variables', () => { expect(object.overrides).toEqual({ speed: 5 }); }); - it('owner references are validated and cleared when the owner disappears', () => { + it('the owner is the parent: validated, kept across a round trip, dropped when dangling', () => { const project = createEmptyProject('p', 'P'); const scene = buildAsset('scene', 'S', 's'); const sprite = buildAsset('sprite', 'Box', 'box'); @@ -348,16 +348,15 @@ describe('variables', () => { const placeB = new PlaceAssetCommand('s', 'b', { x: 0, y: 0 }); stack.execute(placeBox); stack.execute(placeB); - expect(() => stack.execute(new SetOwnerCommand('s', placeB.objectId, placeB.objectId))).toThrow(/itself/); - stack.execute(new SetOwnerCommand('s', placeB.objectId, placeBox.objectId)); - const objects = () => (project.assets[0] as { objects: { id: string; ownerId: string }[] }).objects; - expect(objects()[1]!.ownerId).toBe(placeBox.objectId); - // Round trip keeps the owner; a dangling owner is dropped by the loader. - const loaded = deserializeProject(serializeProject(project)); - expect((loaded.assets[0] as { objects: { ownerId: string }[] }).objects[1]!.ownerId).toBe(placeBox.objectId); - const raw = serializeProject(project) as { assets: { objects?: { ownerId: string }[] }[] }; - raw.assets[0]!.objects![1]!.ownerId = 'ghost'; - expect((deserializeProject(raw).assets[0] as { objects: { ownerId: string }[] }).objects[1]!.ownerId).toBe(''); + expect(() => stack.execute(new SetParentCommand('s', placeB.objectId, placeB.objectId))).toThrow(/own parent/); + stack.execute(new SetParentCommand('s', placeB.objectId, placeBox.objectId)); + const objectsOf = (p: unknown) => ((p as { assets: { objects: { id: string; parentId: string }[] }[] }).assets[0]!).objects; + expect(objectsOf(project)[1]!.parentId).toBe(placeBox.objectId); + // Round trip keeps the parent; a dangling parent is dropped by the loader. + expect(objectsOf(deserializeProject(serializeProject(project)))[1]!.parentId).toBe(placeBox.objectId); + const raw = serializeProject(project) as { assets: { objects?: { parentId: string }[] }[] }; + raw.assets[0]!.objects![1]!.parentId = 'ghost'; + expect(objectsOf(deserializeProject(raw))[1]!.parentId).toBe(''); }); }); diff --git a/tests/core/hierarchy.test.ts b/tests/core/hierarchy.test.ts new file mode 100644 index 00000000..41d9a041 --- /dev/null +++ b/tests/core/hierarchy.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand, DeleteAssetCommand } from '../../src/core/commands/assetCommands'; +import { CommandStack } from '../../src/core/commands/CommandStack'; +import { MoveSceneObjectCommand, PlaceAssetCommand, RemoveSceneObjectCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands'; +import { GraphInterpreter, type NodeContext, worldPositionOf } from '../../src/core/graph'; +import { descendantsOf, localPositionFor, treeOrder, worldPosition } from '../../src/core/model/hierarchy'; +import { createEmptyProject, findScene } from '../../src/core/model/Project'; +import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; +import { World } from '../../src/runtime/World'; + +/** Scene with a sprite asset and three placed boxes: a at (100,100), b at (10,0), c at (0,5). */ +function setup() { + const project = createEmptyProject('p', 'Hierarchy'); + const stack = new CommandStack(project); + const scene = new CreateAssetCommand('scene', 'Level'); + const sprite = new CreateAssetCommand('sprite', 'Box'); + stack.execute(scene); + stack.execute(sprite); + const a = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 100, y: 100, z: 1 }); + const b = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 10, y: 0 }); + const c = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 5 }); + for (const cmd of [a, b, c]) stack.execute(cmd); + const sceneOf = () => findScene(project, scene.asset.id)!; + const object = (id: string) => sceneOf().objects.find((o) => o.id === id)!; + return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id, a: a.objectId, b: b.objectId, c: c.objectId, sceneOf, object }; +} + +describe('scene hierarchy', () => { + it('re-parenting keeps the world position and moves children along with the parent', () => { + const { stack, sceneId, a, b, c, sceneOf, object } = setup(); + stack.execute(new SetParentCommand(sceneId, b, a)); + // b stays at world (10,0): local becomes (-90,-100,-1). + expect(object(b).parentId).toBe(a); + expect(object(b).position).toEqual({ x: -90, y: -100, z: -1 }); + expect(worldPosition(sceneOf(), object(b))).toEqual({ x: 10, y: 0, z: 0 }); + + stack.execute(new SetParentCommand(sceneId, c, b)); + expect(worldPosition(sceneOf(), object(c))).toEqual({ x: 0, y: 5, z: 0 }); + + // Moving the root drags the whole subtree. + stack.execute(new MoveSceneObjectCommand(sceneId, a, { x: 200, y: 100, z: 1 })); + expect(worldPosition(sceneOf(), object(b))).toEqual({ x: 110, y: 0, z: 0 }); + expect(worldPosition(sceneOf(), object(c))).toEqual({ x: 100, y: 5, z: 0 }); + + // Undo restores the previous parent and local position exactly. + stack.undo(); + stack.undo(); + expect(object(c).parentId).toBe(''); + expect(object(c).position).toEqual({ x: 0, y: 5, z: 0 }); + stack.undo(); + expect(object(b).parentId).toBe(''); + expect(object(b).position).toEqual({ x: 10, y: 0, z: 0 }); + + expect(localPositionFor(sceneOf(), a, { x: 100, y: 100, z: 1 })).toEqual({ x: 0, y: 0, z: 0 }); + }); + + it('refuses cycles and self-parenting', () => { + const { stack, sceneId, a, b, c } = setup(); + stack.execute(new SetParentCommand(sceneId, b, a)); + stack.execute(new SetParentCommand(sceneId, c, b)); + expect(() => stack.execute(new SetParentCommand(sceneId, a, a))).toThrow(/own parent/); + expect(() => stack.execute(new SetParentCommand(sceneId, a, c))).toThrow(/own children/); + expect(() => stack.execute(new SetParentCommand(sceneId, a, b))).toThrow(/own children/); + }); + + it('lists objects in tree order and descendants depth-first', () => { + const { stack, sceneId, a, b, c, sceneOf } = setup(); + // Storage order is a, b, c; parent c under a → c is shown right after a, before b. + stack.execute(new SetParentCommand(sceneId, c, a)); + expect(treeOrder(sceneOf()).map((e) => [e.object.id, e.depth])).toEqual([[a, 0], [c, 1], [b, 0]]); + stack.execute(new SetParentCommand(sceneId, b, c)); + expect(descendantsOf(sceneOf(), a).map((o) => o.id)).toEqual([c, b]); + }); + + it('removing an object or deleting an asset takes the subtree along, undo restores it', () => { + const { project, stack, sceneId, spriteId, a, b, c, sceneOf } = setup(); + const behavior = new CreateAssetCommand('behavior', 'Logic'); + stack.execute(behavior); + const placeLogic = new PlaceAssetCommand(sceneId, behavior.asset.id, { x: 0, y: 0 }, a); + stack.execute(placeLogic); + expect(sceneOf().objects.find((o) => o.id === placeLogic.objectId)!.position).toEqual({ x: -100, y: -100, z: -1 }); + stack.execute(new SetParentCommand(sceneId, b, a)); + + stack.execute(new RemoveSceneObjectCommand(sceneId, a)); + expect(sceneOf().objects.map((o) => o.id)).toEqual([c]); + stack.undo(); + expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c, placeLogic.objectId]); + expect(sceneOf().objects.find((o) => o.id === b)!.parentId).toBe(a); + + // Deleting the sprite asset removes a, b, c – and the behavior parented below a. + stack.execute(new DeleteAssetCommand(spriteId)); + expect(sceneOf().objects).toHaveLength(0); + stack.undo(); + expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c, placeLogic.objectId]); + expect(project.assets.map((x) => x.id)).toContain(spriteId); + }); + + it('the loader accepts legacy ownerId, drops dangling parents and breaks cycles', () => { + const { project, stack, sceneId, a, b, c } = setup(); + stack.execute(new SetParentCommand(sceneId, b, a)); + const raw = JSON.parse(JSON.stringify(serializeProject(project))) as { assets: { objects?: Record[] }[] }; + const objects = raw.assets[0]!.objects!; + // Legacy documents stored the link as `ownerId`. + delete objects[1]!.parentId; + objects[1]!.ownerId = a; + // A cycle a → c → a and a dangling reference are both cleared. + objects[0]!.parentId = c; + objects[2]!.parentId = a; + const loaded = findScene(deserializeProject(raw), sceneId)!; + const parentOf = (id: string) => loaded.objects.find((o) => o.id === id)!.parentId; + expect(parentOf(b)).toBe(a); + expect([parentOf(a), parentOf(c)].filter(Boolean)).toHaveLength(1); // one link of the cycle survives + objects[1]!.parentId = 'ghost'; + delete objects[1]!.ownerId; + expect(findScene(deserializeProject(raw), sceneId)!.objects[1]!.parentId).toBe(''); + }); +}); + +describe('runtime hierarchy', () => { + it('entities mirror parents: world positions follow the parent, Destroy takes children along', () => { + const { project, stack, sceneId, a, b, c } = setup(); + stack.execute(new SetParentCommand(sceneId, b, a)); + stack.execute(new SetParentCommand(sceneId, c, b)); + const world = World.instantiate(project, sceneId); + const entity = (id: string) => world.findByObjectId(id)!; + expect(entity(b).parent).toBe(entity(a)); + expect(world.worldPosition(entity(c))).toEqual({ x: 0, y: 5, z: 0 }); + entity(a).x += 50; + expect(world.worldPosition(entity(c))).toEqual({ x: 50, y: 5, z: 0 }); + expect(worldPositionOf(entity(b))).toEqual({ x: 60, y: 0, z: 0 }); + world.remove(entity(a)); + expect(world.entities).toHaveLength(0); + }); + + it('Get position exposes local and world coordinates, Parent walks up', () => { + const parent = { id: 'p', name: 'p', x: 100, y: 20, z: 0, props: {}, parent: null }; + const child = { id: 'c', name: 'c', x: 5, y: 5, z: 0, props: {}, parent }; + const graph = { nodes: [ + { id: 'start', type: 'OnStart', x: 0, y: 0, params: {} }, + { id: 'self', type: 'Self', x: 0, y: 0, params: {} }, + { id: 'pos', type: 'GetPosition', x: 0, y: 0, params: {} }, + { id: 'par', type: 'Parent', x: 0, y: 0, params: {} }, + { id: 'ppos', type: 'GetPosition', x: 0, y: 0, params: {} }, + { id: 'log1', type: 'Log', x: 0, y: 0, params: {} }, + { id: 'log2', type: 'Log', x: 0, y: 0, params: {} }, + { id: 'log3', type: 'Log', x: 0, y: 0, params: {} }, + ], edges: [ + { id: 'e1', from: { node: 'start', port: 'exec' }, to: { node: 'log1', port: 'exec' } }, + { id: 'e2', from: { node: 'log1', port: 'exec' }, to: { node: 'log2', port: 'exec' } }, + { id: 'e3', from: { node: 'log2', port: 'exec' }, to: { node: 'log3', port: 'exec' } }, + { id: 'e4', from: { node: 'self', port: 'object' }, to: { node: 'pos', port: 'object' } }, + { id: 'e5', from: { node: 'self', port: 'object' }, to: { node: 'par', port: 'object' } }, + { id: 'e6', from: { node: 'par', port: 'parent' }, to: { node: 'ppos', port: 'object' } }, + { id: 'e7', from: { node: 'pos', port: 'worldX' }, to: { node: 'log1', port: 'message' } }, + { id: 'e8', from: { node: 'pos', port: 'x' }, to: { node: 'log2', port: 'message' } }, + { id: 'e9', from: { node: 'ppos', port: 'worldX' }, to: { node: 'log3', port: 'message' } }, + ] }; + const logs: string[] = []; + const ctx: NodeContext = { self: child, owner: parent, variables: {}, time: 0, dt: 0, findByName: () => null, isKeyDown: () => false, log: (m) => logs.push(m), destroy: () => {}, stop: () => {} }; + new GraphInterpreter(graph).fire('OnStart', ctx); + expect(logs).toEqual(['105', '5', '100']); + }); +}); diff --git a/tests/core/serialize.test.ts b/tests/core/serialize.test.ts index b674006b..775591e1 100644 --- a/tests/core/serialize.test.ts +++ b/tests/core/serialize.test.ts @@ -52,7 +52,7 @@ describe('serialize', () => { name: '', position: { x: 5, y: 0, z: 0 }, overrides: { color: 0x00ff00, width: 1 }, - ownerId: '', + parentId: '', }); expect(project.assets[1]!.name).toBe('sprite'); expect(project.assets[1]!.props).toEqual({ width: 10, height: 100, color: 0xe0553f }); diff --git a/tools/e2e.mjs b/tools/e2e.mjs index 5cbe4f14..3d1a9498 100644 --- a/tools/e2e.mjs +++ b/tools/e2e.mjs @@ -85,8 +85,8 @@ try { }); const evaluate = async (expression) => (await cdp.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })).result.value; - const locate = async (text) => { - const p = await evaluate(`window.__gm && window.__gm.locate(${JSON.stringify(text)})`); + const locate = async (text, last = false) => { + const p = await evaluate(`window.__gm && window.__gm.locate(${JSON.stringify(text)}, ${last})`); if (!p) throw new Error(`widget with text "${text}" not found`); return p; }; @@ -426,9 +426,17 @@ try { // 13e. Play always starts on the Scene tab; switching back to Graph while playing shows // the live highlight: hold ArrowRight → the exec edge is hot. + const fpsEdit = await evaluate('__gm.fps()'); + check('fps readout is measured in edit mode', typeof fpsEdit === 'number' && fpsEdit > 0, `fps=${fpsEdit}`); + // Single evaluate: the readout refreshes twice a second, so read value and label together. + const fpsLabel = await evaluate(`!!__gm.locate(__gm.fps() + ' fps')`); + check('fps label is visible in the toolbar', fpsLabel === true); await clickText('▶ Play'); await sleep(200); check('play switches to the scene tab', (await evaluate('__gm.tab()')) === 'scene'); + await sleep(600); + const fpsPlay = await evaluate('__gm.fps()'); + check('fps readout keeps running in play mode', typeof fpsPlay === 'number' && fpsPlay > 0, `fps=${fpsPlay}`); await evaluate(`__gm.showTab('graph')`); await sleep(100); await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }); @@ -465,6 +473,74 @@ try { check('graph editor rebinds when a behavior is selected again', (await evaluate(`__gm.graph.current()`)) === 'mover'); await evaluate(`__gm.showTab('scene')`); + // 14. Hierarchy / parenting. Give both crates unique names, then drag the blue crate's row + // onto the red crate's row: it becomes a child, keeps its world position (local becomes + // the difference) and is listed right below its parent. + await evaluate(`(() => { + const objs = __gm.state.activeScene.objects; + const o1 = objs.find(o => o.id === 'o1'); const o2 = objs.find(o => o.id === 'o2'); + o1.name = 'Red crate'; o1.position = { x: -150, y: 0, z: 0 }; + o2.name = 'Blue crate'; o2.position = { x: 120, y: -80, z: 0 }; o2.parentId = ''; + __gm.state.select(null); + __gm.state.preview(); + })()`); + await sleep(200); + const redRow = await locate('Red crate'); + const blueRow = await locate('Blue crate'); + await drag(blueRow.x, blueRow.y, redRow.x, redRow.y); + await sleep(200); + const objectJson = (id) => evaluate(`JSON.stringify(__gm.state.activeScene.objects.find(o => o.id === '${id}'))`); + let o2 = JSON.parse(await objectJson('o2')); + check('dropping a row onto another parents it, keeping the world position', o2.parentId === 'o1' && o2.position.x === 270 && o2.position.y === -80, JSON.stringify(o2)); + const order = JSON.parse(await evaluate(`JSON.stringify(__gm.hierarchyOrder())`)); + check('children are listed directly below their parent', order.indexOf('o2') === order.indexOf('o1') + 1 && order.indexOf('o4') === order.indexOf('o1') + 2, JSON.stringify(order)); + await shot('20-parented'); + + // 14a. Moving the parent (X field of the red crate) drags the child along in world space. + await clickText('Red crate'); + const xField = await locate('X'); + await click(xField.x + 160, xField.y); + await keyPress('a', 'KeyA', { modifiers: 2, windowsVirtualKeyCode: 65 }); + await typeText('-50'); + await keyPress('Enter', 'Enter', { windowsVirtualKeyCode: 13 }); + await sleep(200); + o2 = JSON.parse(await objectJson('o2')); + const worldOfO2 = await evaluate(`(() => { const s = __gm.state.activeScene; const child = s.objects.find(x => x.id === 'o2'); const parent = s.objects.find(x => x.id === child.parentId); return JSON.stringify({ x: child.position.x + parent.position.x, y: child.position.y + parent.position.y }); })()`); + check('moving the parent moves the child in world space (local unchanged)', o2.position.x === 270 && worldOfO2 === '{"x":220,"y":-80}', `${JSON.stringify(o2.position)} world=${worldOfO2}`); + + // 14b. Play: entities mirror the hierarchy; undo restores parent and position. + await evaluate(`__gm.state.select(null)`); + await keyPress('Enter', 'Enter', { modifiers: 2, windowsVirtualKeyCode: 13 }); + await sleep(300); + const playWorld = await evaluate('JSON.stringify(__gm.world())'); + const e2 = JSON.parse(playWorld).entities.find((e) => e.objectId === 'o2'); + check('play instantiates the parent link and world positions', e2 && e2.parent === 'o1' && e2.worldX === 220 && e2.x === 270, JSON.stringify(e2)); + await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 }); + await sleep(200); + await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); // undo move + await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); // undo parent + await sleep(200); + o2 = JSON.parse(await objectJson('o2')); + check('undo restores top level and the original local position', o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, JSON.stringify(o2)); + + // 14c. Parent via the inspector menu, then "Unparent" from the hierarchy context menu: + // the world position is kept both ways. + await clickText('Blue crate'); + await clickText('None ▾'); + await sleep(150); + const menuEntry = await locate('Red crate', true); // the popup is drawn last; the hierarchy row has the same text + await click(menuEntry.x, menuEntry.y); + await sleep(200); + o2 = JSON.parse(await objectJson('o2')); + check('inspector Parent menu parents the object', o2.parentId === 'o1' && o2.position.x === 270, JSON.stringify(o2)); + const blueRow2 = await locate('Blue crate'); + await click(blueRow2.x, blueRow2.y, 'right'); + await sleep(150); + await clickText('Unparent (move to top level)'); + await sleep(200); + o2 = JSON.parse(await objectJson('o2')); + check('Unparent moves the object to the top level, keeping its world position', o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, JSON.stringify(o2)); + check('no exceptions during run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | ')); ws.close(); } catch (error) { diff --git a/tools/seed-demo.mjs b/tools/seed-demo.mjs index bfaf3e50..c333de8c 100644 --- a/tools/seed-demo.mjs +++ b/tools/seed-demo.mjs @@ -45,10 +45,11 @@ const project = { name: 'Level 1', props: { backgroundColor: 0x22252c }, objects: [ - { id: 'o1', assetId: 'crate', name: '', position: { x: -150, y: 0, z: 0 }, overrides: {}, ownerId: '' }, - { id: 'o2', assetId: 'crate', name: 'Blue crate', position: { x: 120, y: -80, z: 0 }, overrides: { width: 140, color: 0x4488ff }, ownerId: '' }, - { id: 'o3', assetId: 'cam', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, ownerId: '' }, - { id: 'o4', assetId: 'mover', name: '', position: { x: -150, y: 110, z: 0 }, overrides: {}, ownerId: 'o1' }, + { id: 'o1', assetId: 'crate', name: '', position: { x: -150, y: 0, z: 0 }, overrides: {}, parentId: '' }, + { id: 'o2', assetId: 'crate', name: 'Blue crate', position: { x: 120, y: -80, z: 0 }, overrides: { width: 140, color: 0x4488ff }, parentId: '' }, + { id: 'o3', assetId: 'cam', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: '' }, + // The mover is parented to the red crate (its owner); its position is local to it. + { id: 'o4', assetId: 'mover', name: '', position: { x: 0, y: 110, z: 0 }, overrides: {}, parentId: 'o1' }, ], }, { id: 'crate', type: 'sprite', name: 'Crate', props: { width: 100, height: 60, color: 0xe0553f } },