Files
feuerwerk/tools/smoke-jumprun.mjs
T
rootandClaude Opus 5 76aa441dce Enabled switch, text assets, mouse input and the jump-and-run example game
Engine additions that the example game needed:
- `enabled` as a shared property of every placeable asset (default on, an
  override on the scene object). A switched-off object is not drawn, does not
  collide and its behavior does not run - and neither does anything parented
  below it. Disabled objects are now hidden in the scene view as well, so the
  editor shows what the game shows; they stay in the hierarchy panel, which is
  where they are selected and switched back on.
- Asset type `text` (text, fontSize, color, align), rendered in scene and game.
- Pointer input in the game view: `On click`, `Mouse position`, `Mouse button`,
  `Is hovered`, hit-tested against colliders, else sprite/text footprints.
- New nodes: Set/Is enabled, Set text, Is asset, Name of, Equals, To text,
  Join text, Floor/Ceil/Round, Modulo, Random, Time, Restart game.

Tooling: shared CDP harness (tools/harness.mjs), central project registry with
a guard that keeps destructive scripts off the playground (tools/projects.mjs),
plus smoke suites for the demo scene and the jump-and-run game.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 20:52:10 +02:00

126 lines
8.0 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.
#!/usr/bin/env node
/**
* Plays the example game (tools/seed-jumprun.mjs) in a headless browser: title screen, start
* button, running, jumping, collecting a coin, the win screen, restart and the lose screen.
* It is the proof that the engine features this game needs actually work together.
*
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
* Screenshots land in /tmp/gm-jr-*.png.
*/
import { runChecks } from './harness.mjs';
import { PROJECTS, cliArgs } from './projects.mjs';
const { projectId, baseUrl } = cliArgs('smoke-jumprun.mjs', PROJECTS.jumpRun);
await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (t) => {
const { cdp, consoleLog, check, sleep, evaluate, shot, click, clickText, keyPress } = t;
const world = async () => JSON.parse(await evaluate('JSON.stringify(__gm.world())'));
const entity = async (objectId) => (await world()).entities.find((e) => e.objectId === objectId);
/** Variables of the "Game" behavior instance, live from the running world. */
const gameVars = async () => (await world()).entities.find((e) => e.objectId === 'ogame').props;
const holdKey = async (key, code, keyCode, ms) => {
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key, code, windowsVirtualKeyCode: keyCode });
await sleep(ms);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key, code, windowsVirtualKeyCode: keyCode });
};
/** Clicks an object where the *player* sees it (through the camera), not in the scene view. */
const clickObject = async (objectId) => {
const p = JSON.parse(await evaluate(`JSON.stringify(__gm.gameScreen(${JSON.stringify(objectId)}))`));
if (!p) throw new Error(`object ${objectId} is not on screen in the game view`);
await click(p.x, p.y);
};
/** Edits the play copy (never the saved project) and pushes it into the running world. */
const setVariable = async (objectId, key, value) => {
await evaluate(`(() => { const o = __gm.state.activeScene.objects.find((x) => x.id === ${JSON.stringify(objectId)}); o.overrides[${JSON.stringify(key)}] = ${JSON.stringify(value)}; __gm.state.preview(); })()`);
await sleep(150);
};
await cdp.send('Page.navigate', { url: `${baseUrl}?project=${projectId}` });
await sleep(4000);
check('the game project loads without exceptions', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).join(' | '));
// 1. Nothing was dropped by the loader: every node and edge of the four behaviors survived.
const graphs = JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.filter((a) => a.type === 'behavior').map((a) => ({ id: a.id, n: a.graph.nodes.length, e: a.graph.edges.length })))`));
const expected = { player: [34, 37], game: [38, 43], 'start-button': [9, 10], 'restart-button': [2, 1] };
check('all behavior graphs load complete (no unknown node types, no dropped edges)',
graphs.length === 4 && graphs.every((g) => expected[g.id] && g.n === expected[g.id][0] && g.e === expected[g.id][1]), JSON.stringify(graphs));
const objectCount = JSON.parse(await evaluate('JSON.stringify(__gm.state.activeScene.objects.length)'));
check('the scene holds the seeded objects', objectCount === 67, `${objectCount} objects`);
await shot('01-editor');
// 2. Play: the Game behavior shows the title screen and switches the level off.
await clickText('▶ Play');
await sleep(600);
check('play opens the Game tab', (await evaluate('__gm.tab()')) === 'game');
check('the title screen is up', Boolean(await evaluate('Boolean(__gm.locate("JUMP & RUN"))')));
const level = await entity('L');
const title = await entity('T');
check('the Game behavior switched the level off and the title on', level.props.enabled === false && title.props.enabled === true, JSON.stringify({ level: level.props.enabled, title: title.props.enabled }));
await shot('02-title');
// 3. The Start button is a click target in the running game (On click + hit test).
await clickObject('T-title-button');
await sleep(400);
check('clicking Start hides the title and shows the level', (await entity('L')).props.enabled === true && (await entity('T')).props.enabled === false);
const vars = await gameVars();
check('the run starts with a full clock and no coins', vars.running === true && vars.coins === 0 && vars.timeLeft > 118, JSON.stringify(vars));
await shot('03-running');
// 4. Gravity: the player rests on the ground (its 40-high box on the ground's top edge, y = 150).
const grounded = await entity('L-player');
check('gravity dropped the player onto the ground (top edge at y = 150)', Math.abs(grounded.worldY - 130) < 1.5, `world y ${grounded.worldY.toFixed(2)}`);
// 5. Jumping: Space lifts the player (y gets smaller) and gravity brings it back down.
await keyPress(' ', 'Space', { windowsVirtualKeyCode: 32 });
await sleep(200);
const airborne = await entity('L-player');
check('Space makes the player jump', airborne.worldY < grounded.worldY - 25, `y ${grounded.worldY.toFixed(1)} → ${airborne.worldY.toFixed(1)}`);
await sleep(1200);
const landed = await entity('L-player');
check('the player falls back onto the ground', Math.abs(landed.worldY - 130) < 1.5, `y ${landed.worldY.toFixed(2)}`);
await shot('04-jumped');
// 6. Walking and collecting: running into a coin removes it and counts up.
const coins = async () => (await world()).entities.filter((e) => e.name === 'Coin').length;
const coinsBefore = await coins();
await holdKey('ArrowRight', 'ArrowRight', 39, 1500);
const walked = await entity('L-player');
check('arrow keys walk the player', walked.worldX > landed.worldX + 40, `x ${landed.worldX.toFixed(1)} → ${walked.worldX.toFixed(1)}`);
const afterCoin = await gameVars();
const coinsAfter = await coins();
check('running into a coin collects it', afterCoin.coins >= 1 && coinsAfter < coinsBefore, JSON.stringify({ coins: afterCoin.coins, before: coinsBefore, after: coinsAfter }));
const hud = await evaluate('Boolean(__gm.locate("Coins 1/10"))');
check('the HUD shows the counter', Boolean(hud));
check('the clock is counting down', afterCoin.timeLeft < 118, `timeLeft ${afterCoin.timeLeft}`);
await shot('05-collected');
// 7. Winning: with all ten coins the Game behavior swaps the level for the win screen.
await setVariable('ogame', 'coins', 10);
await sleep(300);
check('collecting everything wins the game', Boolean(await evaluate('Boolean(__gm.locate("YOU WIN!"))')) && (await entity('W')).props.enabled === true && (await entity('L')).props.enabled === false);
await shot('06-won');
// 8. Restart game: back to a fresh run at the title screen, coins and clock reset.
await clickObject('W-win-button');
await sleep(1200);
check('Play again restarts the scene at the title screen', Boolean(await evaluate('Boolean(__gm.locate("JUMP & RUN"))')) && (await evaluate('__gm.state.mode')) === 'play');
const restartedCoins = (await world()).entities.filter((e) => e.name === 'Coin').length;
check('the restarted run has all ten coins back', restartedCoins === 10, `${restartedCoins} coins`);
// 9. Losing: start again, run the clock out.
await clickObject('T-title-button');
await sleep(300);
await setVariable('ogame', 'timeLeft', 0.2);
await sleep(600);
check('the clock running out loses the game', Boolean(await evaluate('Boolean(__gm.locate("TIME UP"))')) && (await entity('B')).props.enabled === true);
await shot('07-lost');
// 10. Stop throws the run away – the saved project is untouched.
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
await sleep(500);
const saved = JSON.parse(await evaluate('JSON.stringify({ objects: __gm.state.activeScene.objects.length, mode: __gm.state.mode, tab: __gm.tab() })'));
check('stop restores the edited scene', saved.objects === 67 && saved.mode === 'edit' && saved.tab === 'scene', JSON.stringify(saved));
check('no exceptions during the run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | '));
});