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

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

No files matched your search

+37 -8
View File
@@ -41,10 +41,11 @@ function installDebugHook(app: Application, state: EditorState, editor: Editor):
const p = viewport.getGlobalPosition();
return { x: p.x + viewport.w / 2, y: p.y + viewport.h / 2 };
};
const locate = (text: string): { x: number; y: number } | null => {
/** Centre of the first (or, with `last`, the topmost/last-drawn) visible text equal to `text`. */
const locate = (text: string, last = false): { x: number; y: number } | null => {
let found: Text | null = null;
const walk = (node: Container): void => {
if (found || !node.visible) return;
if ((found && !last) || !node.visible) return;
if (node instanceof Text && node.text === text) {
found = node;
return;
@@ -74,6 +75,8 @@ function installDebugHook(app: Application, state: EditorState, editor: Editor):
},
},
world: () => editor.worldSnapshot(),
hierarchyOrder: () => editor.hierarchy.rowOrder(),
fps: () => editor.fps,
tab: () => editor.tab,
showTab: (tab: CentreTab) => editor.showTab(tab),
};
@@ -82,6 +85,7 @@ function installDebugHook(app: Application, state: EditorState, editor: Editor):
class Editor {
readonly viewport: Viewport;
readonly graphEditor: GraphEditor;
readonly hierarchy: HierarchyPanel;
tab: CentreTab = 'scene';
private readonly leftColumn: VStack;
private readonly inspector: Inspector;
@@ -93,16 +97,21 @@ class Editor {
private world: World | null = null;
private behaviors: BehaviorSystem | null = null;
private readonly input = new Input();
private readonly toolbar: Toolbar;
/** Frame-rate meter: frames counted over a sliding ~0.5 s window, shown in the toolbar. */
private fpsFrames = 0;
private fpsElapsed = 0;
fps: number | null = null;
private leftWidth = 240;
private rightWidth = 300;
constructor(app: Application, root: VStack, private readonly state: EditorState, private readonly store: ProjectStore) {
const toolbar = new Toolbar(state, {
this.toolbar = new Toolbar(state, {
openProjectMenu: (anchor) => this.openProjectMenu(anchor),
openSceneMenu: (anchor) => this.openSceneMenu(anchor),
togglePlay: () => (state.mode === 'edit' ? this.play() : this.stop()),
});
root.add(toolbar);
root.add(this.toolbar);
this.workspace = new HStack();
this.viewport = new Viewport(state);
@@ -111,7 +120,8 @@ class Editor {
this.graphEditor.timeProvider = () => this.world?.time ?? 0;
this.leftColumn = new VStack({ gap: 1 });
this.leftColumn.add(new AssetPanel(state, () => this.viewport.worldCenter()), 1);
this.leftColumn.add(new HierarchyPanel(state), 1);
this.hierarchy = new HierarchyPanel(state);
this.leftColumn.add(this.hierarchy, 1);
this.inspector = new Inspector(state);
// Centre: a tab strip (Scene | Graph) above the viewport or the graph editor.
@@ -275,6 +285,7 @@ class Editor {
}
private tick(dt: number): void {
this.measureFps(dt);
if (!this.world) return;
this.world.tick(Math.min(dt, 0.1));
this.input.endFrame();
@@ -286,10 +297,28 @@ class Editor {
if (this.graphEditor.visible) this.graphEditor.redraw();
}
/** Counts rendered frames (edit and play alike) and refreshes the toolbar readout twice a second. */
private measureFps(dt: number): void {
this.fpsFrames += 1;
this.fpsElapsed += dt;
if (this.fpsElapsed < 0.5) return;
this.fps = Math.round(this.fpsFrames / this.fpsElapsed);
this.fpsFrames = 0;
this.fpsElapsed = 0;
this.toolbar.setFps(this.fps);
}
/** Read-only view of the running world for tests. */
worldSnapshot(): { entities: { objectId: string; x: number; y: number; props: Record<string, unknown> }[]; log: string[] } | null {
if (!this.world) return null;
return { entities: this.world.entities.map((e) => ({ objectId: e.objectId, x: e.x, y: e.y, props: { ...e.props } })), log: [...this.world.log] };
worldSnapshot(): { entities: { objectId: string; x: number; y: number; worldX: number; worldY: number; parent: string; props: Record<string, unknown> }[]; log: string[] } | null {
const world = this.world;
if (!world) return null;
return {
entities: world.entities.map((e) => {
const w = world.worldPosition(e);
return { objectId: e.objectId, x: e.x, y: e.y, worldX: w.x, worldY: w.y, parent: e.parent?.objectId ?? '', props: { ...e.props } };
}),
log: [...world.log],
};
}
private applyMode(): void {
+24 -6
View File
@@ -1,10 +1,15 @@
import { RemoveSceneObjectCommand } from '../../core/commands/sceneObjectCommands';
import { RemoveSceneObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { treeOrder } from '../../core/model/hierarchy';
import { findAsset } from '../../core/model/Project';
import { resolveSceneObjectProps } from '../../core/properties/resolve';
import { ListView, Menu, Panel, ScrollContainer } from '../../ui';
import type { EditorState } from '../state';
/** Objects placed in the active scene. */
/**
* Objects placed in the active scene as a tree (children indented below their parent).
* Drag a row onto another to parent it there; drop it on the empty space below the rows to
* move it to the top level. The object keeps its world position either way.
*/
export class HierarchyPanel extends Panel {
private readonly list = new ListView();
private readonly scroll = new ScrollContainer();
@@ -17,12 +22,20 @@ export class HierarchyPanel extends Panel {
const scene = state.activeScene;
state.select(id && scene ? { kind: 'object', sceneId: scene.id, objectId: id } : null);
};
this.list.onDrop = (draggedId, targetId) => {
const scene = state.activeScene;
if (!scene) return;
const object = scene.objects.find((o) => o.id === draggedId);
if (!object || object.parentId === (targetId ?? '')) return;
state.execute(new SetParentCommand(scene.id, draggedId, targetId ?? ''));
};
this.list.onContextMenu = (id, x, y) => {
const scene = state.activeScene;
if (!scene) return;
const object = scene.objects.find((o) => o.id === id);
new Menu([
{ label: 'Select asset', onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }) },
{ label: 'Unparent (move to top level)', disabled: !object?.parentId, onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')) },
{ label: 'Remove from scene', danger: true, onSelect: () => state.execute(new RemoveSceneObjectCommand(scene.id, id)) },
]).open(x, y);
};
@@ -36,19 +49,24 @@ export class HierarchyPanel extends Panel {
this.refresh();
}
/** Ids in the order the rows are shown (test hook). */
rowOrder(): string[] {
const scene = this.state.activeScene;
return scene ? treeOrder(scene).map((e) => e.object.id) : [];
}
private refresh(): void {
const scene = this.state.activeScene;
this.setTitle(scene ? `Scene: ${scene.name}` : 'Scene objects');
this.list.setItems(
(scene?.objects ?? []).map((object) => {
(scene ? treeOrder(scene) : []).map(({ object, depth }) => {
const asset = findAsset(this.state.project, object.assetId);
const props = asset ? resolveSceneObjectProps(asset, object) : {};
const owner = object.ownerId ? scene?.objects.find((o) => o.id === object.ownerId) : undefined;
const ownerName = owner ? owner.name || findAsset(this.state.project, owner.assetId)?.name || '?' : '';
return {
id: object.id,
label: object.name || asset?.name || '?',
hint: owner ? `→ ${ownerName}` : asset?.type ?? '',
hint: asset?.type ?? '',
depth,
swatch: asset?.type === 'sprite' ? Number(props.color ?? 0) : undefined,
};
}),
+21 -15
View File
@@ -5,11 +5,12 @@ import {
MoveSceneObjectCommand,
RenameSceneObjectCommand,
SetOverrideCommand,
SetOwnerCommand,
SetParentCommand,
} from '../../core/commands/sceneObjectCommands';
import { AddVariableCommand, nextVariableName, RemoveVariableCommand, RenameVariableCommand } from '../../core/commands/variableCommands';
import { edgesInto, inlineInputDef, inlineInputs, nodeDef } from '../../core/graph';
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
import type { Asset, BehaviorAsset, SceneAsset } from '../../core/model/Asset';
import { isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
import type { Position, PropertyValue } from '../../core/model/Properties';
import { findAsset, findScene, findSceneObject } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject';
@@ -80,7 +81,7 @@ export class Inspector extends Panel {
if (s.kind === 'object') {
const object = this.currentObject(s.sceneId, s.objectId);
const asset = object && findAsset(this.state.project, object.assetId);
return asset ? `${object.ownerId}|` + schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing';
return asset ? `${object.parentId}|` + schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing';
}
const asset = findAsset(this.state.project, s.assetId);
if (!asset || asset.type !== 'behavior') return 'missing';
@@ -206,10 +207,11 @@ export class Inspector extends Panel {
assetRow.add(new Button(asset.name, { variant: 'ghost', onClick: () => this.state.select({ kind: 'asset', assetId: asset.id }) }), 1);
this.body.add(assetRow);
if (asset.type === 'behavior') this.body.add(this.ownerRow(scene.objects, object, sceneId));
this.body.add(this.parentRow(scene, object, sceneId));
// Position – belongs to the scene object only. Z is the draw order (higher = on top).
this.body.add(new Label('POSITION', { style: 'title' }));
// Position – belongs to the scene object only, local to the parent. Z is the draw order (higher = on top).
const parent = object.parentId ? findSceneObject(scene, object.parentId) : undefined;
this.body.add(new Label(parent ? 'POSITION (LOCAL TO PARENT)' : 'POSITION', { style: 'title' }));
for (const axis of ['x', 'y', 'z'] as const) {
let original: Position | undefined;
const row = createFieldRow({ key: axis, label: axis.toUpperCase(), kind: 'number', default: 0, step: 1 }, object.position[axis], {
@@ -275,22 +277,26 @@ export class Inspector extends Panel {
}
}
/** "Owner: <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' });
row.minHeight = Theme.rowHeight;
const label = new Label('Owner', { style: 'muted' });
const label = new Label('Parent', { style: 'muted' });
label.minWidth = FIELD_LABEL_WIDTH;
row.add(label);
const nameOf = (o: SceneObject) => o.name || findAsset(this.state.project, o.assetId)?.name || '?';
const owner = objects.find((o) => o.id === object.ownerId);
const button = new Button(`${owner ? nameOf(owner) : 'None'} ▾`);
const parent = scene.objects.find((o) => o.id === object.parentId);
const button = new Button(`${parent ? nameOf(parent) : 'None'} ▾`);
button.onClick = () => {
const g = button.getGlobalPosition();
const items: MenuItem[] = [{ label: 'None', onSelect: () => this.state.execute(new SetOwnerCommand(sceneId, object.id, '')) }];
for (const candidate of objects) {
if (candidate.id === object.id) continue;
items.push({ label: candidate.id === object.ownerId ? `● ${nameOf(candidate)}` : nameOf(candidate), onSelect: () => this.state.execute(new SetOwnerCommand(sceneId, object.id, candidate.id)) });
const items: MenuItem[] = [{ label: 'None', onSelect: () => this.state.execute(new SetParentCommand(sceneId, object.id, '')) }];
for (const { object: candidate, depth } of treeOrder(scene)) {
if (isSelfOrAncestor(scene, object.id, candidate.id)) continue;
const name = `${' '.repeat(depth)}${nameOf(candidate)}`;
items.push({ label: candidate.id === object.parentId ? `● ${name}` : name, onSelect: () => this.state.execute(new SetParentCommand(sceneId, object.id, candidate.id)) });
}
new Menu(items).open(g.x, g.y + button.h + 2);
};
+9
View File
@@ -28,6 +28,7 @@ export class Toolbar extends HStack {
private readonly playButton: Button;
private readonly status: Label;
private readonly notice: Label;
private readonly fps: Label;
constructor(private readonly state: EditorState, actions: ToolbarActions) {
super({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
@@ -68,6 +69,9 @@ export class Toolbar extends HStack {
this.status = new Label('', { style: 'small', align: 'right' });
this.status.minWidth = 110;
this.add(this.status);
this.fps = new Label('– fps', { style: 'small', align: 'right' });
this.fps.minWidth = 56;
this.add(this.fps);
state.on('project', () => this.refresh());
state.on('load', () => this.refresh());
@@ -77,6 +81,11 @@ export class Toolbar extends HStack {
this.refresh();
}
/** Frame rate readout; `null` while no measurement is available yet. */
setFps(fps: number | null): void {
this.fps.setText(fps === null ? '– fps' : `${fps} fps`);
}
private refresh(): void {
const { state } = this;
this.projectName.setValue(state.project.name);