Scene hierarchy: parentId with local positions, tree panel, reparenting and Parent node

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-07 15:04:56 +02:00
1 parent 551a50ffc9
commit 26d5edc496
22 files changed
+729 -166

No files matched your search

+23 -7
View File
@@ -11,8 +11,8 @@ Live: <https://nockal.com/game-maker/> · Widget playground: <https://nockal.com
| Term | Meaning | | Term | Meaning |
|---|---| |---|---|
| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`, `behavior`) and typed properties. Assets have **no position**. | | **Asset** | Named thing with a type (`scene`, `camera`, `sprite`, `behavior`) and typed properties. Assets have **no position**. |
| **Scene object** | An asset placed into a scene: a link to the asset + a **position** (`x`, `y`, `z`) + sparse **overrides** of the asset's properties + an optional **owner** (another object of the scene). Scenes cannot be placed. | | **Scene object** | An asset placed into a scene: a link to the asset + a **position** (`x`, `y`, `z`) + sparse **overrides** of the asset's properties + an optional **parent** (another object of the scene). Scenes cannot be placed. |
| **Behavior** | Logic as an asset: a **node graph** plus the **variables** it declares. Placed like anything else (a diamond symbol in the scene); its owner is the object the graph acts on. | | **Behavior** | Logic as an asset: a **node graph** plus the **variables** it declares. Placed like anything else (a diamond symbol in the scene); its parent is the **owner** the graph acts on. |
| **Override** | A per-object value that replaces the asset's value for one property. Only changed keys are stored; the inspector marks them bold and offers a reset (↺). | | **Override** | A per-object value that replaces the asset's value for one property. Only changed keys are stored; the inspector marks them bold and offers a reset (↺). |
| **Default** | Every property has a schema default. Asset values that differ from it are marked bold in the inspector and can be reset (↺) the same way an override can. | | **Default** | Every property has a schema default. Asset values that differ from it are marked bold in the inspector and can be reset (↺) the same way an override can. |
| **Edit mode** | The viewport draws the project model; panels, grid, gizmos and drag-to-move are active. | | **Edit mode** | The viewport draws the project model; panels, grid, gizmos and drag-to-move are active. |
@@ -37,11 +37,13 @@ shown on the node and editable in the inspector.
- **Variables** are the behavior's own property schema (`BehaviorAsset.variables`): they - **Variables** are the behavior's own property schema (`BehaviorAsset.variables`): they
appear in the inspector, can be overridden per placed instance, and each running instance appear in the inspector, can be overridden per placed instance, and each running instance
gets its own mutable copy. `Get variable` / `Set variable` read and write them. gets its own mutable copy. `Get variable` / `Set variable` read and write them.
- **Owner**: a placed behavior may point at another object of the scene (inspector → Owner, - **Owner** = parent: a placed behavior is usually parented to the object it controls
dashed line in the viewport). The `Owner` node returns it; `Self` returns the behavior itself. (inspector → Parent, or drag it onto that object in the hierarchy; dashed line in the
viewport). The `Owner` node returns it; `Self` returns the behavior itself; `Parent` walks up
from any object.
- **Node set** (`src/core/graph/nodes.ts`): events `On start`, `On update`, `On key down/up/held`, - **Node set** (`src/core/graph/nodes.ts`): events `On start`, `On update`, `On key down/up/held`,
`On timer` · flow `Branch`, `Sequence` · data `Self`, `Owner`, `Object by name`, `Get/Set `On timer` · flow `Branch`, `Sequence` · data `Self`, `Owner`, `Parent`, `Object by name`, `Get/Set
variable`, `Get/Set property`, `Get position`, literals, `Add/Subtract/Multiply/Divide`, variable`, `Get/Set property`, `Get position` (local and world), literals, `Add/Subtract/Multiply/Divide`,
`Min/Max/Abs/Sqrt/Clamp`, `Length`, `Distance`, `Normalize`, `Axis input` (four keys → a `Min/Max/Abs/Sqrt/Clamp`, `Length`, `Distance`, `Normalize`, `Axis input` (four keys → a
normalized direction, so diagonals are not faster), `Compare`, `And/Or/Not`, `Key is down` · normalized direction, so diagonals are not faster), `Compare`, `And/Or/Not`, `Key is down` ·
actions `Move` (dx, dy, scale, dt – connect an event's dt for per-second movement), `Set position`, `Destroy`, actions `Move` (dx, dy, scale, dt – connect an event's dt for per-second movement), `Set position`, `Destroy`,
@@ -67,9 +69,20 @@ shown on the node and editable in the inspector.
sprite can never hide a symbol regardless of z or hierarchy order. sprite can never hide a symbol regardless of z or hierarchy order.
**Z-order is part of the position.** `position.z` is the draw order of a scene object: higher **Z-order is part of the position.** `position.z` is the draw order of a scene object: higher
`z` is drawn on top; objects with equal `z` keep their hierarchy order. It is edited in the `z` is drawn on top; objects with equal `z` keep their hierarchy (tree) order. It is edited in the
inspector next to X/Y, undoable like any move, and applies in edit and play mode alike. inspector next to X/Y, undoable like any move, and applies in edit and play mode alike.
**Hierarchy (parenting).** Every scene object may have a parent in the same scene
(`SceneObject.parentId`). Positions – including `z` – are **local to the parent**; the world
position is the sum along the chain (`src/core/model/hierarchy.ts`). Children move with their
parent and are removed with it (also when the parent's asset is deleted); undo brings the whole
subtree back. Re-parenting keeps the world position by recomputing the local one, and cycles
are rejected. In the **Scene objects** panel children are indented below their parent; drag a
row onto another row to parent it, onto the empty space below the rows (or right-click →
Unparent) to move it to the top level; the inspector offers the same as a **Parent** menu. In
play mode entities carry the same `parent` link: moving a parent entity moves its subtree,
`Destroy` removes it. `Get position` exposes both the local and the world coordinates.
## Stack ## Stack
- **PixiJS v8** – rendering and pointer events. No game engine; runtime systems live in `src/runtime/`. - **PixiJS v8** – rendering and pointer events. No game engine; runtime systems live in `src/runtime/`.
@@ -95,6 +108,9 @@ api/ PHP backend data/projects/ persisted projects
tests/ Vitest unit tests tools/ e2e.mjs (CDP smoke test), seed-demo.mjs tests/ Vitest unit tests tools/ e2e.mjs (CDP smoke test), seed-demo.mjs
``` ```
The toolbar's right edge shows the frame rate (frames counted over a 0.5 s window, updated
twice a second) in edit and play mode alike; `__gm.fps()` exposes the same number to tests.
Rules that keep it organised: `core/` imports nothing from other layers; `ui/` never sees Rules that keep it organised: `core/` imports nothing from other layers; `ui/` never sees
assets; position exists only on scene objects; overrides are merged in exactly one place assets; position exists only on scene objects; overrides are merged in exactly one place
(`core/properties/resolve.ts`); the inspector is generated from `PropertySchema` – a new (`core/properties/resolve.ts`); the inspector is generated from `PropertySchema` – a new
+15 -31
View File
@@ -2,11 +2,11 @@ import { createEmptyGraph } from '../graph/Graph';
import type { Asset } from '../model/Asset'; import type { Asset } from '../model/Asset';
import type { AssetType } from '../model/AssetTypes'; import type { AssetType } from '../model/AssetTypes';
import type { PropertyValue } from '../model/Properties'; import type { PropertyValue } from '../model/Properties';
import { findAsset, findUsages, type Project } from '../model/Project'; import { findAsset, findScene, listScenes, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { coerceValue, defaultsFor, schemaForAsset } from '../properties/PropertySchema'; import { coerceValue, defaultsFor, schemaForAsset } from '../properties/PropertySchema';
import { newId } from '../ids'; import { newId } from '../ids';
import { type Command, CommandError } from './Command'; import { type Command, CommandError } from './Command';
import { type RemovedObject, removeSubtrees, restoreObjects } from './sceneObjectCommands';
export function buildAsset(type: AssetType, name: string, id = newId()): Asset { export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
const base = { id, name, props: defaultsFor(type) }; const base = { id, name, props: defaultsFor(type) };
@@ -45,12 +45,14 @@ export class CreateAssetCommand implements Command {
} }
} }
/** Deletes an asset and, as a cascade, every scene object referencing it. */ /**
* Deletes an asset and, as a cascade, every scene object referencing it – together with the
* objects parented below those (a subtree is removed as a whole).
*/
export class DeleteAssetCommand implements Command { export class DeleteAssetCommand implements Command {
readonly label: string; readonly label: string;
private removed?: { asset: Asset; index: number }; private removed?: { asset: Asset; index: number };
private removedUsages: { sceneId: string; object: SceneObject; index: number }[] = []; private removedObjects: { sceneId: string; removed: RemovedObject[] }[] = [];
private clearedOwners: { sceneId: string; objectId: string; ownerId: string }[] = [];
private previousActiveScene = ''; private previousActiveScene = '';
constructor(private readonly assetId: string) { constructor(private readonly assetId: string) {
@@ -61,23 +63,11 @@ export class DeleteAssetCommand implements Command {
const index = project.assets.findIndex((a) => a.id === this.assetId); const index = project.assets.findIndex((a) => a.id === this.assetId);
const asset = project.assets[index]; const asset = project.assets[index];
if (!asset) throw new CommandError(`Asset ${this.assetId} not found`); if (!asset) throw new CommandError(`Asset ${this.assetId} not found`);
this.removedUsages = findUsages(project, this.assetId).map(({ scene, object }) => ({ this.removedObjects = [];
sceneId: scene.id, for (const scene of listScenes(project)) {
object, const roots = scene.objects.filter((o) => o.assetId === this.assetId).map((o) => o.id);
index: scene.objects.indexOf(object), if (roots.length === 0) continue;
})); this.removedObjects.push({ sceneId: scene.id, removed: removeSubtrees(scene, roots) });
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.removed = { asset, index }; this.removed = { asset, index };
project.assets.splice(index, 1); project.assets.splice(index, 1);
@@ -91,15 +81,9 @@ export class DeleteAssetCommand implements Command {
undo(project: Project): void { undo(project: Project): void {
if (!this.removed) return; if (!this.removed) return;
project.assets.splice(this.removed.index, 0, this.removed.asset); project.assets.splice(this.removed.index, 0, this.removed.asset);
// Restore usages in ascending index order so the original positions come back. for (const entry of this.removedObjects) {
for (const usage of [...this.removedUsages].sort((a, b) => a.index - b.index)) { const scene = findScene(project, entry.sceneId);
const scene = findAsset(project, usage.sceneId); if (scene) restoreObjects(scene, entry.removed);
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;
} }
project.activeSceneId = this.previousActiveScene; project.activeSceneId = this.previousActiveScene;
} }
+70 -38
View File
@@ -1,6 +1,8 @@
import { isPlaceableType } from '../model/AssetTypes'; import { isPlaceableType } from '../model/AssetTypes';
import { descendantsOf, isSelfOrAncestor, localPositionFor, worldPosition } from '../model/hierarchy';
import type { Position, PropertyValue, Vec2 } from '../model/Properties'; import type { Position, PropertyValue, Vec2 } from '../model/Properties';
import { findScene, findSceneObject, type Project } from '../model/Project'; import { findScene, findSceneObject, type Project } from '../model/Project';
import type { SceneAsset } from '../model/Asset';
import { createSceneObject, type SceneObject } from '../model/SceneObject'; import { createSceneObject, type SceneObject } from '../model/SceneObject';
import { coerceValue, schemaForAsset } from '../properties/PropertySchema'; import { coerceValue, schemaForAsset } from '../properties/PropertySchema';
import { newId } from '../ids'; import { newId } from '../ids';
@@ -19,18 +21,57 @@ export function requireObject(project: Project, sceneId: string, objectId: strin
return object; 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<string>();
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 { export class PlaceAssetCommand implements Command {
readonly label = 'Place asset'; readonly label = 'Place asset';
readonly objectId = newId(); 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 { execute(project: Project): void {
const scene = requireScene(project, this.sceneId); const scene = requireScene(project, this.sceneId);
const asset = requireAsset(project, this.assetId); const asset = requireAsset(project, this.assetId);
if (!isPlaceableType(asset.type)) throw new CommandError('A scene cannot be placed into a scene'); if (!isPlaceableType(asset.type)) throw new CommandError('A scene cannot be placed into a scene');
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 { 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 { export class RemoveSceneObjectCommand implements Command {
readonly label = 'Remove object'; readonly label = 'Remove object';
private removed?: { object: SceneObject; index: number }; private removed: RemovedObject[] = [];
private clearedOwners: string[] = [];
constructor(private readonly sceneId: string, private readonly objectId: string) {} constructor(private readonly sceneId: string, private readonly objectId: string) {}
execute(project: Project): void { execute(project: Project): void {
const scene = requireScene(project, this.sceneId); requireObject(project, this.sceneId, this.objectId);
const index = scene.objects.findIndex((o) => o.id === this.objectId); this.removed = removeSubtrees(requireScene(project, this.sceneId), [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);
}
}
} }
undo(project: Project): void { undo(project: Project): void {
if (!this.removed) return; restoreObjects(requireScene(project, this.sceneId), this.removed);
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;
}
} }
} }
/** 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 { export class MoveSceneObjectCommand implements Command {
readonly label = 'Move object'; readonly label = 'Move object';
private previous: Position = { x: 0, y: 0, z: 0 }; private previous: Position = { x: 0, y: 0, z: 0 };
@@ -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 { * Re-parents an object ('' = top level). The object keeps its world position: its local
readonly label = 'Set owner'; * position is recomputed for the new parent. Cycles (an object below itself) are rejected.
private previous = ''; */
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 { execute(project: Project): void {
const scene = requireScene(project, this.sceneId);
const object = requireObject(project, this.sceneId, this.objectId); const object = requireObject(project, this.sceneId, this.objectId);
if (this.ownerId) { if (this.parentId) {
if (this.ownerId === this.objectId) throw new CommandError('An object cannot own itself'); if (this.parentId === this.objectId) throw new CommandError('An object cannot be its own parent');
requireObject(project, this.sceneId, this.ownerId); 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; this.previous = { parentId: object.parentId, position: { ...object.position } };
object.ownerId = this.ownerId; const world = worldPosition(scene, object);
object.parentId = this.parentId;
object.position = localPositionFor(scene, this.parentId, world);
} }
undo(project: Project): void { 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 };
} }
} }
+17 -1
View File
@@ -12,10 +12,26 @@ export type Value = PropertyValue | EntityHandle | null;
export interface EntityHandle { export interface EntityHandle {
id: string; id: string;
name: string; name: string;
/** Position local to `parent` (world position for top-level entities). */
x: number; x: number;
y: number; y: number;
z: number; z: number;
props: PropertyBag; 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<EntityHandle>([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 { export interface NodeContext {
/** The behavior entity itself. */ /** The behavior entity itself. */
self: EntityHandle; 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; owner: EntityHandle | null;
/** Mutable variable state of this behavior instance. */ /** Mutable variable state of this behavior instance. */
variables: PropertyBag; variables: PropertyBag;
+16 -5
View File
@@ -1,5 +1,5 @@
import type { PropertyDef } from '../properties/PropertySchema'; 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, * The built-in node set. Each entry is data + a `run` function; everything else (palette,
@@ -59,7 +59,17 @@ registerNode({
// --- data: references -------------------------------------------------------------------- // --- 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: '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({ registerNode({
type: 'ObjectByName', type: 'ObjectByName',
label: 'Object by name', label: 'Object by name',
@@ -141,13 +151,14 @@ registerNode({
type: 'GetPosition', type: 'GetPosition',
label: 'Get position', label: 'Get position',
category: 'data', 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')], inputs: [obj('object')],
outputs: [num('x'), num('y')], outputs: [num('x'), num('y'), num('worldX', 'world x'), num('worldY', 'world y')],
params: [], params: [],
run: (_ctx, inputs) => { run: (_ctx, inputs) => {
const target = asEntity(inputs.object); 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 } };
}, },
}); });
+10 -6
View File
@@ -10,24 +10,28 @@ export interface SceneObject {
assetId: string; assetId: string;
/** Optional display name; falls back to the asset name when empty. */ /** Optional display name; falls back to the asset name when empty. */
name: string; 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; position: Position;
overrides: PropertyBag; overrides: PropertyBag;
/** /**
* Another object of the same scene this one acts on (used by behaviors: "the owner"). * Parent object of the same scene, empty string = top level. Children move with their
* Empty string = none. Any object may carry one, only behaviors interpret it. * 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. */ /** 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 { return {
id, id,
assetId, assetId,
name: '', name: '',
position: { x: position.x, y: position.y, z: 'z' in position ? position.z : 0 }, position: { x: position.x, y: position.y, z: 'z' in position ? position.z : 0 },
overrides: {}, overrides: {},
ownerId: '', parentId,
}; };
} }
+93
View File
@@ -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<string>();
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<string>([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;
}
+17 -4
View File
@@ -83,12 +83,25 @@ function normalizeObjects(raw: unknown, schemas: Map<string, PropertySchema>): S
name: typeof o.name === 'string' ? o.name : '', name: typeof o.name === 'string' ? o.name : '',
position: { x: toNumber(p.x), y: toNumber(p.y), z: toNumber(p.z) }, position: { x: toNumber(p.x), y: toNumber(p.y), z: toNumber(p.z) },
overrides: normalizeBag(o.overrides, schema, true), 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. // Parents must exist in the same scene, differ from the object itself and not form cycles.
const ids = new Set(objects.map((o) => o.id)); const byId = new Map(objects.map((o) => [o.id, o]));
for (const o of objects) if (o.ownerId && (o.ownerId === o.id || !ids.has(o.ownerId))) o.ownerId = ''; 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<string>([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; return objects;
} }
+37 -8
View File
@@ -41,10 +41,11 @@ function installDebugHook(app: Application, state: EditorState, editor: Editor):
const p = viewport.getGlobalPosition(); const p = viewport.getGlobalPosition();
return { x: p.x + viewport.w / 2, y: p.y + viewport.h / 2 }; 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; let found: Text | null = null;
const walk = (node: Container): void => { const walk = (node: Container): void => {
if (found || !node.visible) return; if ((found && !last) || !node.visible) return;
if (node instanceof Text && node.text === text) { if (node instanceof Text && node.text === text) {
found = node; found = node;
return; return;
@@ -74,6 +75,8 @@ function installDebugHook(app: Application, state: EditorState, editor: Editor):
}, },
}, },
world: () => editor.worldSnapshot(), world: () => editor.worldSnapshot(),
hierarchyOrder: () => editor.hierarchy.rowOrder(),
fps: () => editor.fps,
tab: () => editor.tab, tab: () => editor.tab,
showTab: (tab: CentreTab) => editor.showTab(tab), showTab: (tab: CentreTab) => editor.showTab(tab),
}; };
@@ -82,6 +85,7 @@ function installDebugHook(app: Application, state: EditorState, editor: Editor):
class Editor { class Editor {
readonly viewport: Viewport; readonly viewport: Viewport;
readonly graphEditor: GraphEditor; readonly graphEditor: GraphEditor;
readonly hierarchy: HierarchyPanel;
tab: CentreTab = 'scene'; tab: CentreTab = 'scene';
private readonly leftColumn: VStack; private readonly leftColumn: VStack;
private readonly inspector: Inspector; private readonly inspector: Inspector;
@@ -93,16 +97,21 @@ class Editor {
private world: World | null = null; private world: World | null = null;
private behaviors: BehaviorSystem | null = null; private behaviors: BehaviorSystem | null = null;
private readonly input = new Input(); 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 leftWidth = 240;
private rightWidth = 300; private rightWidth = 300;
constructor(app: Application, root: VStack, private readonly state: EditorState, private readonly store: ProjectStore) { 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), openProjectMenu: (anchor) => this.openProjectMenu(anchor),
openSceneMenu: (anchor) => this.openSceneMenu(anchor), openSceneMenu: (anchor) => this.openSceneMenu(anchor),
togglePlay: () => (state.mode === 'edit' ? this.play() : this.stop()), togglePlay: () => (state.mode === 'edit' ? this.play() : this.stop()),
}); });
root.add(toolbar); root.add(this.toolbar);
this.workspace = new HStack(); this.workspace = new HStack();
this.viewport = new Viewport(state); this.viewport = new Viewport(state);
@@ -111,7 +120,8 @@ class Editor {
this.graphEditor.timeProvider = () => this.world?.time ?? 0; this.graphEditor.timeProvider = () => this.world?.time ?? 0;
this.leftColumn = new VStack({ gap: 1 }); this.leftColumn = new VStack({ gap: 1 });
this.leftColumn.add(new AssetPanel(state, () => this.viewport.worldCenter()), 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); this.inspector = new Inspector(state);
// Centre: a tab strip (Scene | Graph) above the viewport or the graph editor. // Centre: a tab strip (Scene | Graph) above the viewport or the graph editor.
@@ -275,6 +285,7 @@ class Editor {
} }
private tick(dt: number): void { private tick(dt: number): void {
this.measureFps(dt);
if (!this.world) return; if (!this.world) return;
this.world.tick(Math.min(dt, 0.1)); this.world.tick(Math.min(dt, 0.1));
this.input.endFrame(); this.input.endFrame();
@@ -286,10 +297,28 @@ class Editor {
if (this.graphEditor.visible) this.graphEditor.redraw(); 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. */ /** Read-only view of the running world for tests. */
worldSnapshot(): { entities: { objectId: string; x: number; y: number; props: Record<string, unknown> }[]; log: string[] } | null { worldSnapshot(): { entities: { objectId: string; x: number; y: number; worldX: number; worldY: number; parent: string; props: Record<string, unknown> }[]; log: string[] } | null {
if (!this.world) return null; const world = this.world;
return { entities: this.world.entities.map((e) => ({ objectId: e.objectId, x: e.x, y: e.y, props: { ...e.props } })), log: [...this.world.log] }; 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 { private applyMode(): void {
+24 -6
View File
@@ -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 { findAsset } from '../../core/model/Project';
import { resolveSceneObjectProps } from '../../core/properties/resolve'; import { resolveSceneObjectProps } from '../../core/properties/resolve';
import { ListView, Menu, Panel, ScrollContainer } from '../../ui'; import { ListView, Menu, Panel, ScrollContainer } from '../../ui';
import type { EditorState } from '../state'; 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 { export class HierarchyPanel extends Panel {
private readonly list = new ListView(); private readonly list = new ListView();
private readonly scroll = new ScrollContainer(); private readonly scroll = new ScrollContainer();
@@ -17,12 +22,20 @@ export class HierarchyPanel extends Panel {
const scene = state.activeScene; const scene = state.activeScene;
state.select(id && scene ? { kind: 'object', sceneId: scene.id, objectId: id } : null); 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) => { this.list.onContextMenu = (id, x, y) => {
const scene = state.activeScene; const scene = state.activeScene;
if (!scene) return; if (!scene) return;
const object = scene.objects.find((o) => o.id === id); const object = scene.objects.find((o) => o.id === id);
new Menu([ new Menu([
{ label: 'Select asset', onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }) }, { 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)) }, { label: 'Remove from scene', danger: true, onSelect: () => state.execute(new RemoveSceneObjectCommand(scene.id, id)) },
]).open(x, y); ]).open(x, y);
}; };
@@ -36,19 +49,24 @@ export class HierarchyPanel extends Panel {
this.refresh(); 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 { private refresh(): void {
const scene = this.state.activeScene; const scene = this.state.activeScene;
this.setTitle(scene ? `Scene: ${scene.name}` : 'Scene objects'); this.setTitle(scene ? `Scene: ${scene.name}` : 'Scene objects');
this.list.setItems( this.list.setItems(
(scene?.objects ?? []).map((object) => { (scene ? treeOrder(scene) : []).map(({ object, depth }) => {
const asset = findAsset(this.state.project, object.assetId); const asset = findAsset(this.state.project, object.assetId);
const props = asset ? resolveSceneObjectProps(asset, object) : {}; const props = asset ? resolveSceneObjectProps(asset, object) : {};
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 { return {
id: object.id, id: object.id,
label: object.name || asset?.name || '?', label: object.name || asset?.name || '?',
hint: owner ? `→ ${ownerName}` : asset?.type ?? '', hint: asset?.type ?? '',
depth,
swatch: asset?.type === 'sprite' ? Number(props.color ?? 0) : undefined, swatch: asset?.type === 'sprite' ? Number(props.color ?? 0) : undefined,
}; };
}), }),
+21 -15
View File
@@ -5,11 +5,12 @@ import {
MoveSceneObjectCommand, MoveSceneObjectCommand,
RenameSceneObjectCommand, RenameSceneObjectCommand,
SetOverrideCommand, SetOverrideCommand,
SetOwnerCommand, SetParentCommand,
} from '../../core/commands/sceneObjectCommands'; } from '../../core/commands/sceneObjectCommands';
import { AddVariableCommand, nextVariableName, RemoveVariableCommand, RenameVariableCommand } from '../../core/commands/variableCommands'; import { AddVariableCommand, nextVariableName, RemoveVariableCommand, RenameVariableCommand } from '../../core/commands/variableCommands';
import { edgesInto, inlineInputDef, inlineInputs, nodeDef } from '../../core/graph'; 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 type { Position, PropertyValue } from '../../core/model/Properties';
import { findAsset, findScene, findSceneObject } from '../../core/model/Project'; import { findAsset, findScene, findSceneObject } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject'; import type { SceneObject } from '../../core/model/SceneObject';
@@ -80,7 +81,7 @@ export class Inspector extends Panel {
if (s.kind === 'object') { if (s.kind === 'object') {
const object = this.currentObject(s.sceneId, s.objectId); const object = this.currentObject(s.sceneId, s.objectId);
const asset = object && findAsset(this.state.project, object.assetId); 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); const asset = findAsset(this.state.project, s.assetId);
if (!asset || asset.type !== 'behavior') return 'missing'; 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); assetRow.add(new Button(asset.name, { variant: 'ghost', onClick: () => this.state.select({ kind: 'asset', assetId: asset.id }) }), 1);
this.body.add(assetRow); 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). // Position – belongs to the scene object only, local to the parent. Z is the draw order (higher = on top).
this.body.add(new Label('POSITION', { style: 'title' })); 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) { for (const axis of ['x', 'y', 'z'] as const) {
let original: Position | undefined; let original: Position | undefined;
const row = createFieldRow({ key: axis, label: axis.toUpperCase(), kind: 'number', default: 0, step: 1 }, object.position[axis], { 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: <name> ▾" – picks another object of the same scene, or none. */ /**
private ownerRow(objects: SceneObject[], object: SceneObject, sceneId: string): HStack { * "Parent: <name> ▾" – 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' }); const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight; row.minHeight = Theme.rowHeight;
const label = new Label('Owner', { style: 'muted' }); const label = new Label('Parent', { style: 'muted' });
label.minWidth = FIELD_LABEL_WIDTH; label.minWidth = FIELD_LABEL_WIDTH;
row.add(label); row.add(label);
const nameOf = (o: SceneObject) => o.name || findAsset(this.state.project, o.assetId)?.name || '?'; const nameOf = (o: SceneObject) => o.name || findAsset(this.state.project, o.assetId)?.name || '?';
const owner = objects.find((o) => o.id === object.ownerId); const parent = scene.objects.find((o) => o.id === object.parentId);
const button = new Button(`${owner ? nameOf(owner) : 'None'} ▾`); const button = new Button(`${parent ? nameOf(parent) : 'None'} ▾`);
button.onClick = () => { button.onClick = () => {
const g = button.getGlobalPosition(); const g = button.getGlobalPosition();
const items: MenuItem[] = [{ label: 'None', onSelect: () => this.state.execute(new SetOwnerCommand(sceneId, object.id, '')) }]; const items: MenuItem[] = [{ label: 'None', onSelect: () => this.state.execute(new SetParentCommand(sceneId, object.id, '')) }];
for (const candidate of objects) { for (const { object: candidate, depth } of treeOrder(scene)) {
if (candidate.id === object.id) continue; if (isSelfOrAncestor(scene, object.id, candidate.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 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); new Menu(items).open(g.x, g.y + button.h + 2);
}; };
+9
View File
@@ -28,6 +28,7 @@ export class Toolbar extends HStack {
private readonly playButton: Button; private readonly playButton: Button;
private readonly status: Label; private readonly status: Label;
private readonly notice: Label; private readonly notice: Label;
private readonly fps: Label;
constructor(private readonly state: EditorState, actions: ToolbarActions) { constructor(private readonly state: EditorState, actions: ToolbarActions) {
super({ gap: Theme.gap, padding: Theme.padding, align: 'center' }); 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 = new Label('', { style: 'small', align: 'right' });
this.status.minWidth = 110; this.status.minWidth = 110;
this.add(this.status); 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('project', () => this.refresh());
state.on('load', () => this.refresh()); state.on('load', () => this.refresh());
@@ -77,6 +81,11 @@ export class Toolbar extends HStack {
this.refresh(); 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 { private refresh(): void {
const { state } = this; const { state } = this;
this.projectName.setValue(state.project.name); this.projectName.setValue(state.project.name);
+4 -3
View File
@@ -13,12 +13,13 @@ export interface Entity {
assetId: string; assetId: string;
type: AssetType; type: AssetType;
name: string; name: string;
/** Position local to `parent` (world position for top-level entities). */
x: number; x: number;
y: 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; z: number;
/** For behaviors: the resolved variables (mutable state). For others: resolved properties. */ /** For behaviors: the resolved variables (mutable state). For others: resolved properties. */
props: PropertyBag; props: PropertyBag;
/** Scene object id of the owner (behaviors), empty when none. */ /** Parent entity (mirrors `SceneObject.parentId`); children move and die with it. */
ownerObjectId: string; parent: Entity | null;
} }
+20 -3
View File
@@ -1,3 +1,4 @@
import { worldPositionOf } from '../core/graph';
import { findAsset, findScene, type Project } from '../core/model/Project'; import { findAsset, findScene, type Project } from '../core/model/Project';
import { resolveSceneObjectProps } from '../core/properties/resolve'; import { resolveSceneObjectProps } from '../core/properties/resolve';
import type { Entity } from './Entity'; import type { Entity } from './Entity';
@@ -27,10 +28,11 @@ export class World {
if (!scene) throw new Error(`Scene ${sceneId} not found`); if (!scene) throw new Error(`Scene ${sceneId} not found`);
const background = scene.props.backgroundColor; const background = scene.props.backgroundColor;
const world = new World(sceneId, typeof background === 'number' ? background : 0x000000); const world = new World(sceneId, typeof background === 'number' ? background : 0x000000);
const parentIds = new Map<Entity, string>();
for (const object of scene.objects) { for (const object of scene.objects) {
const asset = findAsset(project, object.assetId); const asset = findAsset(project, object.assetId);
if (!asset || asset.type === 'scene') continue; if (!asset || asset.type === 'scene') continue;
world.entities.push({ const entity: Entity = {
id: `e-${object.id}`, id: `e-${object.id}`,
objectId: object.id, objectId: object.id,
assetId: asset.id, assetId: asset.id,
@@ -40,12 +42,25 @@ export class World {
y: object.position.y, y: object.position.y,
z: object.position.z, z: object.position.z,
props: resolveSceneObjectProps(asset, object), 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; 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 { addSystem(system: System): this {
this.systems.push(system); this.systems.push(system);
system.start?.(this); system.start?.(this);
@@ -74,7 +89,9 @@ export class World {
return this.entities.find((e) => e.type === type); return this.entities.find((e) => e.type === type);
} }
/** Removes an entity and, recursively, everything parented to it. */
remove(entity: Entity): void { remove(entity: Entity): void {
for (const child of this.childrenOf(entity)) this.remove(child);
const index = this.entities.indexOf(entity); const index = this.entities.indexOf(entity);
if (index >= 0) this.entities.splice(index, 1); if (index >= 0) this.entities.splice(index, 1);
} }
+1 -1
View File
@@ -99,7 +99,7 @@ export class BehaviorSystem implements System {
private context(entity: Entity, world: World, dt: number): NodeContext { private context(entity: Entity, world: World, dt: number): NodeContext {
return { return {
self: entity, self: entity,
owner: entity.ownerObjectId ? world.findByObjectId(entity.ownerObjectId) ?? null : null, owner: entity.parent,
variables: entity.props, variables: entity.props,
time: world.time, time: world.time,
dt, dt,
+69 -2
View File
@@ -14,9 +14,13 @@ export interface ListItem {
swatch?: number; 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 * 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. * 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 { export class ListView extends Widget {
private items: ListItem[] = []; private items: ListItem[] = [];
@@ -25,23 +29,37 @@ export class ListView extends Widget {
onSelect?: (id: string | null) => void; onSelect?: (id: string | null) => void;
onActivate?: (id: string) => void; onActivate?: (id: string) => void;
onContextMenu?: (id: string, globalX: number, globalY: number) => 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; rowHeight: number = Theme.rowHeight;
emptyText = 'Nothing here yet'; emptyText = 'Nothing here yet';
private readonly emptyLabel = new Text({ text: '', style: textStyle('muted') }); 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() { constructor() {
super(); super();
this.focusable = true; this.focusable = true;
this.addChild(this.emptyLabel); this.dropMarker.eventMode = 'none';
this.addChild(this.emptyLabel, this.dropMarker);
this.on('pointerdown', (e: FederatedPointerEvent) => { this.on('pointerdown', (e: FederatedPointerEvent) => {
if (e.target === this) this.select(null); 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 { get selected(): string | null {
return this.selectedId; 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 { setItems(items: ListItem[]): void {
this.items = items; this.items = items;
for (const row of this.rows) row.destroy({ children: true }); for (const row of this.rows) row.destroy({ children: true });
@@ -54,12 +72,14 @@ export class ListView extends Widget {
} else { } else {
this.select(item.id); this.select(item.id);
if (e.detail === 2) this.onActivate?.(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); row.position.set(0, index * this.rowHeight);
this.addChild(row); this.addChild(row);
return 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; if (this.selectedId && !items.some((i) => i.id === this.selectedId)) this.selectedId = null;
this.layout(); this.layout();
this.redraw(); 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); 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.text = this.items.length === 0 ? this.emptyText : '';
this.emptyLabel.position.set(Theme.padding, Math.round((this.rowHeight - this.emptyLabel.height) / 2)); 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 { override redraw(): void {
const c = Theme.colors; 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(); this.bg.clear();
if (this.selected) this.bg.rect(0, 0, this.w, this.h).fill(this.listFocused ? c.selection : c.controlActive); 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); 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; let x = indent;
if (this.item.swatch !== undefined) { if (this.item.swatch !== undefined) {
this.bg.roundRect(x, (this.h - 12) / 2, 12, 12, 2).fill(this.item.swatch); this.bg.roundRect(x, (this.h - 12) / 2, 12, 12, 2).fill(this.item.swatch);
+24 -16
View File
@@ -2,6 +2,7 @@ import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphi
import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands'; import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
import { type AssetType, isSymbolType } from '../core/model/AssetTypes'; import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
import { type Position, type PropertyBag, sortByZ, type Vec2 } from '../core/model/Properties'; 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 { findAsset } from '../core/model/Project';
import { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera'; import { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera';
import { resolveSceneObjectProps } from '../core/properties/resolve'; import { resolveSceneObjectProps } from '../core/properties/resolve';
@@ -16,11 +17,11 @@ interface DrawItem {
name: string; name: string;
x: number; x: number;
y: number; y: number;
/** Draw order (see `Position.z`). */ /** Draw order (see `Position.z`). `x`/`y`/`z` are world values (parents already applied). */
z: number; z: number;
props: PropertyBag; props: PropertyBag;
/** Id of the owner item (behaviors), empty when none. */ /** Id of the parent item, empty when top-level. */
ownerId: string; parentId: string;
} }
/** World-unit size of the behavior symbol (a diamond). */ /** World-unit size of the behavior symbol (a diamond). */
@@ -106,6 +107,7 @@ export class Viewport extends Widget {
this.dragging.moved = true; this.dragging.moved = true;
const object = this.state.activeScene?.objects.find((o) => o.id === this.dragging?.objectId); const object = this.state.activeScene?.objects.find((o) => o.id === this.dragging?.objectId);
if (!object) return; 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 }; 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(); this.state.preview();
} }
@@ -214,18 +216,20 @@ export class Viewport extends Widget {
private collectItems(): { items: DrawItem[]; background: number } { private collectItems(): { items: DrawItem[]; background: number } {
if (this.playWorld) { if (this.playWorld) {
// Symbols (camera, behaviors) are editor-only; the running game shows scene content. // Symbols (camera, behaviors) are editor-only; the running game shows scene content.
const world = this.playWorld;
return { return {
background: this.playWorld.backgroundColor, background: world.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: '' })), 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; const scene = this.state.activeScene;
if (!scene) return { items: [], background: Theme.colors.viewport }; if (!scene) return { items: [], background: Theme.colors.viewport };
const items: DrawItem[] = []; 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); const asset = findAsset(this.state.project, object.assetId);
if (!asset) continue; 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) }; 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); 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) { for (const [id, node] of this.nodes) {
if (!seen.has(id)) { if (!seen.has(id)) {
node.destroy(); node.destroy();
@@ -329,18 +333,22 @@ export class Viewport extends Widget {
return isSymbolType(item.type); 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(); this.links.clear();
if (!editing) return; if (!editing) return;
const byId = new Map(items.map((i) => [i.id, i])); const byId = new Map(items.map((i) => [i.id, i]));
for (const item of items) { for (const item of items) {
if (item.type !== 'behavior' || !item.ownerId) continue; if (!this.isSymbol(item) || !item.parentId) continue;
const owner = byId.get(item.ownerId); const parent = byId.get(item.parentId);
if (!owner) continue; if (!parent) continue;
dashedLine(this.links, item.x, item.y, owner.x, owner.y, 8 / scale, 6 / scale); const color = item.type === 'behavior' ? Theme.colors.behavior : Theme.colors.textMuted;
this.links.stroke({ width: 1.5 / scale, color: Theme.colors.behavior, alpha: 0.8 }); dashedLine(this.links, item.x, item.y, parent.x, parent.y, 8 / scale, 6 / scale);
this.links.circle(owner.x, owner.y, 3 / scale).fill(Theme.colors.behavior); this.links.stroke({ width: 1.5 / scale, color, alpha: 0.8 });
this.links.circle(parent.x, parent.y, 3 / scale).fill(color);
} }
} }
+12 -13
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { buildAsset } from '../../src/core/commands/assetCommands'; import { buildAsset } from '../../src/core/commands/assetCommands';
import { CommandStack } from '../../src/core/commands/CommandStack'; import { CommandStack } from '../../src/core/commands/CommandStack';
import { AddNodeCommand, ConnectCommand, DisconnectCommand, RemoveNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands'; 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 { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
import { type EntityHandle, GraphInterpreter, type NodeContext, allNodeDefs, portsCompatible } from '../../src/core/graph'; import { type EntityHandle, GraphInterpreter, type NodeContext, allNodeDefs, portsCompatible } from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset'; 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'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
function entity(name: string, x = 0): EntityHandle { 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> = {}): NodeContext & { logs: string[]; stopped: boolean } { function context(overrides: Partial<NodeContext> = {}): NodeContext & { logs: string[]; stopped: boolean } {
@@ -337,7 +337,7 @@ describe('variables', () => {
expect(object.overrides).toEqual({ speed: 5 }); 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 project = createEmptyProject('p', 'P');
const scene = buildAsset('scene', 'S', 's'); const scene = buildAsset('scene', 'S', 's');
const sprite = buildAsset('sprite', 'Box', 'box'); const sprite = buildAsset('sprite', 'Box', 'box');
@@ -348,16 +348,15 @@ describe('variables', () => {
const placeB = new PlaceAssetCommand('s', 'b', { x: 0, y: 0 }); const placeB = new PlaceAssetCommand('s', 'b', { x: 0, y: 0 });
stack.execute(placeBox); stack.execute(placeBox);
stack.execute(placeB); stack.execute(placeB);
expect(() => stack.execute(new SetOwnerCommand('s', placeB.objectId, placeB.objectId))).toThrow(/itself/); expect(() => stack.execute(new SetParentCommand('s', placeB.objectId, placeB.objectId))).toThrow(/own parent/);
stack.execute(new SetOwnerCommand('s', placeB.objectId, placeBox.objectId)); stack.execute(new SetParentCommand('s', placeB.objectId, placeBox.objectId));
const objects = () => (project.assets[0] as { objects: { id: string; ownerId: string }[] }).objects; const objectsOf = (p: unknown) => ((p as { assets: { objects: { id: string; parentId: string }[] }[] }).assets[0]!).objects;
expect(objects()[1]!.ownerId).toBe(placeBox.objectId); expect(objectsOf(project)[1]!.parentId).toBe(placeBox.objectId);
// Round trip keeps the owner; a dangling owner is dropped by the loader. // Round trip keeps the parent; a dangling parent is dropped by the loader.
const loaded = deserializeProject(serializeProject(project)); expect(objectsOf(deserializeProject(serializeProject(project)))[1]!.parentId).toBe(placeBox.objectId);
expect((loaded.assets[0] as { objects: { ownerId: string }[] }).objects[1]!.ownerId).toBe(placeBox.objectId); const raw = serializeProject(project) as { assets: { objects?: { parentId: string }[] }[] };
const raw = serializeProject(project) as { assets: { objects?: { ownerId: string }[] }[] }; raw.assets[0]!.objects![1]!.parentId = 'ghost';
raw.assets[0]!.objects![1]!.ownerId = 'ghost'; expect(objectsOf(deserializeProject(raw))[1]!.parentId).toBe('');
expect((deserializeProject(raw).assets[0] as { objects: { ownerId: string }[] }).objects[1]!.ownerId).toBe('');
}); });
}); });
+163
View File
@@ -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<string, unknown>[] }[] };
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']);
});
});
+1 -1
View File
@@ -52,7 +52,7 @@ describe('serialize', () => {
name: '', name: '',
position: { x: 5, y: 0, z: 0 }, position: { x: 5, y: 0, z: 0 },
overrides: { color: 0x00ff00, width: 1 }, overrides: { color: 0x00ff00, width: 1 },
ownerId: '', parentId: '',
}); });
expect(project.assets[1]!.name).toBe('sprite'); expect(project.assets[1]!.name).toBe('sprite');
expect(project.assets[1]!.props).toEqual({ width: 10, height: 100, color: 0xe0553f }); expect(project.assets[1]!.props).toEqual({ width: 10, height: 100, color: 0xe0553f });
+78 -2
View File
@@ -85,8 +85,8 @@ try {
}); });
const evaluate = async (expression) => (await cdp.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })).result.value; const evaluate = async (expression) => (await cdp.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })).result.value;
const locate = async (text) => { const locate = async (text, last = false) => {
const p = await evaluate(`window.__gm && window.__gm.locate(${JSON.stringify(text)})`); const p = await evaluate(`window.__gm && window.__gm.locate(${JSON.stringify(text)}, ${last})`);
if (!p) throw new Error(`widget with text "${text}" not found`); if (!p) throw new Error(`widget with text "${text}" not found`);
return p; return p;
}; };
@@ -426,9 +426,17 @@ try {
// 13e. Play always starts on the Scene tab; switching back to Graph while playing shows // 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. // 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 clickText('▶ Play');
await sleep(200); await sleep(200);
check('play switches to the scene tab', (await evaluate('__gm.tab()')) === 'scene'); 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 evaluate(`__gm.showTab('graph')`);
await sleep(100); await sleep(100);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }); 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'); check('graph editor rebinds when a behavior is selected again', (await evaluate(`__gm.graph.current()`)) === 'mover');
await evaluate(`__gm.showTab('scene')`); 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(' | ')); check('no exceptions during run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | '));
ws.close(); ws.close();
} catch (error) { } catch (error) {
+5 -4
View File
@@ -45,10 +45,11 @@ const project = {
name: 'Level 1', name: 'Level 1',
props: { backgroundColor: 0x22252c }, props: { backgroundColor: 0x22252c },
objects: [ objects: [
{ id: 'o1', assetId: 'crate', name: '', position: { x: -150, y: 0, z: 0 }, overrides: {}, ownerId: '' }, { 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 }, ownerId: '' }, { 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: {}, ownerId: '' }, { id: 'o3', assetId: 'cam', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: '' },
{ id: 'o4', assetId: 'mover', name: '', position: { x: -150, y: 110, z: 0 }, overrides: {}, ownerId: 'o1' }, // 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 } }, { id: 'crate', type: 'sprite', name: 'Crate', props: { width: 100, height: 60, color: 0xe0553f } },