- SceneObject.editorOffset (x/y): shifts only where an object is drawn in the Scene tab, cascades to children, undo-able with a reset action. Never written into container templates. - On click without an owner now fires for any click inside the camera frame instead of hit-testing the (editor-only) behavior symbol. - seed-jumprun: coins are a "Coin" container (sprite + trigger), nested containers are expanded recursively, Destroy targets the coin's parent, and the clock uses a Ceil node instead of ToText. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
126 lines
8.0 KiB
JavaScript
126 lines
8.0 KiB
JavaScript
#!/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: [35, 38], 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 === 77, `${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 === 77 && 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(' | '));
|
||
});
|