Files
feuerwerk/tools/e2e/35-load-scene.mjs
T
adminandClaude Opus 5 60d0146bde The topbar is icons only: the leading Project dropdown, the Scene picker and the labelled Play/Pause buttons are gone and in their place stand five icon buttons - menu, undo, redo on the left, play and pause in the middle - while Notice, the save state, fps and the Account menu keep the right side; the Play button swaps its icon for a stop square while the project runs and Pause swaps for a second play sign while it is held (Button.setIcon()), and iconSpace() drops the gap between icon and label when a button has no label, which is what kept a 24 px icon button 3 px off its own centre; the menu carries New project, Open project..., a separator and Project settings..., where the picker (src/editor/OpenProjectDialog.ts) preselects the open project and marks it "open", opens on a double click and puts Delete on the left of Cancel and Open - it closes itself before it asks whether to delete, because two modals on top of each other would have left the question standing over a list it no longer speaks about - and the settings dialog (src/editor/ProjectSettingsDialog.ts) holds the name, the "Play starts" switch (Start scene / Active scene, a preference of this browser and so no command) and the collision layers from #152, which retires CollisionLayersDialog.ts and the "Collision layers..." entry; what Apply actually writes is the pure function settingsChanges(), so renaming and the layers are each their own undo step and each drop out when nothing differs; the Scene dropdown falls away without replacement, the active scene is switched by double clicking it in the Assets panel, which is the way that was there anyway; new module 46 runs last with 9 checks (no label left in the bar, the run buttons in edit mode, the four menu entries, the picker with the open project preselected, Cancel leaving the project standing, the settings dialog on the state of the project, renaming with undo and redo through the icon buttons) and it neither opens nor deletes a project, because that would pull the project out from under every earlier module; modules 8, 18 and 35 and the harness follow the new bar, 6 unit tests cover settingsChanges and 3 the icons (#183)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:34:14 +02:00

