Hold a running game, and let the editor run reach into containers

Pause is a sub-state of play, not a third mode: the toolbar button and
Ctrl+Enter stop ticking the world, the clock included. A held frame still
drops the input edges, so resuming does not fire a pause worth of keys in
one go.

The editor run missed the case it is most wanted for. hasEditorRun only
looked at the stored scene objects, so a behavior inside a placed
container never started one; the world now builds from resolveObjects and
writes back through objectRef, keeping the move as an instance edit
instead of baking the instance out.

Keys and clicks go to the run while the Game tab is on screen, where no
key means anything to the editor, and are handed back on leaving it -
held keys released, so nothing keeps walking unpressed.
This commit is contained in:
root committed 2026-09-09 18:16:28 +02:00
1 parent 8f31afac37
commit 9c4148dc1b
19 files changed
+838 -78

No files matched your search

+161 -7
View File
@@ -4,12 +4,28 @@ import { edge, node } from '../lib/builders.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: that one
* moves along the arrow keys, and the editor run deliberately never sees the keyboard. Everything
* it adds is removed again at the end.
* 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 = {
@@ -26,7 +42,7 @@ const driftGraph = {
};
export async function editorRun(t) {
const { check, sleep, evaluate, json, shot, click, mutate, undo, object, api, projectId } = t;
const { check, sleep, evaluate, json, shot, click, holdKey, mutate, undo, object, api, projectId } = t;
// 18a. 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.
@@ -166,15 +182,153 @@ export async function editorRun(t) {
`overrides=${JSON.stringify((await object('er-logic')).overrides)} x=${afterUndo.position.x}, started at ${startX}`,
);
// 18g. 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;
scene.objects = scene.objects.filter((o) => o.id !== 'er-box' && o.id !== 'er-logic');
p.assets = p.assets.filter((a) => a.id !== 'drifter');
const proto = scene.objects[0];
const make = (id, assetId, parentId, y) => {
const o = structuredClone(proto);
Object.assign(o, {
id,
assetId,
name: '',
position: { x: 0, y, z: 0 },
parentId,
overrides: {},
instanceEdits: {},
});
return o;
};
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}`,
);
// 18h. 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 proto = scene.objects[0];
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);
const make = (id, assetId, parentId, y, overrides) => {
const o = structuredClone(proto);
Object.assign(o, {
id,
assetId,
name: '',
position: { x: 0, y, z: 0 },
parentId,
overrides,
instanceEdits: {},
});
return o;
};
scene.objects.push(
make('er-walkbox', 'crate', '', 1200, {}),
make('er-walker', 'walker', 'er-walkbox', 0, { 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.sceneObjects().filter((o) => o.id.startsWith('er-')).length`)) === 0 &&
(await json('__gm.editorRun()')).running === false,
);
}
+185
View File
@@ -0,0 +1,185 @@
import { edge, node } from '../lib/builders.mjs';
/**
* Pausing and resuming play: the world stops where it is, the clock stops with it, and input that
* arrives while it is held is dropped instead of firing all at once on resume.
*
* The step brings its own box and its own behavior. The fixture's Mover needs the arrow keys to
* move at all, so a hold would be indistinguishable from simply not pressing anything; this one
* drifts on its own. Everything it adds is removed again at the end.
*/
const DRIFT_PER_SECOND = 100;
const KEY_LOG = 'arrow down';
/**
* Two independent things in one graph: `On update → Move` drifts the owner right without any
* input, and `On key down → Log` leaves a mark for every *edge* the world saw – which is what a
* pause has to swallow.
*/
const pauseGraph = {
nodes: [
node('p-update', 'OnUpdate', 0, 0),
node('p-owner', 'Owner', 0, 120),
node('p-move', 'Move', 340, 0, { dx: 1, scale: DRIFT_PER_SECOND }),
node('p-key', 'OnKeyDown', 0, 260, { key: 'ArrowRight' }),
node('p-log', 'Log', 340, 260, { message: KEY_LOG }),
],
edges: [
edge('p-e1', 'p-update', 'exec', 'p-move', 'exec'),
edge('p-e2', 'p-update', 'dt', 'p-move', 'dt'),
edge('p-e3', 'p-owner', 'object', 'p-move', 'object'),
edge('p-e4', 'p-key', 'exec', 'p-log', 'exec'),
],
};
export async function pause(t) {
const { check, sleep, json, shot, clickText, press, entity, world, mutate, stop } = t;
const playInfo = () => json('__gm.playInfo()');
const boxX = async () => (await entity('p-box')).x;
const keyHits = async () => (await world()).log.filter((line) => line.includes(KEY_LOG)).length;
// 19a. A box with a drifting behavior below it, written into the model directly: what is under
// test is the pause, not another way of building a graph.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const proto = scene.objects[0];
const asset = structuredClone(p.assets.find((a) => a.id === 'mover'));
Object.assign(asset, {
id: 'pauser',
name: 'Pauser',
props: { enabled: true, runInEditor: false },
variables: [],
graph: ${JSON.stringify(pauseGraph)},
});
p.assets.push(asset);
const make = (id, assetId, parentId, y) => {
const o = structuredClone(proto);
Object.assign(o, {
id,
assetId,
name: id === 'p-box' ? 'Pause box' : 'Pauser',
position: { x: 0, y, z: 0 },
parentId,
overrides: {},
instanceEdits: {},
});
return o;
};
scene.objects.push(make('p-box', 'crate', '', 300), make('p-logic', 'pauser', 'p-box', 0));
`);
await sleep(400);
await t.play();
const running = await playInfo();
const beforeDrift = await boxX();
await sleep(500);
const drifted = await boxX();
// The precondition of everything below: without movement, "it stood still" is trivially true.
check(
'the box drifts on its own while playing, with nothing held down',
running.playing === true && running.paused === false && drifted > beforeDrift + 20,
`x ${beforeDrift} → ${drifted}, ${JSON.stringify(running)}`,
);
// 19b. The toolbar button holds it. Both the clock and the box have to stop – a world that keeps
// ticking while the picture happens to look the same would pass a position check alone.
await clickText('❚❚ Pause');
await sleep(200);
const held = await playInfo();
const heldX = await boxX();
await sleep(1000);
const stillHeld = await playInfo();
check(
'pausing stops the world where it is: the box and the clock both stand still',
held.paused === true &&
stillHeld.playing === true &&
Math.abs((await boxX()) - heldX) < 0.001 &&
Math.abs(stillHeld.time - held.time) < 0.05,
`x ${heldX} → ${await boxX()}, clock ${held.time.toFixed(2)} → ${stillHeld.time.toFixed(2)} over 1 s`,
);
// `locate` throws on a missing label, so the button that must be *gone* is asked for through
// the hook, which answers with null.
const labelShown = (text) => json(`__gm.locate(${JSON.stringify(text)}) !== null`);
check(
'the button offers to resume while it is held, and no longer to pause',
(await labelShown('▶ Resume')) === true && (await labelShown('❚❚ Pause')) === false,
`resume=${await labelShown('▶ Resume')} pause=${await labelShown('❚❚ Pause')}`,
);
await shot('36-paused');
// 19c. Keys pressed while it is held are dropped, not queued: resuming must not fire an edge for
// each of them. The count before proves the graph reacts at all.
const hitsBefore = await keyHits();
await press('ArrowRight');
await sleep(1000);
const hitsWhilePaused = await keyHits();
await press('ArrowRight');
await press('ArrowRight');
await sleep(200);
// 19d. Ctrl+Enter resumes, the same shortcut that started play.
await press('Enter', { ctrl: true });
await sleep(400);
const resumed = await playInfo();
const resumedX = await boxX();
check(
'resuming lets it move on from where it stopped, without catching up the held time',
resumed.paused === false &&
resumedX > heldX &&
// A second of pause plus the pressing above: catching up would show as a jump far beyond
// what the resumed run itself had time for.
resumedX < heldX + DRIFT_PER_SECOND * 1.2,
`x ${heldX} → ${resumedX}, clock ${stillHeld.time.toFixed(2)} → ${resumed.time.toFixed(2)}`,
);
const hitsAfter = await keyHits();
check(
'a key pressed while paused is swallowed instead of firing on resume',
hitsWhilePaused === hitsBefore && hitsAfter === hitsBefore,
`${hitsBefore} hits before, ${hitsWhilePaused} while held, ${hitsAfter} after resuming`,
);
// Without this the check above would also pass on a graph that never logs anything at all.
await press('ArrowRight');
await sleep(300);
check(
'the same key does fire once the game is running again',
(await keyHits()) === hitsBefore + 1,
`${hitsBefore} → ${await keyHits()}`,
);
// 19e. A hold must never outlive its play: Esc stops from a paused game, and the next run starts
// moving. A pause left standing would leave the editor unable to run anything.
await press('Enter', { ctrl: true });
await sleep(200);
check('Ctrl+Enter holds it again', (await playInfo()).paused === true);
await stop();
const afterStop = await playInfo();
await t.play();
await sleep(500);
const restartedX = await boxX();
await sleep(400);
check(
'stopping clears the hold, so the next play runs',
afterStop.playing === false &&
afterStop.paused === false &&
(await playInfo()).paused === false &&
(await boxX()) > restartedX,
`after stop ${JSON.stringify(afterStop)}, x ${restartedX} → ${await boxX()}`,
);
await stop();
await sleep(200);
// 19f. Play discarded its copy, so the box is back at the position the model holds.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'p-box' && o.id !== 'p-logic');
p.assets = p.assets.filter((a) => a.id !== 'pauser');
`);
await sleep(300);
check(
'the step leaves nothing of its own behind',
(await json(`__gm.sceneObjects().filter((o) => o.id.startsWith('p-')).length`)) === 0,
);
}
+2
View File
@@ -20,6 +20,7 @@ import { overflow } from './14-overflow.mjs';
import { aspect } from './15-aspect.mjs';
import { containerTab } from './16-container.mjs';
import { editorRun } from './17-editor-run.mjs';
import { pause } from './18-pause.mjs';
export const STEPS = [
hierarchy,
@@ -39,4 +40,5 @@ export const STEPS = [
aspect,
containerTab,
editorRun,
pause,
];