Files
feuerwerk/tools/e2e/03-play.mjs
T
rootandClaude Opus 5 631cf54c72 Give every behavior variable one row shape on asset and instance
buildStructBlock only covered structs, so a placed instance drew numbers,
text, bools and lists through createFieldRow on a 64 px name column while
its struct block sat on the asset's grid below it. variableRows.ts now
builds the row for every variable kind on both sides; the edit option
decides what the owner may change (asset: rename, x, item kind; instance:
revert and push). Label and Button take a fixed width so the mark column
no longer shifts names when a list shows its item-kind picker.

The two structFields calls that became dead with 9f48257 are gone. The
e2e steps locate the Enabled toggle through __gm.inspectorField instead
of clicking a fixed offset from its label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 07:27:29 +02:00

306 lines
12 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,
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' });
// Found through its row, not an offset from the label: the label column's width is layout.
const enabledRow = await json("__gm.inspectorField('Enabled')");
await click(enabledRow.x, 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 json("__gm.inspectorField('Enabled')");
await click(switchBackOn.x, 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),
);
// 7e. Not playing, the Game tab previews the scene through the same camera instead of waiting
// for Play. Proven by comparing it with what Play then draws – identical readout and an
// identical pixel on the red crate – so "it renders something" cannot pass for a preview.
const crateSpot = async () => {
const p = await json(`__gm.gameScreen('o1')`);
return json(`__gm.pixelAt(${Math.round(p.x)}, ${Math.round(p.y)})`);
};
await evaluate(`__gm.showTab('game')`);
await sleep(150);
const preview = await gameInfo();
const previewPixel = await crateSpot();
check(
'the game tab previews the scene through the active camera, without playing',
preview.rendering && preview.camera === 'o3' && preview.visibleItems === 2,
JSON.stringify(preview),
);
check(
'and the crate is really drawn there, not left as letterbox',
previewPixel !== '#000000',
previewPixel,
);
await shot('10c-game-preview');
await play();
info = await gameInfo();
const playingPixel = await crateSpot();
check(
'and it is the picture Play produces: same camera, same items, same pixel on the crate',
JSON.stringify(info) === JSON.stringify(preview) && playingPixel === previewPixel,
`${JSON.stringify(preview)} vs ${JSON.stringify(info)}, ${previewPixel} vs ${playingPixel}`,
);
await stop();
// Switching the only camera off leaves nothing to preview – and says so, live on the tab.
await evaluate(`__gm.showTab('game')`);
await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' });
const previewSwitch = await json("__gm.inspectorField('Enabled')");
await click(previewSwitch.x, previewSwitch.y);
await sleep(200);
info = await gameInfo();
check(
'switching the camera off empties the preview and names the reason',
!info.rendering && info.camera === '' && Boolean(await json(`__gm.locate('No active camera')`)),
JSON.stringify(info),
);
await shot('10d-no-active-camera');
await undo();
await sleep(200);
info = await gameInfo();
check(
'and undo brings the preview back without a reload',
info.rendering && info.camera === 'o3' && (await json(`__gm.locate('No active camera')`)) === null,
JSON.stringify(info),
);
await evaluate(`__gm.showTab('scene')`);
check(
'the fixture is left as it was found',
(await objects()).length === 7 && Object.keys((await object('o3')).overrides).length === 0,
JSON.stringify((await object('o3')).overrides),
);
}