259 lines
10 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.
import { edge, node, port, variable, written } from '../lib/builders.mjs';
import { MAKE_OBJECT } from './lib/scaffold.mjs';
/**
* More than one scene (#39): the project names the scene a run begins with – which is not the same
* question as which scene is open in the editor – and `Load scene` swaps the running one for
* another from inside a graph.
*
* What the swap keeps is the point: the scene is built from scratch, while what belongs to the
* *run* carries on. A Variables asset is the witness, because a fresh run would put it back to
* what it declares. The sounds of the old scene end with it – not by a rule of their own, but
* because the behaviors playing them are destroyed like everything else in that scene.
*/
const SOURCE = { variablesAsset: 'ls-vars' };
/** `On start → Variables{set score = score + 1}`, plus `On key down g → Load scene`. */
const graphFor = (sceneId, soundId) => ({
nodes: [
node('ls-start', 'OnStart', 0, 0),
node('ls-read', 'Variables', 0, 200, SOURCE, [port('score', 'number')]),
node('ls-add', 'Add', 260, 200, { b: 1 }),
node('ls-write', 'Variables', 560, 0, SOURCE, [written('score', 'number')]),
node('ls-play', 'PlaySound', 820, 0, { sound: soundId, volume: 1, loop: 'true' }),
node('ls-key', 'OnKeyDown', 0, 420, { key: 'g' }),
node('ls-load', 'LoadScene', 260, 420, { scene: sceneId }),
],
edges: [
edge('ls-e1', 'ls-start', 'exec', 'ls-write', 'exec'),
edge('ls-e2', 'ls-read', 'score', 'ls-add', 'a'),
edge('ls-e3', 'ls-add', 'result', 'ls-write', 'score'),
edge('ls-e4', 'ls-write', 'exec', 'ls-play', 'exec'),
edge('ls-e5', 'ls-key', 'exec', 'ls-load', 'exec'),
],
});
export async function loadScene(t) {
const {
check,
sleep,
json,
evaluate,
mutate,
click,
doubleClick,
clickText,
clickToolbar,
press,
play,
stop,
shot,
has,
showTab,
} = t;
const playInfo = () => json('__gm.playInfo()');
const playFrom = () => json('__gm.playFrom()');
/**
* The start-scene switch sits in the Project settings dialog since the top bar became icons
* (#183): menu button → "Project settings…" → the switch → the mode → Apply.
*/
const setPlayFrom = async (mode) => {
await clickToolbar('menu');
await clickText('Project settings…');
const at = await json('__gm.projectSettingsPlayFromAt()');
await click(at.x, at.y);
await clickText(mode);
const chosen = await json('__gm.projectSettings()');
await clickText('Apply');
return chosen;
};
const stats = () => json('__gm.sound.stats()');
const waitFor = async (read, done, tries = 25) => {
let value = await read();
for (let i = 0; i < tries && !done(value); i++) {
await sleep(150);
value = await read();
}
return value;
};
await press('Escape');
await evaluate('__gm.state.select(null)');
await showTab('scene');
const firstId = await json('__gm.state.project.activeSceneId');
const firstName = await json('__gm.state.activeScene.name');
// A sound to have something playing across the swap; the seed put the files on the server.
const stored = await evaluate(`fetch('/game-maker/api/index.php?r=sounds').then((r) => r.json())`);
const file = stored[0]?.file ?? '';
// 35a. A second scene with a box of its own, a Variables asset, and the behavior that counts and
// loads. Everything lives in the new scene, so nothing later in the suite sees any of it.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
${MAKE_OBJECT}
const second = structuredClone(scene);
second.id = 'ls-scene';
second.name = 'Level 2';
second.objects = [
make('ls-box', 'crate', '', 0, 'Second box'),
make('ls-brain', 'ls-logic', 'ls-box', 0, 'Loader'),
];
const logic = structuredClone(p.assets.find((a) => a.id === 'mover'));
Object.assign(logic, {
id: 'ls-logic',
name: 'Loader',
props: { enabled: true, runInEditor: false },
variables: [],
graph: ${JSON.stringify(graphFor(firstId, 'ls-sound'))},
});
p.assets.push(logic, second, {
id: 'ls-vars',
type: 'variables',
name: 'Run',
props: { score: 0 },
variables: ${JSON.stringify([variable('score', 'number', 0)])},
}, {
id: 'ls-sound',
type: 'sound',
name: 'Hum',
props: { volume: 1, loop: true },
file: ${JSON.stringify(file)},
});`);
// 35b. Without a start scene the switch says the active scene – the two questions have the same
// answer until someone answers them differently.
check(
'with no start scene named, Play starts from the active one',
(await json('__gm.state.project.startSceneId')) === '' &&
(await playFrom()).mode === 'start' &&
(await playFrom()).scene === firstName,
JSON.stringify(await playFrom()),
);
// 35c. Naming one: right-click the scene in the assets panel. By this point in the suite the
// project holds every kind of asset, so the new scene is the last row of a list far longer
// than the panel – selecting it brings it into view, which is what makes the click land.
await evaluate("__gm.state.select({ kind: 'asset', assetId: 'ls-scene' })");
check(
'selecting an asset scrolls its row into the panel',
await has('Level 2'),
JSON.stringify({ row: await json('__gm.assetRow("ls-scene")'), panel: await json('__gm.assetsRect()') }),
);
await clickText('Level 2', 'right');
await clickText('Set as start scene');
check(
'the scene’s context menu names it the start scene, and the switch follows',
(await json('__gm.state.project.startSceneId')) === 'ls-scene' && (await playFrom()).scene === 'Level 2',
JSON.stringify(await playFrom()),
);
check(
'the assets panel marks the start scene apart from the active one',
// Under the Scenes heading the row no longer repeats its type – the marks are all it says.
await has('▶'),
JSON.stringify(
await json('__gm.state.project.assets.filter((a) => a.type === "scene").map((a) => a.name)'),
),
);
await shot('35-start-scene');
// 35d. Play starts there rather than in the scene the editor is showing.
await play();
const started = await playInfo();
check(
'Play runs the start scene while the editor still shows the active one',
started.playing === true &&
started.sceneId === 'ls-scene' &&
(await json('__gm.state.project.activeSceneId')) === firstId,
JSON.stringify(started),
);
// 35d². Switching the scene the editor shows keeps working while the run is on. Since the top
// bar lost its scene selector (#183) the Assets panel is the one way there, and it was
// never blocked in play mode; the run itself is untouched by it.
const sceneRow = await json('__gm.assetRow("ls-scene")');
await doubleClick(sceneRow.x, sceneRow.y);
const playing = await playInfo();
check(
'a scene opened from the Assets panel moves the editor in play mode, and the run runs on',
(await json('__gm.state.project.activeSceneId')) === 'ls-scene' &&
playing.playing === true &&
playing.sceneId === 'ls-scene',
JSON.stringify({ sceneRow, playing }),
);
// 35e. The swap itself. The score was counted in the old scene; a fresh run would show 0.
const before = await waitFor(
() => json('__gm.world()'),
(w) => (w?.shared?.variables?.['ls-vars']?.score ?? 0) === 1,
);
const humming = await waitFor(stats, (s) => s.playing > 0);
await press('g');
const after = await waitFor(
() => json('__gm.playInfo()'),
(info) => info.sceneId === firstId,
);
const world = await json('__gm.world()');
check(
'Load scene swaps the running scene without ending the run',
after.playing === true && after.sceneId === firstId,
JSON.stringify({ before: before.shared?.variables, after }),
);
check(
'the objects of the old scene are gone and the new scene is built',
!world.entities.some((e) => e.objectId === 'ls-box') && world.entities.length > 0,
JSON.stringify(world.entities.map((e) => e.name)),
);
check(
'what belongs to the run carries over: the shared score survives the swap',
world.shared?.variables?.['ls-vars']?.score === 1,
JSON.stringify(world.shared?.variables),
);
check(
'the sounds of the old scene end with the behaviors that were playing them',
humming.playing >= 1 && (await stats()).playing === 0,
JSON.stringify({ humming, now: await stats() }),
);
await stop();
// 35f. The switch is the way to run what you are working on instead – and it is remembered
// between runs, because it says how you work rather than what the game does.
const chosen = await setPlayFrom('Active scene');
await play();
const fromActive = await playInfo();
check(
'switched to "active scene", Play runs the scene the editor shows',
fromActive.sceneId === firstId &&
(await playFrom()).mode === 'active' &&
// The dialog held the new mode before Apply, and Apply left the start scene alone.
chosen.playFrom === 'active' &&
(await json('__gm.state.project.startSceneId')) === 'ls-scene',
JSON.stringify({ fromActive, chosen, switch: await playFrom() }),
);
await stop();
await setPlayFrom('Start scene');
check(
'the switch goes back to the start scene',
(await playFrom()).mode === 'start' && (await playFrom()).scene === 'Level 2',
JSON.stringify(await playFrom()),
);
// 35g. Everything this step made goes again, the start scene included.
await mutate(`
const p = __gm.state.project;
p.startSceneId = '';
const gone = new Set(['ls-logic', 'ls-scene', 'ls-vars', 'ls-sound']);
p.assets = p.assets.filter((a) => !gone.has(a.id));
__gm.state.select(null);`);
check(
'the step leaves nothing of its own behind',
(await json('__gm.state.project.assets.filter((a) => a.id.startsWith("ls-")).length')) === 0 &&
(await json('__gm.state.project.startSceneId')) === '' &&
(await stats()).playing === 0,
JSON.stringify(await json('__gm.state.project.assets.map((a) => a.id)')),
);
}