Files
feuerwerk/tools/e2e/03-play.mjs
T
adminandClaude Opus 5.5 24d360aeec
game-maker / gate (push) Successful in 4m5s
game-maker / suite (push) Failing after 12m45s
Add zoom steps and a larger start caption to the game link (#266)
The released game only ever zoomed in whole steps up to 400%, so a
small camera on a large screen stayed far smaller than it could be.
The "Click to play" caption was drawn in the 11 px panel title size
and was easy to miss.

- GAME_SCALE_STEPS gains 300%, 500% and 600%. The game tab picker,
  Auto in the editor and the game link all read the same list.
- The start caption takes its size from the new Theme.captionSize
  (16) instead of a constant in GameScreen.
- The player page keeps the default arrow cursor at all times. It no
  longer switches to the hand over the caption.

Tests:
- view.test: Auto for 320x180 in 2000x2000 is now 6, with cases
  for 3 and 5. play-settings.test takes 2.5 and 8 as invalid steps.
- e2e 60 checks the caption against the panel title size and the
  arrow cursor before and after the start. Module 03 follows the new
  step list.
- ./check.sh 2808 tests, CHECK EXIT 0 (before the last Theme tweak,
  which changes no test). Full e2e run ALL PASSED, 1574 checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:14:54 +02:00

414 lines
17 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 }),
);
// The panels catch up four times a second while playing (#265), and a crate spawned at the top
// level is listed in the group named after its asset.
await t.sleep(300);
const crateGroup = await json(`__gm.state.activeScene.groups?.find((g) => g.name === 'Crate')?.id ?? ''`);
check(
'hierarchy lists the spawned object in a group named Crate',
spawned?.groupId === crateGroup &&
(await evaluate(`__gm.hierarchyRow('group:${crateGroup}') !== null`)) &&
(await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 9,
JSON.stringify({ crateGroup, spawned }),
);
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',
// The letterbox is Theme.colors.background.
previewPixel !== '#1b1d22',
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),
);
// 3g. Game view scale (#225): 100% by default, picked in the header. A fixed step draws that
// many pixels per unit whatever the view's size, a frame larger than the view is cut to it,
// fit fills the tighter side and auto takes the largest step that still fits.
const scale = () => json('__gm.gameScale()');
const pickScale = async (label) => {
const { picker } = await scale();
await click(picker.x, picker.y);
await clickText(label);
return scale();
};
const centred = ({ frame, view }) =>
Math.abs(frame.x + frame.w / 2 - (view.x + view.w / 2)) <= 1 &&
Math.abs(frame.y + frame.h / 2 - (view.y + view.h / 2)) <= 1;
const undoSteps = () => json('__gm.state.commands.undoStack.length');
await evaluate(`localStorage.removeItem('game-maker.editor')`);
const stepsBefore = await undoSteps();
let g = await scale();
const crateAt100 = (await json(`__gm.gameRect('o1')`)).w;
check(
'the game view starts at 100%, one pixel per world unit',
g.mode === 1 && g.scale === 1 && g.label === '100%',
JSON.stringify(g),
);
g = await pickScale('25%');
const crateAt25 = (await json(`__gm.gameRect('o1')`)).w;
check(
'at 25% the 640x360 frame is 160x90, centred on a whole pixel, and the crate a quarter as wide',
g.scale === 0.25 &&
g.frame.w === 160 &&
g.frame.h === 90 &&
Number.isInteger(g.frame.x - g.view.x) &&
centred(g) &&
Math.abs(crateAt25 * 4 - crateAt100) < 0.01,
JSON.stringify({ g, crateAt25, crateAt100 }),
);
await shot('10e-game-scale-25');
g = await pickScale('400%');
check(
'at 400% the frame is larger than the view and cut to it, the camera still in the middle',
g.scale === 4 &&
JSON.stringify(g.frame) === JSON.stringify(g.view) &&
(await json(`__gm.gameRect('o1')`)).w === crateAt100 * 4,
JSON.stringify(g),
);
g = await pickScale('Fit');
const fit = Math.min(g.view.w / 640, g.view.h / 360);
check(
'fit fills the tighter side exactly and names the factor it came to',
g.mode === 'fit' &&
Math.abs(g.factor - fit) < 1e-9 &&
g.label === `Fit · ${Math.round(fit * 100)}%` &&
centred(g),
JSON.stringify(g),
);
g = await pickScale('Auto');
const auto = Math.max(0.25, ...[0.25, 0.5, 1, 2, 3, 4, 5, 6].filter((step) => step <= fit));
check(
'auto takes the largest step that still fits',
g.mode === 'auto' && g.factor === auto && g.label === `Auto · ${auto * 100}%` && g.frame.w === 640 * auto,
JSON.stringify({ g, fit }),
);
check(
'the scale is saved with the project (#226), not in this browser, and is no undo step',
(await json('__gm.state.project.gameScale')) === 'auto' &&
(await evaluate(`localStorage.getItem('game-maker.editor')`)) === null &&
(await undoSteps()) === stepsBefore,
JSON.stringify({ steps: await undoSteps(), stepsBefore }),
);
// 3h. Fit in the scene tab's header frames what the game view shows, at the game view's scale:
// the crate and the camera frame land on the same screen pixels in both tabs, so switching
// between them moves nothing – at auto's whole step and at fit's fractional one alike.
const sameAsGame = async (label) => {
const game = await pickScale(label);
const inGame = await json(`__gm.gameRect('o1')`);
await showTab('scene');
const fitAt = await json(`__gm.sceneHeaderAt('fit')`);
await click(fitAt.x, fitAt.y);
const inScene = await json(`__gm.objectRect('o1')`);
const cameraInScene = await json(`__gm.objectRect('o3')`);
const view = await json('__gm.viewportRect()');
await showTab('game');
const near = (a, b) => ['x', 'y', 'w', 'h'].every((k) => Math.abs(a[k] - b[k]) < 0.01);
// `objectRect` gives the centre, the game frame its top-left corner.
const { x, y, w, h } = game.frame;
const frameCentred = { x: x + w / 2, y: y + h / 2, w, h };
return {
ok: near(inGame, inScene) && near(frameCentred, cameraInScene) && near(game.view, view),
detail: JSON.stringify({
label,
inGame,
inScene,
frame: game.frame,
cameraInScene,
view: game.view,
sceneView: view,
}),
};
};
for (const label of ['Auto', 'Fit']) {
const { ok, detail } = await sameAsGame(label);
check(`Fit in the scene tab shows the world exactly as the game view does at ${label}`, ok, detail);
}
g = await pickScale('100%');
check('and 100% can be picked back', g.mode === 1 && g.scale === 1, JSON.stringify(g));
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),
);
}