The editor gets a name of its own that fits beside Brandmauer, Glutspeicher and Feuerwehr. This is stage 1, 2 and 4 of #422: the name, the URL and the stored state in the browser. Repository, CI runners, containers and the GAME_MAKER_* variables follow in stage 3. - All paths come from SITE_PATH in src/core/site.ts: the API endpoint, the data folders, the play page and the base in vite.config.ts. A later move is one line instead of fifteen. - The window title, the sign-in screen, the playground and the README say Feuerwerk. The package, __feuerwerkPlay, the take worklet and the writing app in Ogg and Matroska follow. - The session cookie is now feuerwerk_session, so everyone signs in once more. - localStorage keys are now feuerwerk.*. moveOldStoredKeys copies the old game-maker.* keys over on the first start, so settings, panel sizes and save games survive. - tools/ci/serve.sh serves the build under /feuerwerk/. The variable GAME_MAKER_BASE_URL keeps its name until stage 3. Outside the repository, nockal.com/feuerwerk/ is a second symlink to the same tree. nginx sends the bare /game-maker/ folder there with a 301. Everything else under /game-maker/ is still served directly, so shared play links and the API keep working. Finding: when nginx redirects a path without a trailing slash or with a query, the Brandmauer answers with its own 307 to the same URL plus a slash, placed after the query. A play link would lose its token that way, which is why only the bare folder is redirected. Tests: - Gate on melektaus-atx: CHECK EXIT 0, 307 files, 4756 tests (4 new in site.test.ts). - Counter-check: when the migration misses the old prefix, 3 of the 4 new tests fail. - e2e 60 under /feuerwerk/: ALL PASSED, 20 checks. - From outside: /feuerwerk/ 200, /game-maker/ 301, an old play link 200. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
380 lines
16 KiB
JavaScript
380 lines
16 KiB
JavaScript
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.
|
||
*
|
||
* A second Variables asset scoped to the scene is the other half of that witness (#164): the two
|
||
* count the same way in the same graph, and only one of them is still counting after the swap.
|
||
*/
|
||
|
||
const SOURCE = { variablesAsset: 'ls-vars' };
|
||
const PER_SCENE = { variablesAsset: 'ls-level' };
|
||
|
||
/**
|
||
* `On start → Variables{set score = score + 1} → the same for the per-scene count`, 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-read2', 'Variables', 0, 640, PER_SCENE, [port('count', 'number')]),
|
||
node('ls-add2', 'Add', 260, 640, { b: 1 }),
|
||
node('ls-write2', 'Variables', 560, 640, PER_SCENE, [written('count', '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-write2', 'exec'),
|
||
edge('ls-e6', 'ls-read2', 'count', 'ls-add2', 'a'),
|
||
edge('ls-e7', 'ls-add2', 'result', 'ls-write2', 'count'),
|
||
edge('ls-e8', 'ls-write2', '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('/feuerwerk/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-level',
|
||
type: 'variables',
|
||
name: 'Level',
|
||
scope: 'scene',
|
||
props: { count: 0 },
|
||
variables: ${JSON.stringify([variable('count', '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()),
|
||
);
|
||
// Under the Scenes heading the row no longer repeats its type – the marks are all it says.
|
||
const marksOn = async (id) => {
|
||
const row = await json(`__gm.assetRow(${JSON.stringify(id)})`);
|
||
const on = async (name) =>
|
||
((await json(`__gm.locateIcons(${JSON.stringify(name)})`)) ?? []).some(
|
||
(hit) => row && Math.abs(hit.y - row.y) < 8,
|
||
);
|
||
return { start: await on('play'), current: await on('current') };
|
||
};
|
||
const startMarks = await marksOn('ls-scene');
|
||
const activeMarks = await marksOn(await json('__gm.state.project.activeSceneId'));
|
||
check(
|
||
'the assets panel marks the start scene apart from the active one',
|
||
startMarks.start && !startMarks.current && activeMarks.current && !activeMarks.start,
|
||
JSON.stringify({ startMarks, activeMarks }),
|
||
);
|
||
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),
|
||
);
|
||
// 35e². The other scope, counted by the same graph in the same frame (#164). The loader stayed
|
||
// behind with its scene, so nobody counts in the new one – and the value is the declared 0
|
||
// rather than the 1 the score right beside it still carries.
|
||
check(
|
||
'what belongs to the scene starts over: the per-scene count does not carry',
|
||
(before.shared?.variables?.['ls-level']?.count ?? 0) === 1 &&
|
||
world.shared?.variables?.['ls-level']?.count === 0 &&
|
||
world.shared?.variables?.['ls-vars']?.score === 1,
|
||
JSON.stringify({ before: before.shared?.variables, after: 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 – saved with the project
|
||
// (#226), so it holds between runs.
|
||
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.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()),
|
||
);
|
||
|
||
// 35f². Keep on scene load (#165). The second box gets the switch, by hand in the inspector, and
|
||
// then travels through the swap with its loader below it. The loader is the witness twice
|
||
// over: it counts on start, so a behavior that started again would say 2, and it is the one
|
||
// humming, so a behavior that was destroyed would have gone silent.
|
||
await evaluate("__gm.state.select({ kind: 'asset', assetId: 'ls-scene' })");
|
||
const secondRow = await json('__gm.assetRow("ls-scene")');
|
||
await doubleClick(secondRow.x, secondRow.y);
|
||
const boxRow = await json("__gm.hierarchyRow('ls-box')");
|
||
await click(boxRow.x, boxRow.y);
|
||
const keepRow = await json("__gm.inspectorField('Keep on scene load')");
|
||
if (keepRow) await click(keepRow.x, keepRow.y);
|
||
const keptBox = await json(
|
||
"__gm.state.project.assets.find((a) => a.id === 'ls-scene').objects.find((o) => o.id === 'ls-box')",
|
||
);
|
||
check(
|
||
'a top-level object has the switch "Keep on scene load", and a click writes it as an override',
|
||
Boolean(keepRow) && keptBox.overrides.keepOnSceneLoad === true,
|
||
JSON.stringify({ keepRow, overrides: keptBox.overrides }),
|
||
);
|
||
await evaluate(
|
||
"__gm.state.select({ kind: 'object', holderId: 'ls-scene', objectIds: ['ls-brain'], objectId: 'ls-brain' })",
|
||
);
|
||
check(
|
||
'an object below another one has no such switch, only a top-level one travels',
|
||
(await json("__gm.inspectorField('Keep on scene load')")) === null &&
|
||
(await json("__gm.inspectorField('Enabled')")) !== null,
|
||
JSON.stringify(await json("__gm.inspectorField('Keep on scene load')")),
|
||
);
|
||
|
||
await play();
|
||
await waitFor(
|
||
() => json('__gm.world()'),
|
||
(w) => (w?.shared?.variables?.['ls-vars']?.score ?? 0) === 1,
|
||
);
|
||
await waitFor(stats, (s) => s.playing > 0);
|
||
await press('g');
|
||
const swapped = await waitFor(
|
||
() => json('__gm.playInfo()'),
|
||
(info) => info.sceneId === firstId,
|
||
);
|
||
const keptWorld = await json('__gm.world()');
|
||
const ofBox = (w) => w.entities.filter((e) => e.objectId === 'ls-box' || e.objectId === 'ls-brain');
|
||
check(
|
||
'a kept object is in the next scene with what hangs below it, among that scene’s own objects',
|
||
swapped.sceneId === firstId &&
|
||
ofBox(keptWorld).length === 2 &&
|
||
keptWorld.entities.find((e) => e.objectId === 'ls-brain')?.parent === 'ls-box' &&
|
||
keptWorld.entities.length > 2,
|
||
JSON.stringify({ swapped: swapped.sceneId, kept: ofBox(keptWorld), all: keptWorld.entities.length }),
|
||
);
|
||
check(
|
||
'its behavior runs on rather than starting again, and its sound plays on',
|
||
keptWorld.shared?.variables?.['ls-vars']?.score === 1 && (await stats()).playing >= 1,
|
||
JSON.stringify({ shared: keptWorld.shared?.variables, sound: await stats() }),
|
||
);
|
||
await shot('35-kept-object');
|
||
// Loading the scene it is in by now: the box is the scene's own by its id, and stays the one box.
|
||
const timeBefore = (await playInfo()).time;
|
||
await press('g');
|
||
await waitFor(playInfo, (info) => info.time < timeBefore);
|
||
const again = await json('__gm.world()');
|
||
check(
|
||
'loading the scene again keeps the one object, and its behavior still has not started twice',
|
||
ofBox(again).length === 2 && again.shared?.variables?.['ls-vars']?.score === 1,
|
||
JSON.stringify({ kept: ofBox(again), shared: again.shared?.variables }),
|
||
);
|
||
await stop();
|
||
const storedIn = (sceneId) =>
|
||
json(
|
||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(sceneId)}).objects.map((o) => o.id)`,
|
||
);
|
||
check(
|
||
'Stop leaves the stored scenes as they were: the box is back in its own and in no other',
|
||
(await storedIn('ls-scene')).join() === 'ls-box,ls-brain' &&
|
||
!(await storedIn(firstId)).includes('ls-box'),
|
||
JSON.stringify({ second: await storedIn('ls-scene'), first: await storedIn(firstId) }),
|
||
);
|
||
await evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(firstId)} })`);
|
||
const firstRow = await json(`__gm.assetRow(${JSON.stringify(firstId)})`);
|
||
await doubleClick(firstRow.x, firstRow.y);
|
||
await evaluate('__gm.state.select(null)');
|
||
|
||
// 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-level', '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)')),
|
||
);
|
||
}
|