- Graph: nodes.ts split into nodes/<category>/<Type>.ts (62 files) with shared.ts factories and index.ts registry; NodeContext/values/NodeRegistry separated, writeTyped() replaces duplicated typed writes - Comments: one style (JSDoc on non-obvious exports, // for decisions and traps), wrong/stale comments fixed or removed, ids.ts now real 10 base-36 chars - Core: chainOf, shared worldPosition/editorPosition core, commands/require.ts, commands/naming.ts, util/list.ts, setBagValue/toPosition/propDef; dead code removed (isBehaviorAsset, SCENE_OBJECT_SCHEMA, CommandStack.subscribe, Input.axis, setMode, titleText, World.worldPosition, unused ctor params) - Editor: Inspector split into inspector/ sections, App.ts into Editor.ts + debugHook.ts + shortcuts.ts, Viewport into items.ts + symbols.ts, shared "Add collider" action, unified type labels/swatches - UI toolkit: Widget.trackHover/onPointerRelease, drawControlBox, math helpers, Popup.openBelow, Menu/Dialog/ColorPicker/ListRow in own files, named Theme sizes - Tools: seed-demo/smoke-demo and demo-check project removed, tools/lib (harness, projects, builders), e2e.mjs as runner over tools/e2e/*.mjs sections - Tests: tests/helpers.ts replaces copied fixtures and NodeContext doubles Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
105 lines
6.2 KiB
JavaScript
105 lines
6.2 KiB
JavaScript
const FIXTURE_IDS = ['o1', 'o2', 'o3', 'o4', 'o5', 'oc1', 'oc2'];
|
||
|
||
/**
|
||
* Play mode: the camera frame is rendered, the Mover behavior moves its owner on the arrow
|
||
* keys, Esc stops without touching the model. Then editing while playing (moves, delete, undo
|
||
* and Spawn reach the world at once; Stop discards all of it), culling, and a scene without a
|
||
* camera.
|
||
*/
|
||
export async function play(t) {
|
||
const { check, sleep, evaluate, json, shot, clickText, press, holdKey, undo, state, object, objects, world, entity, select, mutate, stop } = t;
|
||
const gameInfo = () => json('__gm.gameInfo()');
|
||
const play = t.play;
|
||
|
||
// 7. Play: both crates lie inside the 640×360 frame at the origin; the behavior symbol is
|
||
// editor-only. The "Mover" behavior owns the red crate: holding ArrowRight moves it in the
|
||
// world (Axis input × speed × dt), Esc stops without touching the model.
|
||
await clickText('Renamed crate');
|
||
const xBeforePlay = (await object('o2')).position.x;
|
||
await play();
|
||
let s = await state();
|
||
check('play mode entered', s.mode === 'play', JSON.stringify(s));
|
||
let info = await gameInfo();
|
||
check('play renders the camera frame with both crates', info.rendering && info.visibleItems === 2, JSON.stringify(info));
|
||
await shot('08-playing');
|
||
await holdKey('ArrowRight', 400);
|
||
const movedCrate = await entity('o1');
|
||
check('behavior graph moved its owner while ArrowRight was held', movedCrate.x > -150 + 30, `world x=${movedCrate.x}`);
|
||
await holdKey('ArrowLeft', 250);
|
||
const afterLeft = await entity('o1');
|
||
check('ArrowLeft (axis x = −1) moved the owner back left', afterLeft.x < movedCrate.x, `world x=${afterLeft.x}`);
|
||
await stop();
|
||
s = await state();
|
||
check('escape stopped play', s.mode === 'edit', JSON.stringify(s));
|
||
await shot('09-stopped');
|
||
const o2AfterPlay = (await object('o2')).position.x;
|
||
const o1AfterPlay = (await object('o1')).position.x;
|
||
check('play did not write back to the model', o2AfterPlay === xBeforePlay && o1AfterPlay === -150, `${o2AfterPlay} vs ${xBeforePlay}, o1=${o1AfterPlay}`);
|
||
|
||
// 7a. Editing while playing: Play opens the Game tab, the panels stay open and edit a runtime
|
||
// copy. Moving the blue crate (model hook + preview, as a scrub does) and deleting it with
|
||
// the Delete key change the world at once, undo brings it back, Esc restores the saved model.
|
||
await play();
|
||
check('play opens the Game tab', (await evaluate('__gm.tab()')) === 'game');
|
||
check('panels stay visible while playing', Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) && (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 7);
|
||
await select({ kind: 'object', sceneId: 'scene1', objectId: 'o2' });
|
||
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 400`);
|
||
let liveO2 = await entity('o2');
|
||
check('an edit while playing moves the entity in the world', liveO2 && liveO2.x === 400, JSON.stringify(liveO2));
|
||
await shot('08b-editing-while-playing');
|
||
await press('Delete');
|
||
await sleep(150);
|
||
const liveIds = (await world()).entities.map((e) => e.objectId);
|
||
check('Delete while playing removes the entity and its collider child from the world', !liveIds.includes('o2') && !liveIds.includes('oc2') && liveIds.length === 5, JSON.stringify(liveIds));
|
||
await undo();
|
||
liveO2 = await entity('o2');
|
||
check('undo while playing brings the entity back into the world', liveO2 && liveO2.x === 400, JSON.stringify(liveO2));
|
||
|
||
// 7a-ii. Spawning: the "Spawner" behavior places a Crate at (0, 150) on Space. The new object
|
||
// shows up in the runtime scene (hierarchy), in the world and in the Game view at once;
|
||
// Stop discards it with the rest of the runtime copy.
|
||
const visibleBeforeSpawn = (await gameInfo()).visibleItems;
|
||
await press('Space');
|
||
await sleep(200);
|
||
const spawnedObjects = await objects();
|
||
const spawned = spawnedObjects.find((o) => !FIXTURE_IDS.includes(o.id));
|
||
check(
|
||
'Spawn node adds a crate object to the runtime scene at (0, 150)',
|
||
spawned && spawned.assetId === 'crate' && spawned.parentId === '' && spawned.position.x === 0 && spawned.position.y === 150 && spawnedObjects.length === 8,
|
||
JSON.stringify(spawned),
|
||
);
|
||
const spawnWorld = await world();
|
||
check('the spawned entity lives in the world and its name reached the Log node', spawnWorld.entities.length === 8 && spawnWorld.log.includes('Crate'), JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log }));
|
||
check('hierarchy lists the spawned object', (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 8);
|
||
info = await gameInfo();
|
||
check('the Game view renders the spawned crate', info.visibleItems === visibleBeforeSpawn + 1, `${JSON.stringify(info)} before=${visibleBeforeSpawn}`);
|
||
await shot('08c-spawned');
|
||
await stop();
|
||
const o2AfterEdit = (await object('o2')).position.x;
|
||
check('stop restores the saved model untouched by play-mode edits (spawned object gone)', o2AfterEdit === xBeforePlay && (await objects()).length === 7, `x=${o2AfterEdit}`);
|
||
check('stop returns to the Scene tab', (await evaluate('__gm.tab()')) === 'scene');
|
||
|
||
// 7b. Culling: move the blue crate far outside the camera frame → only one item visible.
|
||
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 5000`);
|
||
await play();
|
||
info = await gameInfo();
|
||
check('objects outside the camera frame are culled', info.visibleItems === 1, JSON.stringify(info));
|
||
await stop();
|
||
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = ${xBeforePlay}`);
|
||
|
||
// 7c. Without a camera nothing is rendered: delete the camera object, play, then undo.
|
||
await select({ kind: 'object', sceneId: 'scene1', objectId: 'o3' });
|
||
await press('Delete');
|
||
await sleep(200);
|
||
check('camera object deleted', (await objects()).length === 6);
|
||
await play();
|
||
info = await gameInfo();
|
||
s = await state();
|
||
check('play without camera renders nothing', s.mode === 'play' && !info.rendering && info.visibleItems === 0, JSON.stringify(info));
|
||
check('play without camera shows a notice', s.notice.includes('no camera'), s.notice);
|
||
await shot('10-play-no-camera');
|
||
await stop();
|
||
await undo();
|
||
check('undo restored the camera object', (await objects()).length === 7);
|
||
}
|