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:
1 parent
551a50ffc9
commit
26d5edc496
22 files changed
+729
-166
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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('');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -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 } },
|
||||||
|
|||||||
Reference in new issue
Block a user