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);
|
||||
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Shared headless-Chromium harness for the check scripts (e2e.mjs, smoke-demo.mjs).
|
||||
*
|
||||
* Starts chromium with remote debugging, opens a CDP session and hands a set of helpers
|
||||
* to the body function. Widgets are located through `window.__gm.locate(text)` (installed
|
||||
* by the editor), so checks never depend on pixel coordinates.
|
||||
*
|
||||
* await runChecks({ projectId, baseUrl, port, shotPrefix }, async (t) => { ... });
|
||||
*
|
||||
* The harness always waits for chromium to actually exit (a SwiftShader GPU process kept
|
||||
* rendering at full CPU once when only SIGTERM was sent) and exits with code 1 on failures.
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
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); }
|
||||
}
|
||||
|
||||
async function waitForChrome(port) {
|
||||
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');
|
||||
}
|
||||
|
||||
export async function runChecks(options, body) {
|
||||
const { projectId, baseUrl, port = 9333, shotPrefix = 'gm-e2e', width = 1280, height = 800 } = options;
|
||||
const chrome = spawn('chromium', [
|
||||
'--headless', '--no-sandbox', '--disable-gpu', '--use-gl=swiftshader', '--enable-unsafe-swiftshader',
|
||||
`--remote-debugging-port=${port}`, `--window-size=${width},${height}`, '--hide-scrollbars', 'about:blank',
|
||||
], { stdio: 'ignore' });
|
||||
|
||||
const consoleLog = [];
|
||||
let failures = 0;
|
||||
function check(name, ok, detail = '') {
|
||||
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' – ' + detail : ''}`);
|
||||
if (!ok) failures++;
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForChrome(port);
|
||||
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, height, 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/${shotPrefix}-${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 body({
|
||||
cdp, ws, projectId, baseUrl, consoleLog, check, sleep,
|
||||
evaluate, locate, shot, mouse, click, clickText, drag, keyPress, typeText, api, sceneOf, stateJson,
|
||||
});
|
||||
ws.close();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
failures++;
|
||||
} finally {
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Which project each tool writes to, and the guard that keeps the checks away from the
|
||||
* playground.
|
||||
*
|
||||
* demo the project served at ?project=… in the browser. It is the user's playground;
|
||||
* only tools/seed-demo.mjs may overwrite it, and only when asked.
|
||||
* fixture rewritten by every e2e run. tools/e2e.mjs mutates it heavily.
|
||||
* demoCheck a throwaway copy of the demo scene for tools/smoke-demo.mjs.
|
||||
* jumpRun the example game (tools/seed-jumprun.mjs, checked by tools/smoke-jumprun.mjs).
|
||||
*
|
||||
* All ids exist on the server (created via POST ?r=projects); PUT does not create projects.
|
||||
*/
|
||||
export const PROJECTS = {
|
||||
demo: '649188c15414',
|
||||
fixture: '3caa9b6a0c92',
|
||||
demoCheck: 'aaf5e962ef9c',
|
||||
jumpRun: '3d271f3bb58b',
|
||||
};
|
||||
|
||||
export const DEFAULT_BASE_URL = 'https://nockal.com/game-maker/';
|
||||
|
||||
/**
|
||||
* Reads `[projectId] [baseUrl]` from argv, falling back to `defaultId`. Unless `allowDemo` is
|
||||
* set, targeting the demo project aborts: a destructive run must never hit the playground.
|
||||
*/
|
||||
export function cliArgs(script, defaultId, { allowDemo = false } = {}) {
|
||||
const projectId = process.argv[2] ?? defaultId;
|
||||
const baseUrl = process.argv[3] ?? DEFAULT_BASE_URL;
|
||||
if (!projectId) {
|
||||
console.error(`usage: node tools/${script} <projectId> [baseUrl]`);
|
||||
process.exit(2);
|
||||
}
|
||||
if (!allowDemo && projectId === PROJECTS.demo) {
|
||||
console.error(`refusing to run ${script} against the demo project (${PROJECTS.demo}) – it is the playground.`);
|
||||
process.exit(2);
|
||||
}
|
||||
return { projectId, baseUrl };
|
||||
}
|
||||
|
||||
/** Overwrites a project, keeping the name it already has on the server. */
|
||||
export async function writeProject(projectId, baseUrl, project) {
|
||||
const url = `${baseUrl}api/index.php?r=projects/${projectId}`;
|
||||
const current = await fetch(url);
|
||||
if (!current.ok) {
|
||||
console.error(`GET failed: ${current.status} – create the project first (POST ?r=projects)`);
|
||||
process.exit(1);
|
||||
}
|
||||
const doc = await current.json();
|
||||
const body = { revision: doc.revision, project: { ...project, id: projectId, name: doc.name ?? project.name } };
|
||||
const res = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const result = await res.json();
|
||||
if (!res.ok) {
|
||||
console.error('PUT failed', res.status, result);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`seeded ${projectId} (${body.project.name}) → revision ${result.revision}`);
|
||||
}
|
||||
+7
-21
@@ -2,14 +2,13 @@
|
||||
/**
|
||||
* Writes the demo project: a "Player" container (green character with the Mover behavior and a
|
||||
* collider) facing two "Enemy" container instances, plus a Spawner that drops more enemies.
|
||||
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-demo.mjs <projectId> [baseUrl]
|
||||
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-demo.mjs [projectId] [baseUrl]
|
||||
* Defaults to the demo project – the only script allowed to overwrite the playground. Pass
|
||||
* PROJECTS.demoCheck to seed the throwaway copy that tools/smoke-demo.mjs runs against.
|
||||
*/
|
||||
const projectId = process.argv[2];
|
||||
const baseUrl = process.argv[3] ?? 'https://nockal.com/game-maker/';
|
||||
if (!projectId) {
|
||||
console.error('usage: node tools/seed-demo.mjs <projectId> [baseUrl]');
|
||||
process.exit(2);
|
||||
}
|
||||
import { PROJECTS, cliArgs, writeProject } from './projects.mjs';
|
||||
|
||||
const { projectId, baseUrl } = cliArgs('seed-demo.mjs', PROJECTS.demo, { allowDemo: true });
|
||||
|
||||
/**
|
||||
* "Mover" behavior in five nodes: every frame, move the owner along the normalized arrow-key
|
||||
@@ -123,17 +122,4 @@ const project = {
|
||||
],
|
||||
};
|
||||
|
||||
const url = `${baseUrl}api/index.php?r=projects/${projectId}`;
|
||||
const current = await fetch(url);
|
||||
if (!current.ok) {
|
||||
console.error(`GET failed: ${current.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const { revision } = await current.json();
|
||||
const res = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ revision, project }) });
|
||||
const body = await res.json();
|
||||
if (!res.ok) {
|
||||
console.error('PUT failed', res.status, body);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`seeded ${projectId} → revision ${body.revision}`);
|
||||
await writeProject(projectId, baseUrl, project);
|
||||
+6
-22
@@ -5,15 +5,12 @@
|
||||
* from tools/seed-demo.mjs so the shipped demo can change without rewriting 100+ checks; the
|
||||
* fixture's objects are uniquely named and positioned exactly where the checks expect them.
|
||||
*
|
||||
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-e2e.mjs <projectId> [baseUrl]
|
||||
* Run tools/seed-demo.mjs afterwards to put the demo project back.
|
||||
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-e2e.mjs [projectId] [baseUrl]
|
||||
* Defaults to the dedicated fixture project; the demo project is refused.
|
||||
*/
|
||||
const projectId = process.argv[2];
|
||||
const baseUrl = process.argv[3] ?? 'https://nockal.com/game-maker/';
|
||||
if (!projectId) {
|
||||
console.error('usage: node tools/seed-e2e.mjs <projectId> [baseUrl]');
|
||||
process.exit(2);
|
||||
}
|
||||
import { PROJECTS, cliArgs, writeProject } from './projects.mjs';
|
||||
|
||||
const { projectId, baseUrl } = cliArgs('seed-e2e.mjs', PROJECTS.fixture);
|
||||
|
||||
/**
|
||||
* "Mover" behavior in five nodes: every frame, move the owner along the normalized arrow-key
|
||||
@@ -93,17 +90,4 @@ const project = {
|
||||
],
|
||||
};
|
||||
|
||||
const url = `${baseUrl}api/index.php?r=projects/${projectId}`;
|
||||
const current = await fetch(url);
|
||||
if (!current.ok) {
|
||||
console.error(`GET failed: ${current.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const { revision } = await current.json();
|
||||
const res = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ revision, project }) });
|
||||
const body = await res.json();
|
||||
if (!res.ok) {
|
||||
console.error('PUT failed', res.status, body);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`seeded ${projectId} → revision ${body.revision}`);
|
||||
await writeProject(projectId, baseUrl, project);
|
||||
@@ -0,0 +1,408 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Writes the example game: a small jump and run built entirely from assets, behaviors and the
|
||||
* node graph – no engine code is involved.
|
||||
*
|
||||
* Title screen (a container with a panel, texts and a clickable Start button)
|
||||
* Level (ground, platforms, walls, ten coins, the player and the HUD labels)
|
||||
* Win / Lose (containers shown when the coins are collected or the two minutes are up)
|
||||
*
|
||||
* Screens are containers that are switched on and off with the `enabled` property – the same
|
||||
* switch the inspector shows for every object. Everything else is nodes: gravity and jumping
|
||||
* on the player, timer, coin counter and screen flow on the "Game" behavior.
|
||||
*
|
||||
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-jumprun.mjs [projectId] [baseUrl]
|
||||
*/
|
||||
import { PROJECTS, cliArgs, writeProject } from './projects.mjs';
|
||||
|
||||
const { projectId, baseUrl } = cliArgs('seed-jumprun.mjs', PROJECTS.jumpRun);
|
||||
|
||||
// --- colours ---------------------------------------------------------------------------------
|
||||
|
||||
const SKY = 0x151821;
|
||||
const STONE = 0x3c4454;
|
||||
const GRASS = 0x4a7d3a;
|
||||
const PLAYER = 0x63c74d;
|
||||
const COIN = 0xf5c542;
|
||||
const PANEL = 0x11141c;
|
||||
const BUTTON = 0x63c74d;
|
||||
const WHITE = 0xf2f5f7;
|
||||
|
||||
// --- level geometry (the camera shows x −320…320, y −180…180; +y is down) ---------------------
|
||||
|
||||
/** A solid box: the sprite plus a collider child of the same size. */
|
||||
const solid = (id, x, y, w, h, color, name = '') => [
|
||||
{ id, assetId: 'box', name, position: { x, y, z: 0 }, overrides: { width: w, height: h, color }, parentId: '', templateId: '' },
|
||||
{ id: `${id}-c`, assetId: 'solid', name: '', position: { x: 0, y: 0, z: 0 }, overrides: { width: w, height: h }, parentId: id, templateId: '' },
|
||||
];
|
||||
|
||||
/** A coin: sprite plus a trigger collider, so it reports the touch without blocking. */
|
||||
const coin = (id, x, y) => [
|
||||
{ id, assetId: 'coin-sprite', name: 'Coin', position: { x, y, z: 1 }, overrides: {}, parentId: '', templateId: '' },
|
||||
{ id: `${id}-c`, assetId: 'trigger', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: id, templateId: '' },
|
||||
];
|
||||
|
||||
const text = (id, x, y, content, { size = 28, color = WHITE, align = 'center', name = '', parentId = '', z = 1 } = {}) => ({
|
||||
id,
|
||||
assetId: 'label',
|
||||
name,
|
||||
position: { x, y, z },
|
||||
overrides: { text: content, fontSize: size, color, align },
|
||||
parentId,
|
||||
templateId: '',
|
||||
});
|
||||
|
||||
const levelTemplate = [
|
||||
// Ground, side walls and six platforms.
|
||||
...solid('ground', 0, 165, 660, 30, GRASS),
|
||||
...solid('wall-l', -315, 0, 10, 360, STONE),
|
||||
...solid('wall-r', 315, 0, 10, 360, STONE),
|
||||
...solid('p1', -170, 90, 120, 16, STONE),
|
||||
...solid('p2', -30, 30, 140, 16, STONE),
|
||||
...solid('p3', 170, 60, 140, 16, STONE),
|
||||
...solid('p4', 60, -50, 120, 16, STONE),
|
||||
...solid('p5', -230, -30, 120, 16, STONE),
|
||||
...solid('p6', 255, -60, 110, 16, STONE),
|
||||
// Ten coins: three on the ground, one above each platform, one in the far corner.
|
||||
...coin('c1', -150, 128),
|
||||
...coin('c2', 0, 128),
|
||||
...coin('c3', 150, 128),
|
||||
...coin('c4', -170, 60),
|
||||
...coin('c5', -30, 0),
|
||||
...coin('c6', 170, 30),
|
||||
...coin('c7', 60, -80),
|
||||
...coin('c8', -230, -60),
|
||||
...coin('c9', 255, -90),
|
||||
...coin('c10', 290, 128),
|
||||
// The player: sprite, solid collider and the behavior that owns it.
|
||||
{ id: 'player', assetId: 'player-sprite', name: 'Player', position: { x: -270, y: 125, z: 2 }, overrides: {}, parentId: '', templateId: '' },
|
||||
{ id: 'player-c', assetId: 'solid', name: '', position: { x: 0, y: 0, z: 0 }, overrides: { width: 30, height: 40 }, parentId: 'player', templateId: '' },
|
||||
{ id: 'player-logic', assetId: 'player', name: '', position: { x: 0, y: -60, z: 0 }, overrides: {}, parentId: 'player', templateId: '' },
|
||||
// HUD, top left and top right of the camera frame.
|
||||
text('time-label', -300, -160, 'Time 120', { size: 22, align: 'left', name: 'TimeLabel', z: 3 }),
|
||||
text('coin-label', 300, -160, 'Coins 0/10', { size: 22, align: 'right', name: 'CoinLabel', z: 3 }),
|
||||
];
|
||||
|
||||
/** A full-frame panel with a headline, a line of help text and a button carrying a behavior. */
|
||||
const screenTemplate = (prefix, headline, hint, buttonLabel, behaviorId, headlineSize = 52) => [
|
||||
{ id: `${prefix}-panel`, assetId: 'box', name: '', position: { x: 0, y: 0, z: 0 }, overrides: { width: 640, height: 360, color: PANEL }, parentId: '', templateId: '' },
|
||||
text(`${prefix}-title`, 0, -70, headline, { size: headlineSize }),
|
||||
text(`${prefix}-hint`, 0, -10, hint, { size: 20, color: 0x9aa3b2 }),
|
||||
{ id: `${prefix}-button`, assetId: 'box', name: '', position: { x: 0, y: 80, z: 1 }, overrides: { width: 240, height: 60, color: BUTTON }, parentId: '', templateId: '' },
|
||||
text(`${prefix}-button-label`, 0, 0, buttonLabel, { size: 26, color: 0x10240a, parentId: `${prefix}-button`, z: 1 }),
|
||||
{ id: `${prefix}-button-logic`, assetId: behaviorId, name: '', position: { x: 0, y: -60, z: 0 }, overrides: {}, parentId: `${prefix}-button`, templateId: '' },
|
||||
];
|
||||
|
||||
const titleTemplate = screenTemplate('title', 'JUMP & RUN', 'Arrow keys to move, Space to jump.', 'START', 'start-button');
|
||||
const winTemplate = screenTemplate('win', 'YOU WIN!', 'All ten coins collected in time.', 'PLAY AGAIN', 'restart-button');
|
||||
const loseTemplate = screenTemplate('lose', 'TIME UP', 'The two minutes are over – try again.', 'PLAY AGAIN', 'restart-button');
|
||||
|
||||
/** One placed container: the container object plus a copy of every template object. */
|
||||
const instantiate = (containerId, assetId, name, position, template, overrides = {}) => [
|
||||
{ id: containerId, assetId, name, position, overrides, parentId: '', templateId: '' },
|
||||
...template.map((t) => ({ ...t, id: `${containerId}-${t.id}`, parentId: t.parentId ? `${containerId}-${t.parentId}` : containerId, templateId: t.id })),
|
||||
];
|
||||
|
||||
// --- graphs ----------------------------------------------------------------------------------
|
||||
|
||||
const edge = (id, from, fromPort, to, toPort) => ({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } });
|
||||
|
||||
/**
|
||||
* "Player": gravity, walking, jumping and picking coins up – 33 nodes in three strands.
|
||||
*
|
||||
* On update vy += gravity·dt, then Move(owner) by (axis·speed, vy)·dt with collisions on.
|
||||
* Blocked vertically while falling means standing: vy back to 0, onGround true.
|
||||
* On key down Space while onGround shoots vy up (negative is up).
|
||||
* On collision Touching a coin adds one to the "Game" behavior's counter and destroys it.
|
||||
*/
|
||||
const playerGraph = {
|
||||
nodes: [
|
||||
{ id: 'p-upd', type: 'OnUpdate', x: 0, y: 0, params: {} },
|
||||
{ id: 'p-owner', type: 'Owner', x: 0, y: 140, params: {} },
|
||||
{ id: 'p-axis', type: 'AxisInput', x: 0, y: 220, params: { left: 'ArrowLeft', right: 'ArrowRight', up: '', down: '' } },
|
||||
{ id: 'p-speed', type: 'GetVariable', x: 0, y: 360, params: { variable: 'speed' } },
|
||||
{ id: 'p-mulx', type: 'Multiply', x: 220, y: 300, params: {} },
|
||||
{ id: 'p-vy', type: 'GetVariable', x: 0, y: 440, params: { variable: 'vy' } },
|
||||
{ id: 'p-grav', type: 'GetVariable', x: 0, y: 520, params: { variable: 'gravity' } },
|
||||
{ id: 'p-mulg', type: 'Multiply', x: 220, y: 500, params: {} },
|
||||
{ id: 'p-addvy', type: 'Add', x: 420, y: 460, params: {} },
|
||||
{ id: 'p-setvy', type: 'SetVariable', x: 620, y: 0, params: { variable: 'vy' } },
|
||||
{ id: 'p-vy2', type: 'GetVariable', x: 620, y: 340, params: { variable: 'vy' } },
|
||||
{ id: 'p-move', type: 'Move', x: 840, y: 0, params: { collide: true } },
|
||||
{ id: 'p-br', type: 'Branch', x: 1080, y: 0, params: {} },
|
||||
{ id: 'p-seq', type: 'Sequence', x: 1260, y: 0, params: {} },
|
||||
{ id: 'p-vy3', type: 'GetVariable', x: 1080, y: 260, params: { variable: 'vy' } },
|
||||
{ id: 'p-cmp', type: 'Compare', x: 1260, y: 260, params: { op: '>=', b: 0 } },
|
||||
{ id: 'p-ground', type: 'SetVariable', x: 1460, y: 0, params: { variable: 'onGround' } },
|
||||
{ id: 'p-zero', type: 'SetVariable', x: 1460, y: 140, params: { variable: 'vy', value: 0 } },
|
||||
{ id: 'p-air', type: 'SetVariable', x: 1260, y: 400, params: { variable: 'onGround', value: false } },
|
||||
|
||||
{ id: 'p-key', type: 'OnKeyDown', x: 0, y: 700, params: { key: ' ' } },
|
||||
{ id: 'p-og', type: 'GetVariable', x: 0, y: 820, params: { variable: 'onGround' } },
|
||||
{ id: 'p-brj', type: 'Branch', x: 240, y: 700, params: {} },
|
||||
{ id: 'p-jump', type: 'GetVariable', x: 240, y: 880, params: { variable: 'jumpSpeed' } },
|
||||
{ id: 'p-neg', type: 'Multiply', x: 440, y: 880, params: { b: -1 } },
|
||||
{ id: 'p-setjump', type: 'SetVariable', x: 660, y: 700, params: { variable: 'vy' } },
|
||||
|
||||
{ id: 'p-col', type: 'OnCollisionEnter', x: 0, y: 1060, params: {} },
|
||||
{ id: 'p-iscoin', type: 'IsAsset', x: 260, y: 1180, params: { asset: 'coin-sprite' } },
|
||||
{ id: 'p-brc', type: 'Branch', x: 480, y: 1060, params: {} },
|
||||
{ id: 'p-seqc', type: 'Sequence', x: 680, y: 1060, params: {} },
|
||||
{ id: 'p-game', type: 'ObjectByName', x: 680, y: 1280, params: { name: 'Game' } },
|
||||
{ id: 'p-coins', type: 'GetProperty', x: 880, y: 1280, params: { property: 'coins' } },
|
||||
{ id: 'p-add1', type: 'Add', x: 1080, y: 1280, params: { b: 1 } },
|
||||
{ id: 'p-setcoins', type: 'SetProperty', x: 1280, y: 1060, params: { property: 'coins' } },
|
||||
{ id: 'p-destroy', type: 'Destroy', x: 1280, y: 1200, params: {} },
|
||||
],
|
||||
edges: [
|
||||
edge('pe1', 'p-upd', 'exec', 'p-setvy', 'exec'),
|
||||
edge('pe2', 'p-vy', 'value', 'p-addvy', 'a'),
|
||||
edge('pe3', 'p-grav', 'value', 'p-mulg', 'a'),
|
||||
edge('pe4', 'p-upd', 'dt', 'p-mulg', 'b'),
|
||||
edge('pe5', 'p-mulg', 'result', 'p-addvy', 'b'),
|
||||
edge('pe6', 'p-addvy', 'result', 'p-setvy', 'value'),
|
||||
edge('pe7', 'p-setvy', 'exec', 'p-move', 'exec'),
|
||||
edge('pe8', 'p-owner', 'object', 'p-move', 'object'),
|
||||
edge('pe9', 'p-axis', 'x', 'p-mulx', 'a'),
|
||||
edge('pe10', 'p-speed', 'value', 'p-mulx', 'b'),
|
||||
edge('pe11', 'p-mulx', 'result', 'p-move', 'dx'),
|
||||
edge('pe12', 'p-vy2', 'value', 'p-move', 'dy'),
|
||||
edge('pe13', 'p-upd', 'dt', 'p-move', 'dt'),
|
||||
edge('pe14', 'p-move', 'exec', 'p-br', 'exec'),
|
||||
edge('pe15', 'p-move', 'blockedY', 'p-br', 'condition'),
|
||||
edge('pe16', 'p-br', 'then', 'p-seq', 'exec'),
|
||||
edge('pe17', 'p-seq', 'a', 'p-ground', 'exec'),
|
||||
edge('pe18', 'p-vy3', 'value', 'p-cmp', 'a'),
|
||||
edge('pe19', 'p-cmp', 'result', 'p-ground', 'value'),
|
||||
edge('pe20', 'p-seq', 'b', 'p-zero', 'exec'),
|
||||
edge('pe21', 'p-br', 'else', 'p-air', 'exec'),
|
||||
|
||||
edge('pe22', 'p-key', 'exec', 'p-brj', 'exec'),
|
||||
edge('pe23', 'p-og', 'value', 'p-brj', 'condition'),
|
||||
edge('pe24', 'p-brj', 'then', 'p-setjump', 'exec'),
|
||||
edge('pe25', 'p-jump', 'value', 'p-neg', 'a'),
|
||||
edge('pe26', 'p-neg', 'result', 'p-setjump', 'value'),
|
||||
|
||||
edge('pe27', 'p-col', 'exec', 'p-brc', 'exec'),
|
||||
edge('pe28', 'p-col', 'other', 'p-iscoin', 'object'),
|
||||
edge('pe29', 'p-iscoin', 'result', 'p-brc', 'condition'),
|
||||
edge('pe30', 'p-brc', 'then', 'p-seqc', 'exec'),
|
||||
edge('pe31', 'p-seqc', 'a', 'p-setcoins', 'exec'),
|
||||
edge('pe32', 'p-game', 'object', 'p-coins', 'object'),
|
||||
edge('pe33', 'p-coins', 'value', 'p-add1', 'a'),
|
||||
edge('pe34', 'p-add1', 'result', 'p-setcoins', 'value'),
|
||||
edge('pe35', 'p-game', 'object', 'p-setcoins', 'object'),
|
||||
edge('pe36', 'p-seqc', 'b', 'p-destroy', 'exec'),
|
||||
edge('pe37', 'p-col', 'other', 'p-destroy', 'object'),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* "Game": owns the run. On start it shows the title and hides everything else; while
|
||||
* `running` it counts the clock down, writes both HUD labels and decides win (coins reached)
|
||||
* or loss (time gone) by switching the screens.
|
||||
*/
|
||||
const gameGraph = {
|
||||
nodes: [
|
||||
{ id: 'g-start', type: 'OnStart', x: 0, y: 0, params: {} },
|
||||
{ id: 'g-title', type: 'ObjectByName', x: 0, y: 140, params: { name: 'Title' } },
|
||||
{ id: 'g-level', type: 'ObjectByName', x: 0, y: 220, params: { name: 'Level' } },
|
||||
{ id: 'g-win', type: 'ObjectByName', x: 0, y: 300, params: { name: 'Win' } },
|
||||
{ id: 'g-lose', type: 'ObjectByName', x: 0, y: 380, params: { name: 'Lose' } },
|
||||
{ id: 'g-on1', type: 'SetEnabled', x: 260, y: 0, params: { enabled: true } },
|
||||
{ id: 'g-off1', type: 'SetEnabled', x: 480, y: 0, params: { enabled: false } },
|
||||
{ id: 'g-off2', type: 'SetEnabled', x: 700, y: 0, params: { enabled: false } },
|
||||
{ id: 'g-off3', type: 'SetEnabled', x: 920, y: 0, params: { enabled: false } },
|
||||
|
||||
{ id: 'g-upd', type: 'OnUpdate', x: 0, y: 520, params: {} },
|
||||
{ id: 'g-running', type: 'GetVariable', x: 0, y: 640, params: { variable: 'running' } },
|
||||
{ id: 'g-brrun', type: 'Branch', x: 220, y: 520, params: {} },
|
||||
{ id: 'g-time', type: 'GetVariable', x: 220, y: 700, params: { variable: 'timeLeft' } },
|
||||
{ id: 'g-sub', type: 'Subtract', x: 420, y: 700, params: {} },
|
||||
{ id: 'g-max', type: 'Max', x: 600, y: 700, params: { b: 0 } },
|
||||
{ id: 'g-settime', type: 'SetVariable', x: 440, y: 520, params: { variable: 'timeLeft' } },
|
||||
|
||||
{ id: 'g-timelabel', type: 'ObjectByName', x: 440, y: 860, params: { name: 'TimeLabel' } },
|
||||
{ id: 'g-time2', type: 'GetVariable', x: 440, y: 940, params: { variable: 'timeLeft' } },
|
||||
{ id: 'g-totext', type: 'ToText', x: 640, y: 940, params: { decimals: 0 } },
|
||||
{ id: 'g-join', type: 'Join', x: 840, y: 940, params: { a: 'Time ' } },
|
||||
{ id: 'g-settext', type: 'SetText', x: 700, y: 520, params: {} },
|
||||
|
||||
{ id: 'g-coinlabel', type: 'ObjectByName', x: 700, y: 1080, params: { name: 'CoinLabel' } },
|
||||
{ id: 'g-coins', type: 'GetVariable', x: 700, y: 1160, params: { variable: 'coins' } },
|
||||
{ id: 'g-totext2', type: 'ToText', x: 900, y: 1160, params: { decimals: 0 } },
|
||||
{ id: 'g-join2', type: 'Join', x: 1080, y: 1160, params: { a: 'Coins ' } },
|
||||
{ id: 'g-join3', type: 'Join', x: 1260, y: 1160, params: { b: '/10' } },
|
||||
{ id: 'g-settext2', type: 'SetText', x: 960, y: 520, params: {} },
|
||||
|
||||
{ id: 'g-needed', type: 'GetVariable', x: 960, y: 700, params: { variable: 'needed' } },
|
||||
{ id: 'g-cmpwin', type: 'Compare', x: 1160, y: 700, params: { op: '>=' } },
|
||||
{ id: 'g-brwin', type: 'Branch', x: 1220, y: 520, params: {} },
|
||||
{ id: 'g-stop1', type: 'SetVariable', x: 1420, y: 440, params: { variable: 'running', value: false } },
|
||||
{ id: 'g-leveloff1', type: 'SetEnabled', x: 1620, y: 440, params: { enabled: false } },
|
||||
{ id: 'g-winon', type: 'SetEnabled', x: 1820, y: 440, params: { enabled: true } },
|
||||
|
||||
{ id: 'g-cmplose', type: 'Compare', x: 1160, y: 860, params: { op: '<=', b: 0 } },
|
||||
{ id: 'g-brlose', type: 'Branch', x: 1420, y: 640, params: {} },
|
||||
{ id: 'g-stop2', type: 'SetVariable', x: 1620, y: 640, params: { variable: 'running', value: false } },
|
||||
{ id: 'g-leveloff2', type: 'SetEnabled', x: 1820, y: 640, params: { enabled: false } },
|
||||
{ id: 'g-loseon', type: 'SetEnabled', x: 2020, y: 640, params: { enabled: true } },
|
||||
],
|
||||
edges: [
|
||||
edge('ge1', 'g-start', 'exec', 'g-on1', 'exec'),
|
||||
edge('ge2', 'g-title', 'object', 'g-on1', 'object'),
|
||||
edge('ge3', 'g-on1', 'exec', 'g-off1', 'exec'),
|
||||
edge('ge4', 'g-level', 'object', 'g-off1', 'object'),
|
||||
edge('ge5', 'g-off1', 'exec', 'g-off2', 'exec'),
|
||||
edge('ge6', 'g-win', 'object', 'g-off2', 'object'),
|
||||
edge('ge7', 'g-off2', 'exec', 'g-off3', 'exec'),
|
||||
edge('ge8', 'g-lose', 'object', 'g-off3', 'object'),
|
||||
|
||||
edge('ge9', 'g-upd', 'exec', 'g-brrun', 'exec'),
|
||||
edge('ge10', 'g-running', 'value', 'g-brrun', 'condition'),
|
||||
edge('ge11', 'g-brrun', 'then', 'g-settime', 'exec'),
|
||||
edge('ge12', 'g-time', 'value', 'g-sub', 'a'),
|
||||
edge('ge13', 'g-upd', 'dt', 'g-sub', 'b'),
|
||||
edge('ge14', 'g-sub', 'result', 'g-max', 'a'),
|
||||
edge('ge15', 'g-max', 'result', 'g-settime', 'value'),
|
||||
|
||||
edge('ge16', 'g-settime', 'exec', 'g-settext', 'exec'),
|
||||
edge('ge17', 'g-timelabel', 'object', 'g-settext', 'object'),
|
||||
edge('ge18', 'g-time2', 'value', 'g-totext', 'value'),
|
||||
edge('ge19', 'g-totext', 'text', 'g-join', 'b'),
|
||||
edge('ge20', 'g-join', 'text', 'g-settext', 'text'),
|
||||
|
||||
edge('ge21', 'g-settext', 'exec', 'g-settext2', 'exec'),
|
||||
edge('ge22', 'g-coinlabel', 'object', 'g-settext2', 'object'),
|
||||
edge('ge23', 'g-coins', 'value', 'g-totext2', 'value'),
|
||||
edge('ge24', 'g-totext2', 'text', 'g-join2', 'b'),
|
||||
edge('ge25', 'g-join2', 'text', 'g-join3', 'a'),
|
||||
edge('ge26', 'g-join3', 'text', 'g-settext2', 'text'),
|
||||
|
||||
edge('ge27', 'g-settext2', 'exec', 'g-brwin', 'exec'),
|
||||
edge('ge28', 'g-coins', 'value', 'g-cmpwin', 'a'),
|
||||
edge('ge29', 'g-needed', 'value', 'g-cmpwin', 'b'),
|
||||
edge('ge30', 'g-cmpwin', 'result', 'g-brwin', 'condition'),
|
||||
edge('ge31', 'g-brwin', 'then', 'g-stop1', 'exec'),
|
||||
edge('ge32', 'g-stop1', 'exec', 'g-leveloff1', 'exec'),
|
||||
edge('ge33', 'g-level', 'object', 'g-leveloff1', 'object'),
|
||||
edge('ge34', 'g-leveloff1', 'exec', 'g-winon', 'exec'),
|
||||
edge('ge35', 'g-win', 'object', 'g-winon', 'object'),
|
||||
|
||||
edge('ge36', 'g-brwin', 'else', 'g-brlose', 'exec'),
|
||||
edge('ge37', 'g-time2', 'value', 'g-cmplose', 'a'),
|
||||
edge('ge38', 'g-cmplose', 'result', 'g-brlose', 'condition'),
|
||||
edge('ge39', 'g-brlose', 'then', 'g-stop2', 'exec'),
|
||||
edge('ge40', 'g-stop2', 'exec', 'g-leveloff2', 'exec'),
|
||||
edge('ge41', 'g-level', 'object', 'g-leveloff2', 'object'),
|
||||
edge('ge42', 'g-leveloff2', 'exec', 'g-loseon', 'exec'),
|
||||
edge('ge43', 'g-lose', 'object', 'g-loseon', 'object'),
|
||||
],
|
||||
};
|
||||
|
||||
/** "Start button": a click resets the counters on the Game behavior and swaps title for level. */
|
||||
const startButtonGraph = {
|
||||
nodes: [
|
||||
{ id: 's-click', type: 'OnClick', x: 0, y: 0, params: {} },
|
||||
{ id: 's-game', type: 'ObjectByName', x: 0, y: 160, params: { name: 'Game' } },
|
||||
{ id: 's-title', type: 'ObjectByName', x: 0, y: 240, params: { name: 'Title' } },
|
||||
{ id: 's-level', type: 'ObjectByName', x: 0, y: 320, params: { name: 'Level' } },
|
||||
{ id: 's-coins', type: 'SetProperty', x: 240, y: 0, params: { property: 'coins', value: 0 } },
|
||||
{ id: 's-time', type: 'SetProperty', x: 460, y: 0, params: { property: 'timeLeft', value: 120 } },
|
||||
{ id: 's-run', type: 'SetProperty', x: 680, y: 0, params: { property: 'running', value: true } },
|
||||
{ id: 's-hide', type: 'SetEnabled', x: 900, y: 0, params: { enabled: false } },
|
||||
{ id: 's-show', type: 'SetEnabled', x: 1120, y: 0, params: { enabled: true } },
|
||||
],
|
||||
edges: [
|
||||
edge('se1', 's-click', 'exec', 's-coins', 'exec'),
|
||||
edge('se2', 's-game', 'object', 's-coins', 'object'),
|
||||
edge('se3', 's-coins', 'exec', 's-time', 'exec'),
|
||||
edge('se4', 's-game', 'object', 's-time', 'object'),
|
||||
edge('se5', 's-time', 'exec', 's-run', 'exec'),
|
||||
edge('se6', 's-game', 'object', 's-run', 'object'),
|
||||
edge('se7', 's-run', 'exec', 's-hide', 'exec'),
|
||||
edge('se8', 's-title', 'object', 's-hide', 'object'),
|
||||
edge('se9', 's-hide', 'exec', 's-show', 'exec'),
|
||||
edge('se10', 's-level', 'object', 's-show', 'object'),
|
||||
],
|
||||
};
|
||||
|
||||
/** "Restart button": one click, one node – the scene starts over from its saved state. */
|
||||
const restartButtonGraph = {
|
||||
nodes: [
|
||||
{ id: 'r-click', type: 'OnClick', x: 0, y: 0, params: {} },
|
||||
{ id: 'r-restart', type: 'RestartGame', x: 260, y: 0, params: {} },
|
||||
],
|
||||
edges: [edge('re1', 'r-click', 'exec', 'r-restart', 'exec')],
|
||||
};
|
||||
|
||||
const variable = (key, kind, value) => ({ key, label: key, kind, default: value });
|
||||
|
||||
// --- project ---------------------------------------------------------------------------------
|
||||
|
||||
const project = {
|
||||
formatVersion: 1,
|
||||
id: projectId,
|
||||
name: 'Jump and Run',
|
||||
activeSceneId: 'scene1',
|
||||
assets: [
|
||||
{
|
||||
id: 'scene1',
|
||||
type: 'scene',
|
||||
name: 'Level 1',
|
||||
props: { backgroundColor: SKY },
|
||||
objects: [
|
||||
...instantiate('L', 'level', 'Level', { x: 0, y: 0, z: 0 }, levelTemplate),
|
||||
// All three screens sit on the camera frame and start switched off, so the level stays
|
||||
// visible in the editor; the Game behavior decides on `On start` which one is up.
|
||||
...instantiate('T', 'title', 'Title', { x: 0, y: 0, z: 10 }, titleTemplate, { enabled: false }),
|
||||
...instantiate('W', 'win', 'Win', { x: 0, y: 0, z: 10 }, winTemplate, { enabled: false }),
|
||||
...instantiate('B', 'lose', 'Lose', { x: 0, y: 0, z: 10 }, loseTemplate, { enabled: false }),
|
||||
{ id: 'ocam', assetId: 'cam', name: 'Main Camera', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: '', templateId: '' },
|
||||
{ id: 'ogame', assetId: 'game', name: 'Game', position: { x: -200, y: -220, z: 0 }, overrides: {}, parentId: '', templateId: '' },
|
||||
],
|
||||
},
|
||||
{ id: 'cam', type: 'camera', name: 'Main Camera', props: { width: 640, height: 360, zoom: 1 } },
|
||||
{ id: 'box', type: 'sprite', name: 'Box', props: { width: 100, height: 100, color: STONE } },
|
||||
{ id: 'player-sprite', type: 'sprite', name: 'Player Sprite', props: { width: 30, height: 40, color: PLAYER } },
|
||||
{ id: 'coin-sprite', type: 'sprite', name: 'Coin Sprite', props: { width: 24, height: 24, color: COIN } },
|
||||
{ id: 'label', type: 'text', name: 'Label', props: { text: 'Text', fontSize: 28, color: WHITE, align: 'center' } },
|
||||
{ id: 'solid', type: 'collider', name: 'Solid Collider', props: { shape: 'box', width: 100, height: 100, radius: 50, isTrigger: false } },
|
||||
{ id: 'trigger', type: 'collider', name: 'Coin Trigger', props: { shape: 'box', width: 24, height: 24, radius: 12, isTrigger: true } },
|
||||
{
|
||||
id: 'player',
|
||||
type: 'behavior',
|
||||
name: 'Player',
|
||||
props: { speed: 170, jumpSpeed: 480, gravity: 1200, vy: 0, onGround: false },
|
||||
variables: [
|
||||
variable('speed', 'number', 0),
|
||||
variable('jumpSpeed', 'number', 0),
|
||||
variable('gravity', 'number', 0),
|
||||
variable('vy', 'number', 0),
|
||||
variable('onGround', 'boolean', false),
|
||||
],
|
||||
graph: playerGraph,
|
||||
},
|
||||
{
|
||||
id: 'game',
|
||||
type: 'behavior',
|
||||
name: 'Game',
|
||||
props: { timeLeft: 120, coins: 0, needed: 10, running: false },
|
||||
variables: [
|
||||
variable('timeLeft', 'number', 120),
|
||||
variable('coins', 'number', 0),
|
||||
variable('needed', 'number', 10),
|
||||
variable('running', 'boolean', false),
|
||||
],
|
||||
graph: gameGraph,
|
||||
},
|
||||
{ id: 'start-button', type: 'behavior', name: 'Start Button', props: {}, variables: [], graph: startButtonGraph },
|
||||
{ id: 'restart-button', type: 'behavior', name: 'Restart Button', props: {}, variables: [], graph: restartButtonGraph },
|
||||
{ id: 'level', type: 'container', name: 'Level', props: {}, objects: levelTemplate },
|
||||
{ id: 'title', type: 'container', name: 'Title screen', props: {}, objects: titleTemplate },
|
||||
{ id: 'win', type: 'container', name: 'Win screen', props: {}, objects: winTemplate },
|
||||
{ id: 'lose', type: 'container', name: 'Lose screen', props: {}, objects: loseTemplate },
|
||||
],
|
||||
};
|
||||
|
||||
await writeProject(projectId, baseUrl, project);
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Smoke test for the *shipped demo scene* (containers, template instances, spawning, collision).
|
||||
* tools/e2e.mjs covers the editor in depth against a stable fixture; this one guards the other
|
||||
* side: that the scene we actually hand out still loads and plays after a change.
|
||||
*
|
||||
* Usage: PATH=/opt/node22/bin:$PATH node tools/smoke-demo.mjs [projectId] [baseUrl]
|
||||
* Defaults to the throwaway demo-check project (the playground itself is refused):
|
||||
* node tools/seed-demo.mjs aaf5e962ef9c && node tools/smoke-demo.mjs
|
||||
* Screenshots land in /tmp/gm-smoke-*.png.
|
||||
*/
|
||||
import { runChecks } from './harness.mjs';
|
||||
import { PROJECTS, cliArgs } from './projects.mjs';
|
||||
|
||||
const { projectId, baseUrl } = cliArgs('smoke-demo.mjs', PROJECTS.demoCheck);
|
||||
|
||||
/** Seeded ids: the three container instances and everything the demo places. */
|
||||
const ROOTS = ['op', 'o3', 'o5', 'oe1', 'oe2'];
|
||||
const OBJECT_COUNT = 12;
|
||||
|
||||
await runChecks({ projectId, baseUrl, port: 9335, shotPrefix: 'gm-smoke' }, async (t) => {
|
||||
const { cdp, consoleLog, check, sleep, evaluate, shot, clickText, keyPress } = t;
|
||||
|
||||
const objects = async () => JSON.parse(await evaluate('JSON.stringify(__gm.state.activeScene.objects)'));
|
||||
const world = async () => JSON.parse(await evaluate('JSON.stringify(__gm.world())'));
|
||||
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 });
|
||||
};
|
||||
|
||||
await cdp.send('Page.navigate', { url: `${baseUrl}?project=${projectId}` });
|
||||
await sleep(4000);
|
||||
await shot('01-loaded');
|
||||
check('demo scene loaded without exceptions', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).join(' | '));
|
||||
check('debug hook present', Boolean(await evaluate('Boolean(window.__gm)')));
|
||||
|
||||
// 1. Structure: three container instances (Player + two Enemies), a camera and the spawner.
|
||||
const objs = await objects();
|
||||
const roots = objs.filter((o) => o.parentId === '').map((o) => o.id);
|
||||
check('demo scene has the seeded objects and roots', objs.length === OBJECT_COUNT && ROOTS.every((id) => roots.includes(id)), `${objs.length} objects, roots=${JSON.stringify(roots)}`);
|
||||
check('hierarchy lists every object', (await evaluate('__gm.hierarchyOrder().length')) === OBJECT_COUNT);
|
||||
|
||||
// 2. Every child of a container instance points at an object of that container's template.
|
||||
const templateLinks = JSON.parse(await evaluate(`JSON.stringify((() => {
|
||||
const scene = __gm.state.activeScene, assets = __gm.state.project.assets;
|
||||
const containerOf = (id) => { const o = scene.objects.find((x) => x.id === id); return o ? assets.find((a) => a.id === o.assetId && a.type === 'container') ?? containerOf(o.parentId) : null; };
|
||||
return scene.objects.filter((o) => o.templateId).map((o) => {
|
||||
const c = containerOf(o.parentId);
|
||||
return { id: o.id, container: c ? c.id : '', ok: Boolean(c && c.objects.some((t) => t.id === o.templateId)) };
|
||||
});
|
||||
})())`));
|
||||
check('container instances are linked to their template', templateLinks.length === 7 && templateLinks.every((l) => l.ok), JSON.stringify(templateLinks));
|
||||
|
||||
// 3. Play: the demo runs, the Mover moves the player and colliders keep it out of an enemy.
|
||||
await clickText('▶ Play');
|
||||
await sleep(400);
|
||||
check('play opens the Game tab with the demo running', (await evaluate('__gm.tab()')) === 'game' && (await world()).entities.length === OBJECT_COUNT);
|
||||
const startX = (await world()).entities.find((e) => e.objectId === 'o1').worldX;
|
||||
await holdKey('ArrowRight', 'ArrowRight', 39, 400);
|
||||
const movedX = (await world()).entities.find((e) => e.objectId === 'o1').worldX;
|
||||
check('the Mover behavior moves the player while ArrowRight is held', movedX > startX + 30, `world x ${startX} → ${movedX}`);
|
||||
await shot('02-playing');
|
||||
|
||||
// 3b. Line the player up with the upper enemy (100, −100) – editing while playing only touches
|
||||
// the runtime copy – and walk right into it. Collider halves are 50 and 40, so a blocked
|
||||
// player comes to rest with its centre exactly 90 away; without collisions it would run past.
|
||||
await evaluate(`(() => { const o = __gm.state.activeScene.objects.find((x) => x.id === 'op'); o.position.x = -100; o.position.y = -100; __gm.state.preview(); })()`);
|
||||
await sleep(150);
|
||||
await holdKey('ArrowRight', 'ArrowRight', 39, 1500);
|
||||
const w = await world();
|
||||
const player = w.entities.find((e) => e.objectId === 'o1');
|
||||
const enemy = w.entities.find((e) => e.objectId === 'o2');
|
||||
const dx = player.worldX - enemy.worldX;
|
||||
const dy = Math.abs(player.worldY - enemy.worldY);
|
||||
check('solid colliders stop the player at the enemy instead of letting it pass', dy < 1 && dx > -91 && dx < -89, `dx=${dx.toFixed(2)} dy=${dy.toFixed(2)}`);
|
||||
await shot('03-collided');
|
||||
|
||||
// 4. Spawning a container: the Spawner drops a whole Enemy instance (container + two children).
|
||||
await keyPress(' ', 'Space', { windowsVirtualKeyCode: 32 });
|
||||
await sleep(250);
|
||||
const after = await objects();
|
||||
const fresh = after.filter((o) => !objs.some((old) => old.id === o.id));
|
||||
const container = fresh.find((o) => o.parentId === '');
|
||||
const linked = fresh.filter((o) => o.templateId && o.id !== container?.id);
|
||||
check('Space spawns a full Enemy container instance', fresh.length === 3 && container?.assetId === 'enemy' && container.position.y === 150 && linked.length === 2, JSON.stringify(fresh.map((o) => ({ id: o.id, assetId: o.assetId, templateId: o.templateId }))));
|
||||
const spawnWorld = await world();
|
||||
check('the spawned instance lives in the world and reached the Log node', spawnWorld.entities.length === OBJECT_COUNT + 3 && spawnWorld.log.includes('Enemy'), JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log.slice(-1) }));
|
||||
await shot('04-spawned');
|
||||
|
||||
// 5. Stop discards the runtime copy: the saved demo is exactly as it was.
|
||||
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
|
||||
await sleep(400);
|
||||
const restored = await objects();
|
||||
check('stop restores the demo scene untouched', restored.length === OBJECT_COUNT && (await evaluate('__gm.tab()')) === 'scene', `${restored.length} objects`);
|
||||
const op = restored.find((o) => o.id === 'op');
|
||||
const o1 = restored.find((o) => o.id === 'o1');
|
||||
check('the player container is back at its seeded position (the play-mode move is gone)', op.position.x === -100 && op.position.y === 0 && o1.position.x === 0 && o1.position.y === 0, JSON.stringify({ op: op.position, o1: o1.position }));
|
||||
|
||||
check('no exceptions during run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | '));
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/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(' | '));
|
||||
});
|
||||
Reference in new issue
Block a user