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

+78 -2
View File
@@ -85,8 +85,8 @@ try {
});
const evaluate = async (expression) => (await cdp.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })).result.value;
const locate = async (text) => {
const p = await evaluate(`window.__gm && window.__gm.locate(${JSON.stringify(text)})`);
const locate = async (text, last = false) => {
const p = await evaluate(`window.__gm && window.__gm.locate(${JSON.stringify(text)}, ${last})`);
if (!p) throw new Error(`widget with text "${text}" not found`);
return p;
};
@@ -426,9 +426,17 @@ try {
// 13e. Play always starts on the Scene tab; switching back to Graph while playing shows
// the live highlight: hold ArrowRight → the exec edge is hot.
const fpsEdit = await evaluate('__gm.fps()');
check('fps readout is measured in edit mode', typeof fpsEdit === 'number' && fpsEdit > 0, `fps=${fpsEdit}`);
// Single evaluate: the readout refreshes twice a second, so read value and label together.
const fpsLabel = await evaluate(`!!__gm.locate(__gm.fps() + ' fps')`);
check('fps label is visible in the toolbar', fpsLabel === true);
await clickText('▶ Play');
await sleep(200);
check('play switches to the scene tab', (await evaluate('__gm.tab()')) === 'scene');
await sleep(600);
const fpsPlay = await evaluate('__gm.fps()');
check('fps readout keeps running in play mode', typeof fpsPlay === 'number' && fpsPlay > 0, `fps=${fpsPlay}`);
await evaluate(`__gm.showTab('graph')`);
await sleep(100);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
@@ -465,6 +473,74 @@ try {
check('graph editor rebinds when a behavior is selected again', (await evaluate(`__gm.graph.current()`)) === 'mover');
await evaluate(`__gm.showTab('scene')`);
// 14. Hierarchy / parenting. Give both crates unique names, then drag the blue crate's row
// onto the red crate's row: it becomes a child, keeps its world position (local becomes
// the difference) and is listed right below its parent.
await evaluate(`(() => {
const objs = __gm.state.activeScene.objects;
const o1 = objs.find(o => o.id === 'o1'); const o2 = objs.find(o => o.id === 'o2');
o1.name = 'Red crate'; o1.position = { x: -150, y: 0, z: 0 };
o2.name = 'Blue crate'; o2.position = { x: 120, y: -80, z: 0 }; o2.parentId = '';
__gm.state.select(null);
__gm.state.preview();
})()`);
await sleep(200);
const redRow = await locate('Red crate');
const blueRow = await locate('Blue crate');
await drag(blueRow.x, blueRow.y, redRow.x, redRow.y);
await sleep(200);
const objectJson = (id) => evaluate(`JSON.stringify(__gm.state.activeScene.objects.find(o => o.id === '${id}'))`);
let o2 = JSON.parse(await objectJson('o2'));
check('dropping a row onto another parents it, keeping the world position', o2.parentId === 'o1' && o2.position.x === 270 && o2.position.y === -80, JSON.stringify(o2));
const order = JSON.parse(await evaluate(`JSON.stringify(__gm.hierarchyOrder())`));
check('children are listed directly below their parent', order.indexOf('o2') === order.indexOf('o1') + 1 && order.indexOf('o4') === order.indexOf('o1') + 2, JSON.stringify(order));
await shot('20-parented');
// 14a. Moving the parent (X field of the red crate) drags the child along in world space.
await clickText('Red crate');
const xField = await locate('X');
await click(xField.x + 160, xField.y);
await keyPress('a', 'KeyA', { modifiers: 2, windowsVirtualKeyCode: 65 });
await typeText('-50');
await keyPress('Enter', 'Enter', { windowsVirtualKeyCode: 13 });
await sleep(200);
o2 = JSON.parse(await objectJson('o2'));
const worldOfO2 = await evaluate(`(() => { const s = __gm.state.activeScene; const child = s.objects.find(x => x.id === 'o2'); const parent = s.objects.find(x => x.id === child.parentId); return JSON.stringify({ x: child.position.x + parent.position.x, y: child.position.y + parent.position.y }); })()`);
check('moving the parent moves the child in world space (local unchanged)', o2.position.x === 270 && worldOfO2 === '{"x":220,"y":-80}', `${JSON.stringify(o2.position)} world=${worldOfO2}`);
// 14b. Play: entities mirror the hierarchy; undo restores parent and position.
await evaluate(`__gm.state.select(null)`);
await keyPress('Enter', 'Enter', { modifiers: 2, windowsVirtualKeyCode: 13 });
await sleep(300);
const playWorld = await evaluate('JSON.stringify(__gm.world())');
const e2 = JSON.parse(playWorld).entities.find((e) => e.objectId === 'o2');
check('play instantiates the parent link and world positions', e2 && e2.parent === 'o1' && e2.worldX === 220 && e2.x === 270, JSON.stringify(e2));
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
await sleep(200);
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); // undo move
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); // undo parent
await sleep(200);
o2 = JSON.parse(await objectJson('o2'));
check('undo restores top level and the original local position', o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, JSON.stringify(o2));
// 14c. Parent via the inspector menu, then "Unparent" from the hierarchy context menu:
// the world position is kept both ways.
await clickText('Blue crate');
await clickText('None ▾');
await sleep(150);
const menuEntry = await locate('Red crate', true); // the popup is drawn last; the hierarchy row has the same text
await click(menuEntry.x, menuEntry.y);
await sleep(200);
o2 = JSON.parse(await objectJson('o2'));
check('inspector Parent menu parents the object', o2.parentId === 'o1' && o2.position.x === 270, JSON.stringify(o2));
const blueRow2 = await locate('Blue crate');
await click(blueRow2.x, blueRow2.y, 'right');
await sleep(150);
await clickText('Unparent (move to top level)');
await sleep(200);
o2 = JSON.parse(await objectJson('o2'));
check('Unparent moves the object to the top level, keeping its world position', o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, JSON.stringify(o2));
check('no exceptions during run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | '));
ws.close();
} catch (error) {
+5 -4
View File
@@ -45,10 +45,11 @@ const project = {
name: 'Level 1',
props: { backgroundColor: 0x22252c },
objects: [
{ id: 'o1', assetId: 'crate', name: '', position: { x: -150, y: 0, z: 0 }, overrides: {}, ownerId: '' },
{ id: 'o2', assetId: 'crate', name: 'Blue crate', position: { x: 120, y: -80, z: 0 }, overrides: { width: 140, color: 0x4488ff }, ownerId: '' },
{ id: 'o3', assetId: 'cam', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, ownerId: '' },
{ id: 'o4', assetId: 'mover', name: '', position: { x: -150, y: 110, z: 0 }, overrides: {}, ownerId: 'o1' },
{ id: 'o1', assetId: 'crate', name: '', position: { x: -150, y: 0, z: 0 }, overrides: {}, parentId: '' },
{ id: 'o2', assetId: 'crate', name: 'Blue crate', position: { x: 120, y: -80, z: 0 }, overrides: { width: 140, color: 0x4488ff }, parentId: '' },
{ id: 'o3', assetId: 'cam', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: '' },
// The mover is parented to the red crate (its owner); its position is local to it.
{ id: 'o4', assetId: 'mover', name: '', position: { x: 0, y: 110, z: 0 }, overrides: {}, parentId: 'o1' },
],
},
{ id: 'crate', type: 'sprite', name: 'Crate', props: { width: 100, height: 60, color: 0xe0553f } },