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>
414 lines
17 KiB
JavaScript
414 lines
17 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,
|
||
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),
|
||
);
|
||
}
|