The theme asks for Source Sans 3 at weight 500, but fonts.ts only
loaded 400, 600 and 700. The browser synthesised the 500 text. The
issue comment also asked for the symbols the font lacks to become SVG
icons. A scan of src/ against the font's cmap found 20 such characters
in 30 places.
- fonts.ts loads source-sans-3-latin-500-normal.woff2 as well.
- Menus draw their ticks and the "current" dot as icons. The dot is a
new MenuItem.current field and no longer part of the label text.
- Inspector action buttons (reset, apply, fit width/height) are
icons. The menu arrow behind them is the drawn Dropdown chevron.
- Kind marks come from a table in markIcons.ts. Picture kinds use an
icon, # and Aa stay text because the font has them.
- Scene marks in the asset panel, sound play/stop and the transition
arrows are icons too.
- Apply is an icon although the font has the arrow. It sits next to
the reset icon and would look different as a glyph.
- A new Icon widget draws on redraw like Button, so Node tests need no
DOMParser.
- Tests find icons through the SVG id. The new hook
__gm.locateIcons(id) and the harness helpers findIcon/clickIcon
replace locate() on the old characters.
Findings:
- Weight 700 is loaded but the theme never uses it. Left as is.
- The old scene-mark check in module 35 accepted any play glyph on
screen. It now checks the mark on the start scene's row.
- Module 44 needs the textures from module 37 and fails without it.
Tests:
- ./check.sh: 145 files, 2187 tests, exit 0 (before 144 / 2143).
- New tests/ui/fonts.test.ts: every theme weight is loaded, and every
character in src/ exists in every shipped face. The cmap reader
matches fontTools (231 characters).
- Icon tests cover the 19 new files. Each kind mark is either an
icon or text, never both.
- e2e 1-16,22,30,34,35,43,44: 586 green, 0 FAIL, then 44 stopped
for lack of module 37. e2e 1-16,37,44,47,48: 618 green.
e2e 1-14 on the final build: 454 green.
- Two new e2e checks: loaded weights in 14, a reset icon pixel in 04.
- Counter-probes all fail as expected: 500 removed, a play glyph put
back, the struct icon removed (one unit test each). In the browser,
500 removed and reset icon hidden fail exactly the two new checks.
- Editor-suite check sum 1312 (1310 measured by CI at 056a622, +2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
297 lines
12 KiB
JavaScript
297 lines
12 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('/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-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 – 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-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)')),
|
||
);
|
||
}
|