- `.gitignore` said `index.html` unanchored, which also caught the Vite source page; a fresh clone could neither `dev` nor `build`. Anchored to `/index.html`, page added. - Removed GM_REMOTE (tools/remote/gm-browser.ps1 and the ssh/scp branch in browser.mjs): the Windows test machine is gone and the only flaky check that lived on it is closed (#37). - Removed `npm run preview`, undocumented and unused. - Comments: wrong number in collider.test.ts (250 → 200), the "declarations used to…" history in listItems.ts and friends, the 24 px/28 contradiction, section banners in nodes/index.ts, node-catalog.test.ts and api/index.php, name paraphrases, and the stale step numbers in tools/e2e/11-…23- from the old global count. - README: asset types include `animation`, kind order Number/Text/Boolean, tagged colour and texture items, 6/5 kinds, the `Texture` node, `./build.sh`, PROJECT_FORMAT_VERSION, and the file tree (kinds.ts, listItems.ts, propertyPorts.ts, Breadcrumb.ts, beads.ts, tools/assets/). - tests/helpers.ts takes the duplicated `findObject` and `sceneWithCamera`. Proof: ./check.sh 797 tests, full e2e ALL PASSED 633 checks against this bundle. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
295 lines
12 KiB
JavaScript
295 lines
12 KiB
JavaScript
import { edge, node } from '../lib/builders.mjs';
|
||
import { MAKE_OBJECT } from './lib/scaffold.mjs';
|
||
|
||
/**
|
||
* "Run in editor": a behavior that keeps running while the project is not playing, and whose
|
||
* work lands in the saved project.
|
||
*
|
||
* The step brings its own box and its own behavior instead of using the fixture's Mover, so that
|
||
* the drifting checks measure nothing but time. Everything it adds is removed again at the end.
|
||
*/
|
||
|
||
const DRIFT_PER_SECOND = 100;
|
||
const WALK_PER_SECOND = 200;
|
||
/** Must stay in step with `GameViewport.editorRunCaption`. */
|
||
const EDITOR_RUN_CAPTION = 'Behaviors run here · keys and clicks go to the game';
|
||
|
||
/** `On key held(ArrowRight) → Move(owner)`: moves only while that key reaches the world. */
|
||
const walkGraph = {
|
||
nodes: [
|
||
node('k-held', 'OnKeyHeld', 0, 0, { key: 'ArrowRight' }),
|
||
node('k-owner', 'Owner', 0, 120),
|
||
node('k-move', 'Move', 340, 0, { dx: 1, scale: WALK_PER_SECOND }),
|
||
],
|
||
edges: [
|
||
edge('k-e1', 'k-held', 'exec', 'k-move', 'exec'),
|
||
edge('k-e2', 'k-held', 'dt', 'k-move', 'dt'),
|
||
edge('k-e3', 'k-owner', 'object', 'k-move', 'object'),
|
||
],
|
||
};
|
||
|
||
/** `On update → Move(owner, dx = 1, scale = 100)`: the owner drifts right, without any input. */
|
||
const driftGraph = {
|
||
nodes: [
|
||
node('d-update', 'OnUpdate', 0, 0),
|
||
node('d-owner', 'Owner', 0, 120),
|
||
node('d-move', 'Move', 340, 0, { dx: 1, scale: DRIFT_PER_SECOND }),
|
||
],
|
||
edges: [
|
||
edge('d-e1', 'd-update', 'exec', 'd-move', 'exec'),
|
||
edge('d-e2', 'd-update', 'dt', 'd-move', 'dt'),
|
||
edge('d-e3', 'd-owner', 'object', 'd-move', 'object'),
|
||
],
|
||
};
|
||
|
||
export async function editorRun(t) {
|
||
const { check, sleep, evaluate, json, shot, click, holdKey, mutate, undo, object, api, projectId } = t;
|
||
|
||
// 17a. A box with a drifting behavior below it. Written into the model directly: what is under
|
||
// test is the switch, not another way of building a graph.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
const asset = structuredClone(p.assets.find((a) => a.id === 'mover'));
|
||
Object.assign(asset, {
|
||
id: 'drifter',
|
||
name: 'Drifter',
|
||
props: { enabled: true, runInEditor: false },
|
||
variables: [],
|
||
graph: ${JSON.stringify(driftGraph)},
|
||
});
|
||
p.assets.push(asset);
|
||
${MAKE_OBJECT}
|
||
scene.objects.push(make('er-box', 'crate', '', 400, 'Drift box'), make('er-logic', 'drifter', 'er-box', 0, 'Drifter'));
|
||
`);
|
||
await sleep(400);
|
||
|
||
const boxX = async () => (await object('er-box')).position.x;
|
||
const startX = await boxX();
|
||
await sleep(500);
|
||
const idle = await json('__gm.editorRun()');
|
||
check(
|
||
'a placed behavior does not run outside play mode until it is asked to',
|
||
idle.running === false && (await boxX()) === startX,
|
||
`${JSON.stringify(idle)} x=${startX}`,
|
||
);
|
||
|
||
// 17b. The switch itself, through the inspector. It belongs with Enabled and above the
|
||
// VARIABLES section: it is a switch of the behavior, not something the behavior declares.
|
||
await evaluate(
|
||
`__gm.state.select({ kind: 'object', holderId: __gm.state.project.activeSceneId, objectId: 'er-logic' })`,
|
||
);
|
||
await sleep(200);
|
||
const enabledBox = await json(`__gm.inspectorField('Enabled')`);
|
||
const runBox = await json(`__gm.inspectorField('Run in editor')`);
|
||
const variables = await json(`__gm.locate('VARIABLES')`);
|
||
check(
|
||
'the inspector offers the switch on a behavior object, with Enabled and above the variables',
|
||
Boolean(runBox) &&
|
||
Boolean(enabledBox) &&
|
||
Boolean(variables) &&
|
||
runBox.y > enabledBox.y &&
|
||
runBox.y < variables.y,
|
||
JSON.stringify({ enabledBox, runBox, variables }),
|
||
);
|
||
await click(runBox.x, runBox.y);
|
||
await sleep(300);
|
||
|
||
const logic = await object('er-logic');
|
||
check(
|
||
'switching it on is an ordinary override on the object',
|
||
logic.overrides.runInEditor === true,
|
||
JSON.stringify(logic.overrides),
|
||
);
|
||
|
||
const running = await json('__gm.editorRun()');
|
||
const mode = await evaluate('__gm.state.mode');
|
||
check(
|
||
'the behavior runs although Play was never pressed',
|
||
running.running === true && running.behaviors.join() === 'er-logic' && mode === 'edit',
|
||
`${JSON.stringify(running)} mode=${mode}`,
|
||
);
|
||
|
||
// 17c. It really drives its owner, and it is the stored scene object that moves – no runtime
|
||
// copy is involved here, which is the whole difference to playing.
|
||
const before = await boxX();
|
||
await sleep(600);
|
||
const after = await boxX();
|
||
check(
|
||
'the owner drifts on, and the moved object is the stored one',
|
||
after - before > DRIFT_PER_SECOND * 0.3 && (await evaluate('__gm.state.mode')) === 'edit',
|
||
`x ${before} → ${after}`,
|
||
);
|
||
await shot('36-editor-run');
|
||
|
||
// 17d. The point of this semantic: the change is a real edit, so it reaches the server.
|
||
const first = await api(`projects/${projectId}`);
|
||
await sleep(2000);
|
||
const second = await api(`projects/${projectId}`);
|
||
const savedX = second.project.assets.find((a) => a.type === 'scene').objects.find((o) => o.id === 'er-box')
|
||
.position.x;
|
||
check(
|
||
'what the behavior moved is saved, not just shown',
|
||
savedX > startX,
|
||
`stored x=${savedX} (started at ${startX})`,
|
||
);
|
||
// A behavior writing every frame must not turn into a save per frame. That it saves at all is
|
||
// the check above; this one is only about the ceiling – 60 frames a second would be ~120.
|
||
const saves = second.revision - first.revision;
|
||
check(
|
||
'saving stays debounced while it runs, instead of once per frame',
|
||
saves <= 6,
|
||
`${saves} saves in 2 s`,
|
||
);
|
||
|
||
// 17e. Taking the switch back stops it again.
|
||
await click(runBox.x, runBox.y);
|
||
await sleep(300);
|
||
const stoppedAt = await boxX();
|
||
await sleep(500);
|
||
const stopped = await json('__gm.editorRun()');
|
||
check(
|
||
'taking the switch back stops the run and the object stands still',
|
||
stopped.running === false && (await boxX()) === stoppedAt,
|
||
`${JSON.stringify(stopped)} x=${stoppedAt}`,
|
||
);
|
||
|
||
// 17f. Undo covers the two clicks on the switch but nothing the behavior did while on. The first
|
||
// undo re-enables it, so it briefly runs again: undo rewinds the switch, never the clock.
|
||
await undo();
|
||
await undo();
|
||
await sleep(300);
|
||
const afterUndo = await object('er-box');
|
||
check(
|
||
'undo takes back the switch, never the movement it caused',
|
||
(await object('er-logic')).overrides.runInEditor === undefined &&
|
||
afterUndo.position.x >= stoppedAt &&
|
||
afterUndo.position.x > startX,
|
||
`overrides=${JSON.stringify((await object('er-logic')).overrides)} x=${afterUndo.position.x}, started at ${startX}`,
|
||
);
|
||
|
||
// 17g. The case a real project has: the behavior sits inside a *placed container* – a level
|
||
// packed up once and dropped into the scene. The scene stores the container object alone,
|
||
// so this only works if the run resolves the template instead of expecting stored objects.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
${MAKE_OBJECT}
|
||
p.assets.push({
|
||
id: 'er-level',
|
||
type: 'container',
|
||
name: 'Editor level',
|
||
props: { enabled: true },
|
||
objects: [make('t-box', 'crate', '', 800), make('t-logic', 'drifter', 't-box', 0)],
|
||
});
|
||
const instance = make('er-inst', 'er-level', '', 0);
|
||
instance.instanceEdits = { 't-logic': { position: {}, overrides: { runInEditor: true } } };
|
||
scene.objects.push(instance);
|
||
`);
|
||
await sleep(600);
|
||
|
||
const packedX = async () => (await object('er-inst~t-box')).position.x;
|
||
const packedStart = await packedX();
|
||
const packedRun = await json('__gm.editorRun()');
|
||
await sleep(600);
|
||
const packedNow = await packedX();
|
||
check(
|
||
'a behavior inside a placed container runs too, although the scene stores no object for it',
|
||
packedRun.behaviors.includes('er-inst~t-logic') && packedNow - packedStart > DRIFT_PER_SECOND * 0.3,
|
||
`${JSON.stringify(packedRun.behaviors)} x ${packedStart} → ${packedNow}`,
|
||
);
|
||
|
||
const stored = await json(`__gm.state.activeScene.objects.map((o) => o.id)`);
|
||
const edits = await json(`__gm.state.activeScene.objects.find((o) => o.id === 'er-inst').instanceEdits`);
|
||
const template = await json(
|
||
`__gm.state.project.assets.find((a) => a.id === 'er-level').objects.find((o) => o.id === 't-box').position`,
|
||
);
|
||
check(
|
||
'and what it moved is kept as an instance edit – the instance is not baked out, the template not touched',
|
||
stored.includes('er-inst') &&
|
||
!stored.some((id) => id.startsWith('er-inst~')) &&
|
||
edits['t-box'].position.x > packedStart &&
|
||
template.x === 0,
|
||
`stored=${JSON.stringify(stored.filter((id) => id.startsWith('er-')))} edit.x=${edits['t-box']?.position?.x} template.x=${template.x}`,
|
||
);
|
||
|
||
// 17h. The keyboard. It is the editor's while the scene is on screen – a name being typed or the
|
||
// arrow keys walking the hierarchy must not steer a game at the same time. The game tab is
|
||
// where no key means anything to the editor, so that is where a behavior may have them.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
const asset = structuredClone(p.assets.find((a) => a.id === 'drifter'));
|
||
Object.assign(asset, { id: 'walker', name: 'Walker', graph: ${JSON.stringify(walkGraph)} });
|
||
p.assets.push(asset);
|
||
${MAKE_OBJECT}
|
||
scene.objects.push(
|
||
make('er-walkbox', 'crate', '', 1200),
|
||
Object.assign(make('er-walker', 'walker', 'er-walkbox', 0), { overrides: { runInEditor: true } }),
|
||
);
|
||
`);
|
||
await sleep(600);
|
||
|
||
const walkX = async () => (await object('er-walkbox')).position.x;
|
||
const onScene = await json('__gm.editorRun()');
|
||
const sceneBefore = await walkX();
|
||
await holdKey('ArrowRight', 500);
|
||
await sleep(200);
|
||
check(
|
||
'a key pressed while the scene is on screen belongs to the editor, not to the run',
|
||
onScene.running === true &&
|
||
onScene.keyboard === false &&
|
||
(await json('__gm.tab()')) === 'scene' &&
|
||
(await walkX()) === sceneBefore,
|
||
`${JSON.stringify(onScene)} x=${sceneBefore} → ${await walkX()}`,
|
||
);
|
||
|
||
await evaluate(`__gm.showTab('game')`);
|
||
await sleep(300);
|
||
// Clicking the view first: a checkbox left focused by an earlier check would still want the keys.
|
||
const gameCentre = await json('__gm.gameCenter()');
|
||
await click(gameCentre.x, gameCentre.y);
|
||
await sleep(200);
|
||
const onGame = await json('__gm.editorRun()');
|
||
const gameBefore = await walkX();
|
||
await holdKey('ArrowRight', 500);
|
||
await sleep(200);
|
||
const gameAfter = await walkX();
|
||
check(
|
||
'in the game tab the same key reaches the behavior and the box walks',
|
||
onGame.keyboard === true && gameAfter - gameBefore > WALK_PER_SECOND * 0.3,
|
||
`${JSON.stringify(onGame)} x ${gameBefore} → ${gameAfter}`,
|
||
);
|
||
const caption = await json(`__gm.locate(${JSON.stringify(EDITOR_RUN_CAPTION)})`);
|
||
check(
|
||
'and the tab says so, instead of leaving the keyboard a guess',
|
||
caption !== null,
|
||
JSON.stringify(caption),
|
||
);
|
||
await shot('37-editor-run-keys');
|
||
|
||
await evaluate(`__gm.showTab('scene')`);
|
||
await sleep(300);
|
||
const backOnScene = await walkX();
|
||
await holdKey('ArrowRight', 400);
|
||
await sleep(200);
|
||
check(
|
||
'leaving the tab takes the keyboard back, and a key held across it does not stay held',
|
||
(await json('__gm.editorRun()')).keyboard === false && (await walkX()) === backOnScene,
|
||
`x=${backOnScene} → ${await walkX()}`,
|
||
);
|
||
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
scene.objects = scene.objects.filter((o) => !o.id.startsWith('er-'));
|
||
p.assets = p.assets.filter((a) => !['drifter', 'walker', 'er-level'].includes(a.id));
|
||
`);
|
||
await sleep(300);
|
||
check(
|
||
'the step leaves nothing of its own behind',
|
||
(await json(`__gm.sceneObjects().filter((o) => o.id.startsWith('er-')).length`)) === 0 &&
|
||
(await json('__gm.editorRun()')).running === false,
|
||
);
|
||
}
|