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>
This commit is contained in:
1 parent
4900d7bbf6
commit
76aa441dce
33 files changed
+1905
-301
No files matched your search
+63
-141
@@ -1,134 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Headless end-to-end smoke test driven over the Chrome DevTools Protocol.
|
||||
* Usage: PATH=/opt/node22/bin:$PATH node tools/e2e.mjs <projectId> [baseUrl]
|
||||
* Headless end-to-end test driven over the Chrome DevTools Protocol.
|
||||
* Usage: PATH=/opt/node22/bin:$PATH node tools/e2e.mjs [projectId] [baseUrl]
|
||||
* Writes screenshots to /tmp/gm-e2e-*.png and prints PASS/FAIL checks. No dependencies.
|
||||
*
|
||||
* Widgets are located through `window.__gm.locate(text)` (installed by the editor), so
|
||||
* the test does not depend on pixel coordinates.
|
||||
*
|
||||
* Expects the fixture scene from tools/seed-e2e.mjs (not the demo scene) and mutates it:
|
||||
* node tools/seed-e2e.mjs <id> && node tools/e2e.mjs <id> && node tools/seed-demo.mjs <id>
|
||||
* Runs against the dedicated fixture project (the demo project is refused) and mutates it:
|
||||
* node tools/seed-e2e.mjs && node tools/e2e.mjs
|
||||
* The scene it expects is produced by tools/seed-e2e.mjs.
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { runChecks } from './harness.mjs';
|
||||
import { PROJECTS, cliArgs } from './projects.mjs';
|
||||
|
||||
const projectId = process.argv[2];
|
||||
const baseUrl = process.argv[3] ?? 'https://nockal.com/game-maker/';
|
||||
if (!projectId) {
|
||||
console.error('usage: node tools/e2e.mjs <projectId> [baseUrl]');
|
||||
process.exit(2);
|
||||
}
|
||||
const PORT = 9333;
|
||||
const W = 1280;
|
||||
const H = 800;
|
||||
const { projectId, baseUrl } = cliArgs('e2e.mjs', PROJECTS.fixture);
|
||||
|
||||
const chrome = spawn('chromium', [
|
||||
'--headless', '--no-sandbox', '--disable-gpu', '--use-gl=swiftshader', '--enable-unsafe-swiftshader',
|
||||
`--remote-debugging-port=${PORT}`, `--window-size=${W},${H}`, '--hide-scrollbars', 'about:blank',
|
||||
], { stdio: 'ignore' });
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function waitForChrome() {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${PORT}/json/version`);
|
||||
if (res.ok) return res.json();
|
||||
} catch {}
|
||||
await sleep(200);
|
||||
}
|
||||
throw new Error('chromium did not start');
|
||||
}
|
||||
|
||||
class CDP {
|
||||
constructor(ws) {
|
||||
this.ws = ws;
|
||||
this.id = 0;
|
||||
this.pending = new Map();
|
||||
this.listeners = [];
|
||||
ws.addEventListener('message', (ev) => {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.id && this.pending.has(msg.id)) {
|
||||
const { resolve, reject } = this.pending.get(msg.id);
|
||||
this.pending.delete(msg.id);
|
||||
msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result);
|
||||
} else if (msg.method) {
|
||||
for (const l of this.listeners) l(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
send(method, params = {}) {
|
||||
const id = ++this.id;
|
||||
this.ws.send(JSON.stringify({ id, method, params }));
|
||||
return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
|
||||
}
|
||||
on(fn) { this.listeners.push(fn); }
|
||||
}
|
||||
|
||||
const consoleLog = [];
|
||||
let failures = 0;
|
||||
function check(name, ok, detail = '') {
|
||||
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' – ' + detail : ''}`);
|
||||
if (!ok) failures++;
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForChrome();
|
||||
const target = await (await fetch(`http://127.0.0.1:${PORT}/json/new?about:blank`, { method: 'PUT' })).json();
|
||||
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((r) => ws.addEventListener('open', r));
|
||||
const cdp = new CDP(ws);
|
||||
await cdp.send('Page.enable');
|
||||
await cdp.send('Runtime.enable');
|
||||
await cdp.send('Emulation.setDeviceMetricsOverride', { width: W, height: H, deviceScaleFactor: 1, mobile: false });
|
||||
cdp.on((msg) => {
|
||||
if (msg.method === 'Runtime.consoleAPICalled') consoleLog.push(msg.params.args.map((a) => a.value ?? a.description).join(' '));
|
||||
if (msg.method === 'Runtime.exceptionThrown') consoleLog.push('EXCEPTION ' + (msg.params.exceptionDetails.exception?.description ?? msg.params.exceptionDetails.text));
|
||||
});
|
||||
|
||||
const evaluate = async (expression) => (await cdp.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })).result.value;
|
||||
const locate = async (text, last = false) => {
|
||||
const p = await evaluate(`window.__gm && window.__gm.locate(${JSON.stringify(text)}, ${last})`);
|
||||
if (!p) throw new Error(`widget with text "${text}" not found`);
|
||||
return p;
|
||||
};
|
||||
const shot = async (name) => {
|
||||
const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' });
|
||||
writeFileSync(`/tmp/gm-e2e-${name}.png`, Buffer.from(data, 'base64'));
|
||||
};
|
||||
const mouse = (type, x, y, button = 'left', extra = {}) =>
|
||||
cdp.send('Input.dispatchMouseEvent', { type, x, y, button, clickCount: type === 'mousePressed' || type === 'mouseReleased' ? 1 : 0, ...extra });
|
||||
const click = async (x, y, button = 'left') => {
|
||||
await mouse('mouseMoved', x, y);
|
||||
await mouse('mousePressed', x, y, button);
|
||||
await mouse('mouseReleased', x, y, button);
|
||||
await sleep(150);
|
||||
};
|
||||
const clickText = async (text, button = 'left') => {
|
||||
const p = await locate(text);
|
||||
await click(p.x, p.y, button);
|
||||
};
|
||||
const drag = async (x1, y1, x2, y2, steps = 12) => {
|
||||
await mouse('mouseMoved', x1, y1);
|
||||
await mouse('mousePressed', x1, y1);
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await mouse('mouseMoved', x1 + ((x2 - x1) * i) / steps, y1 + ((y2 - y1) * i) / steps, 'left', { buttons: 1 });
|
||||
await sleep(16);
|
||||
}
|
||||
await mouse('mouseReleased', x2, y2);
|
||||
await sleep(150);
|
||||
};
|
||||
const keyPress = async (key, code, opts = {}) => {
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key, code, ...opts });
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key, code, ...opts });
|
||||
};
|
||||
const typeText = async (text) => {
|
||||
for (const ch of text) await cdp.send('Input.insertText', { text: ch });
|
||||
};
|
||||
const api = async (route) => (await fetch(`${baseUrl}api/index.php?r=${route}`)).json();
|
||||
const sceneOf = (doc) => doc.project.assets.find((a) => a.type === 'scene');
|
||||
const stateJson = () => evaluate('JSON.stringify({ selection: __gm.state.selection, mode: __gm.state.mode, save: __gm.state.saveStatus, notice: __gm.state.notice })');
|
||||
await runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async (t) => {
|
||||
const {
|
||||
cdp, consoleLog, check, sleep,
|
||||
evaluate, locate, shot, mouse, click, clickText, drag, keyPress, typeText, api, sceneOf, stateJson,
|
||||
} = t;
|
||||
|
||||
await cdp.send('Page.navigate', { url: `${baseUrl}?project=${projectId}` });
|
||||
await sleep(4000);
|
||||
@@ -826,20 +715,53 @@ try {
|
||||
await sleep(200);
|
||||
check('Apply to container writes the override into the template and the copy', (await templateA()).overrides.width === 77 && JSON.parse(await objectJson(secondCrate.id)).overrides.width === 77);
|
||||
|
||||
check('no exceptions during run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | '));
|
||||
ws.close();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
failures++;
|
||||
} finally {
|
||||
// Wait for chromium to actually exit (a SwiftShader GPU process kept rendering at full CPU
|
||||
// once when only SIGTERM was sent); escalate to SIGKILL if it does not stop in time.
|
||||
await new Promise((resolve) => {
|
||||
if (chrome.exitCode !== null) return resolve();
|
||||
const timer = setTimeout(() => { chrome.kill('SIGKILL'); }, 5000);
|
||||
chrome.once('exit', () => { clearTimeout(timer); resolve(); });
|
||||
chrome.kill('SIGTERM');
|
||||
});
|
||||
}
|
||||
if (consoleLog.length) console.log('--- console ---\n' + consoleLog.slice(0, 20).join('\n'));
|
||||
process.exit(failures ? 1 : 0);
|
||||
// 17. Text assets: created through the + menu, placed, and rendered as real text in the scene.
|
||||
await clickText('+');
|
||||
await sleep(150);
|
||||
await clickText('Text');
|
||||
await sleep(200);
|
||||
const textAssetId = await evaluate(`(__gm.state.project.assets.find(a => a.type === 'text') || {}).id`);
|
||||
check('+ menu creates a text asset', Boolean(textAssetId));
|
||||
await evaluate(`(() => {
|
||||
const id = ${JSON.stringify(textAssetId)};
|
||||
__gm.state.project.assets.find(a => a.id === id).props.text = 'Hello world';
|
||||
// On the camera so the game view really renders it (outside the frame it would be culled).
|
||||
const cam = __gm.state.activeScene.objects.find(o => o.id === 'o3').position;
|
||||
__gm.state.activeScene.objects.push({ id: 'otext', assetId: id, name: '', position: { x: cam.x, y: cam.y, z: 5 }, overrides: {}, parentId: '', templateId: '' });
|
||||
__gm.state.select({ kind: 'object', sceneId: 'scene1', objectIds: ['otext'], objectId: 'otext' });
|
||||
__gm.state.preview();
|
||||
})()`);
|
||||
await sleep(300);
|
||||
check('a placed text object draws its content in the scene', Boolean(await evaluate(`Boolean(__gm.locate('Hello world'))`)));
|
||||
await shot('29-text-object');
|
||||
|
||||
// 17a. The enabled switch: every object has it, switching it off is a normal override, and a
|
||||
// switched-off object disappears from the scene view too (it stays in the hierarchy).
|
||||
const enabledRow = await evaluate(`__gm.locate('Enabled')`);
|
||||
check('the inspector shows the Enabled switch for a scene object', Boolean(enabledRow));
|
||||
const visibleBefore = (await evaluate('__gm.viewportInfo()')).visibleItems;
|
||||
// The checkbox sits right of the 64 px label column; locate() gives the label's text centre.
|
||||
await click(enabledRow.x + 55, enabledRow.y);
|
||||
await sleep(250);
|
||||
const textObject = JSON.parse(await objectJson('otext'));
|
||||
check('clicking it writes an override instead of changing the asset', textObject.overrides.enabled === false, JSON.stringify(textObject.overrides));
|
||||
check('a switched-off object is not drawn in the scene view', (await evaluate(`JSON.stringify(__gm.drawOrder().includes('otext'))`)) === 'false');
|
||||
const visibleAfter = (await evaluate('__gm.viewportInfo()')).visibleItems;
|
||||
check('the scene view renders one item less', visibleAfter === visibleBefore - 1, `${visibleBefore} → ${visibleAfter}`);
|
||||
check('it stays selectable in the hierarchy, so its switch stays reachable', Boolean(await evaluate(`__gm.locate('Enabled')`)));
|
||||
await shot('30-disabled');
|
||||
|
||||
// 17b. In the running game a switched-off object does not exist: it is not rendered, and
|
||||
// switching it back on brings it back.
|
||||
await clickText('▶ Play');
|
||||
await sleep(500);
|
||||
const offItems = JSON.parse(await evaluate('JSON.stringify(__gm.gameInfo().visibleItems)'));
|
||||
await evaluate(`(() => { __gm.state.activeScene.objects.find(o => o.id === 'otext').overrides.enabled = true; __gm.state.preview(); })()`);
|
||||
await sleep(400);
|
||||
const onItems = JSON.parse(await evaluate('JSON.stringify(__gm.gameInfo().visibleItems)'));
|
||||
check('the game renders one item more once the object is switched on', onItems === offItems + 1, `${offItems} → ${onItems}`);
|
||||
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
|
||||
await sleep(300);
|
||||
check('stop leaves the play-mode switch behind', JSON.parse(await objectJson('otext')).overrides.enabled === false);
|
||||
|
||||
});
|
||||
Reference in new issue
Block a user