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); } // 3i. A project that is loaded brings its scene up framed the same way, without the click (#295). // The scale still stands at Fit, so the framed view is not the one a bare reset would give. const gameAtFit = await json(`__gm.gameRect('o1')`); await showTab('scene'); await evaluate('__gm.lookAt(5000, 5000)'); await evaluate('__gm.state.loadProject(__gm.state.project, __gm.state.revision)'); await showTab('scene'); const sceneOnLoad = await json(`__gm.objectRect('o1')`); check( 'a loaded project shows its scene framed like Fit, without a click', Boolean(sceneOnLoad) && ['x', 'y', 'w', 'h'].every((k) => Math.abs(sceneOnLoad[k] - gameAtFit[k]) < 0.01), JSON.stringify({ gameAtFit, sceneOnLoad }), ); await showTab('game'); 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), ); }