Files
feuerwerk/tools/e2e/03-play.mjs
T
adminandClaude Opus 5 28b1afa7af
game-maker / gate (push) Successful in 2m22s
game-maker / suite (push) Successful in 8m38s
Panning the scene view no longer redraws the whole scene
A middle-mouse pan used to run the full redraw on every frame. It
collected every object from the project, cleared and redrew every
shape, re-sorted all children and made Pixi re-upload every buffer,
although only the camera had moved. The user saw the frame rate drop
while panning.

- WorldViewport gets moveCamera(). When zoom and view size are
  unchanged it only moves the world and redraws the overlays drawn in
  screen pixels. collectFrame, the shape redraw and the re-sort drop
  out of a pan frame.
- Every full redraw remembers the box each object is culled by. A pan
  that moves an object across the edge of the view still takes the
  full redraw, so objects appear and vanish as before.
- Grid, selection outlines, rays and the container origin mark move
  into a shared drawOnScreen hook. It runs after every full redraw and
  after every pan. The parent links stay in afterItems because they
  are drawn in world units.
- Wheel zoom keeps the full redraw, because line widths follow the
  zoom. A cache for collectFrame was left out: it would save about
  1 ms and risk stale data.
- debugInfo() reports redraws and cameraMoves, and __gm gets a hook
  that forces a full redraw, so a check can compare against it.

Findings:
- Chrome already coalesces mouse moves to one per frame (500 moves
  gave 183 redraws in 189 frames). A throttle was tried and removed
  again, because its counter-check did not fail.
- A check in module 03 compared the whole gameInfo() object and broke
  on the new counters. It now compares camera, objects and rendering.
- The stall without ?vsync most likely comes from the desynchronized
  context in src/main.ts. It cannot be reproduced headless.

Tests:
- Measured on Jump and Run, main-thread time per frame, 3 runs of 240
  moves: pan 5.7-7.4 ms before, 3.8-4.8 ms after. Plain mouse moves
  stay at 2.1-2.4 ms. 208 of 240 moves took the fast path.
- ./check.sh: 141 files, 2080 tests, exit 0.
- GM_STEPS=1-16,29,42,43: 535 checks, 1 failed (the module 03 test
  above). After the fix GM_STEPS=1-5: all passed, 130 checks.
- 3 new e2e checks in 5d: 12 pan steps give 12 camera moves and 0
  full redraws, pixel rows after the pan match a fresh redraw, a box
  panned out of view and back is drawn again.
- Counter-checks: overlays not redrawn on pan gives "3 of 3 rows
  differ". Edge test disabled keeps the box visible with 0 redraws.
- Check total 1285 (1282 confirmed by CI task 229, plus 3).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:38:05 +02:00

301 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,
evaluate,
json,
shot,
click,
clickText,
dragAsset,
press,
holdKey,
undo,
state,
object,
objects,
world,
entity,
select,
mutate,
stop,
showTab,
} = t;
const gameInfo = () => json('__gm.gameInfo()');
const play = t.play;
// 3a. 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}`,
);
// 3b. 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');
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');
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');
// 3c. 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}`);
// 3d. Without a camera nothing is rendered: delete the camera object, play, then undo.
await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' });
await press('Delete');
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);
// 3e. 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);
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 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),
);
// 3f. 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 showTab('game');
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();
// What is drawn, not how often: the redraw counters differ between any two moments.
const drawnAs = ({ rendering, visibleItems, camera }) =>
JSON.stringify({ rendering, visibleItems, camera });
check(
'and it is the picture Play produces: same camera, same items, same pixel on the crate',
drawnAs(info) === drawnAs(preview) && playingPixel === previewPixel,
`${drawnAs(preview)} vs ${drawnAs(info)}, ${previewPixel} vs ${playingPixel}`,
);
await stop();
// Switching the only camera off leaves nothing to preview – and says so, live on the tab.
await showTab('game');
await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' });
const previewSwitch = await json("__gm.inspectorField('Enabled')");
await click(previewSwitch.x, previewSwitch.y);
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();
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 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),
);
}