Files
feuerwerk/tools/e2e/03-play.mjs
T
rootandClaude Opus 5 37fff5dabf Play through the first camera that is switched on
The game view took whatever camera came first in the scene, ignoring the
switch, so `enabled = false` on a camera did nothing and a second camera
never got its turn. `World.activeCamera()` now decides: of the cameras
switched on – the whole parent chain counted, as for any object – the
first in hierarchy order, and none on renders nothing at all. It is asked
per frame, so a graph switching cameras hands the view over immediately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:13:30 +02:00

246 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
click,
clickText,
dragAsset,
locate,
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', holderId: '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', holderId: '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 is switched on'), s.notice);
await shot('10-play-no-camera');
await stop();
await undo();
check('undo restored the camera object', (await objects()).length === 7);
// 7d. Which camera renders: a camera that is switched off is skipped like any other object, and
// of those left the first in hierarchy order takes the view. The second camera is parked far
// from the origin, so "seen through it" is provable – both crates fall outside its frame.
await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' });
const enabledRow = await locate('Enabled');
// The checkbox sits right of the 64 px label column; locate() gives the label's text centre.
await click(enabledRow.x + 55, enabledRow.y);
await sleep(150);
check(
'the camera has the enabled switch like any other object',
(await object('o3')).overrides.enabled === false,
JSON.stringify((await object('o3')).overrides),
);
await play();
info = await gameInfo();
s = await state();
check(
'a switched-off camera renders nothing although it is still in the scene',
!info.rendering && info.camera === '' && info.visibleItems === 0,
JSON.stringify(info),
);
check('and the notice names the switch, not a missing camera', s.notice.includes('switched on'), s.notice);
await stop();
const centre = await json('__gm.viewportCenter()');
await dragAsset('cam', centre.x + 250, centre.y + 150);
const second = (await objects()).find((o) => !FIXTURE_IDS.includes(o.id));
check('a second camera is placed in the scene', Boolean(second), JSON.stringify(second));
await mutate(
`__gm.state.activeScene.objects.find(o => o.id === ${JSON.stringify(second.id)}).position.x = 5000`,
);
await play();
info = await gameInfo();
check(
'the switched-on camera takes over, and the game is seen through it: the crates at the origin are outside its frame',
info.camera === second.id && info.rendering && info.visibleItems === 0,
JSON.stringify(info),
);
await shot('10b-second-camera');
await stop();
await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' });
const switchBackOn = await locate('Enabled');
await click(switchBackOn.x + 55, switchBackOn.y);
await sleep(150);
await play();
info = await gameInfo();
check(
'with both switched on the first camera in hierarchy order is the active one again',
info.camera === 'o3' && info.visibleItems === 2,
JSON.stringify(info),
);
await stop();
// Back to the fixture: the switch-on, the placement and the switch-off, newest first.
await undo();
await undo();
await undo();
check(
'undo leaves the fixture as it was, camera on and without an override',
(await objects()).length === 7 && Object.keys((await object('o3')).overrides).length === 0,
JSON.stringify((await object('o3')).overrides),
);
}