A behavior object carries a "Run in editor" switch next to Enabled. When it is on, the behavior ticks outside of Play and works on the saved model directly - Unity semantics: what it moves is a real change, autosaved and not undoable. Input stays with the editor, so On key never fires while typing. The editor run never bakes placed containers into the model, since that would silently drop their instanceEdits. syncToModel reports whether it wrote anything, and the dirty flag is only raised on change - otherwise a behavior moving something every frame would trigger ~60 saves a second.
181 lines
6.7 KiB
JavaScript
181 lines
6.7 KiB
JavaScript
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.
|
||
*/
|
||
|
||
const DRIFT_PER_SECOND = 100;
|
||
|
||
/** `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, 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.
|
||
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: 'drifter',
|
||
name: 'Drifter',
|
||
props: { enabled: true, runInEditor: false },
|
||
variables: [],
|
||
graph: ${JSON.stringify(driftGraph)},
|
||
});
|
||
p.assets.push(asset);
|
||
const make = (id, assetId, parentId, y) => {
|
||
const o = structuredClone(proto);
|
||
Object.assign(o, {
|
||
id,
|
||
assetId,
|
||
name: id === 'er-box' ? 'Drift box' : 'Drifter',
|
||
position: { x: 0, y, z: 0 },
|
||
parentId,
|
||
overrides: {},
|
||
instanceEdits: {},
|
||
});
|
||
return o;
|
||
};
|
||
scene.objects.push(make('er-box', 'crate', '', 400), make('er-logic', 'drifter', 'er-box', 0));
|
||
`);
|
||
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}`,
|
||
);
|
||
|
||
// 18b. 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}`,
|
||
);
|
||
|
||
// 18c. 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');
|
||
|
||
// 18d. 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`,
|
||
);
|
||
|
||
// 18e. 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}`,
|
||
);
|
||
|
||
// 18f. Undo covers the two clicks on the switch – but not a millimetre of what the behavior did
|
||
// while it was on. That is the price of editing the model directly, better stated here than
|
||
// discovered later. Note the first undo takes back the *switching off*, so the behavior
|
||
// briefly runs again between the two: 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}`,
|
||
);
|
||
|
||
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');
|
||
`);
|
||
await sleep(300);
|
||
check(
|
||
'the step leaves nothing of its own behind',
|
||
(await json(`__gm.sceneObjects().filter((o) => o.id.startsWith('er-')).length`)) === 0,
|
||
);
|
||
}
|