Until now only the editor toolbar could pause a running game. A pause menu inside the game had no way to stop the world while its own graph kept running, and there was no slow motion. - Five new nodes: Pause game, Resume game, Is game paused, Set time scale and Time scale. Pause and resume take effect on the next frame. - A game pause stops world time, physics, animations, particles and shader time. Only behaviors with the new "Run while paused" switch keep running. - Set time scale 0 freezes the world but graphs keep running, as agreed in the issue. - Play and Stop reset the time scale to 1, Load scene keeps it. The pause belongs to the scene, so Load scene and Restart unpause. - The toolbar pause stays separate and also freezes the menu. When the game pauses itself the status line reads "The game paused itself". - While paused, collision events are not reported, sounds keep playing, and the editor run ignores both nodes with a log line. Findings: - The first status line was longer than any before and got cut with an ellipsis. It is shorter now. - The e2e harness did not know the key "p". It does now. Tests: - ./check.sh: 133 files, 1968 tests, exit 0 (before 132 / 1952) - npm run build: exit 0 - GM_STEPS=18 fresh: ALL PASSED, 33 checks (before 26) - 16 new unit tests: 8 catalog cases, game-pause.test.ts, editor run - 7 new e2e checks in block 18i, driven by real keys in the game - Six counter-checks all bite: behavior filter, collision lock, world time, editor run, clock reset and the game clock in the browser - Check total 1217 in README is computed (1210 + 7), CI measures it Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
333 lines
12 KiB
TypeScript
333 lines
12 KiB
TypeScript
/**
|
||
* "Run in editor": a behavior that keeps running while the project is not playing. Unlike play
|
||
* mode this one edits the saved model, so these tests watch what lands in the scene itself.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
|
||
import { instanceChildId } from '../../src/core/model/instance';
|
||
import { ClearOverrideCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
import type { BehaviorAsset, ContainerAsset, VariablesAsset } from '../../src/core/model/Asset';
|
||
import { hasEditorRun, runsInEditor } from '../../src/core/properties/editorRun';
|
||
import { schemaFor } from '../../src/core/properties/PropertySchema';
|
||
import { resolveSceneObjectProps } from '../../src/core/properties/resolve';
|
||
import { findAsset } from '../../src/core/model/Project';
|
||
import { EditorRun } from '../../src/editor/EditorRun';
|
||
import type { World } from '../../src/runtime/World';
|
||
import { EditorState } from '../../src/editor/state';
|
||
import { VARIABLES_PARAM } from '../../src/core/graph';
|
||
import {
|
||
graphEditor,
|
||
makeBehavior,
|
||
makeSpriteScene,
|
||
makeVariables,
|
||
place,
|
||
type SceneFixture,
|
||
} from '../helpers';
|
||
|
||
/** Wires `On update → Move(owner, dx = 1, scale = 100)`: the owner drifts 100 units per second. */
|
||
function drift(f: SceneFixture, behavior: BehaviorAsset): void {
|
||
const edit = graphEditor(f, behavior);
|
||
const update = edit.addNode('OnUpdate');
|
||
const owner = edit.addNode('Owner');
|
||
const move = edit.addNode('Move', { dx: 1, scale: 100 });
|
||
edit.wire([
|
||
[update, 'exec', move, 'exec'],
|
||
[update, 'dt', move, 'dt'],
|
||
[owner, 'object', move, 'object'],
|
||
]);
|
||
}
|
||
|
||
/** A box with a drifting behavior below it, in a state the editor run can be pointed at. */
|
||
function scene() {
|
||
const f = makeSpriteScene('Box');
|
||
const behavior = makeBehavior(f, 'Drift');
|
||
drift(f, behavior);
|
||
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
||
const logic = place(f, behavior.id, { x: 0, y: 0 }, box);
|
||
|
||
const state = new EditorState();
|
||
state.loadProject(f.project, 1);
|
||
/** Stands in for the game view being the focused tab; flipped by the keyboard tests. */
|
||
const keyboard = { focused: false, world: null as World | null };
|
||
const run = new EditorRun(state, {
|
||
focused: () => keyboard.focused,
|
||
onWorld: (world) => (keyboard.world = world),
|
||
});
|
||
return {
|
||
...f,
|
||
behavior,
|
||
box,
|
||
logic,
|
||
state,
|
||
run,
|
||
keyboard,
|
||
/** One frame, after telling the run that the model may have changed (the Editor's wiring). */
|
||
frame(dt = 1 / 60) {
|
||
run.invalidate();
|
||
run.tick(dt);
|
||
},
|
||
boxX: () => f.object(box).position.x,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The shape a real level has: the box and its behavior packed into a container asset and placed
|
||
* once, so the scene stores the container object alone and the content is resolved (`instance.ts`).
|
||
*/
|
||
function containerScene() {
|
||
const f = scene();
|
||
const create = new CreateContainerCommand(f.sceneId, [f.box]);
|
||
f.stack.execute(create);
|
||
const asset = () => findAsset(f.project, create.assetId) as ContainerAsset;
|
||
const [tBox, tLogic] = asset().objects.map((t) => t.id) as [string, string];
|
||
const child = (templateId: string) => instanceChildId(create.objectId, templateId);
|
||
return {
|
||
...f,
|
||
containerId: create.assetId,
|
||
tBox,
|
||
tLogic,
|
||
child,
|
||
template: (id: string) => asset().objects.find((t) => t.id === id)!,
|
||
instanceEdits: () => f.sceneOf().objects[0]!.instanceEdits,
|
||
switchOn: () => f.state.execute(new SetOverrideCommand(f.sceneId, child(tLogic), 'runInEditor', true)),
|
||
boxX: () => f.object(child(tBox)).position.x,
|
||
};
|
||
}
|
||
|
||
describe('the run-in-editor switch', () => {
|
||
it('is a behavior property, off unless a scene object asks for it', () => {
|
||
const f = scene();
|
||
expect(schemaFor('behavior').map((d) => d.key)).toContain('runInEditor');
|
||
const asset = findAsset(f.project, f.behavior.id)!;
|
||
|
||
expect(runsInEditor(resolveSceneObjectProps(f.project, asset, f.object(f.logic)))).toBe(false);
|
||
expect(hasEditorRun(f.project, f.sceneOf())).toBe(false);
|
||
|
||
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
|
||
expect(runsInEditor(resolveSceneObjectProps(f.project, asset, f.object(f.logic)))).toBe(true);
|
||
expect(hasEditorRun(f.project, f.sceneOf())).toBe(true);
|
||
});
|
||
|
||
it('leaves everything alone until it is switched on', () => {
|
||
const f = scene();
|
||
for (let i = 0; i < 10; i++) f.frame();
|
||
expect(f.run.debugInfo()).toEqual({ running: false, time: 0, behaviors: [], keyboard: false });
|
||
expect(f.boxX()).toBe(0);
|
||
});
|
||
|
||
it('drives the object in the saved model once it is on, and marks the project for saving', () => {
|
||
const f = scene();
|
||
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
|
||
// The command itself dirtied the project; the question is whether the *run* does too.
|
||
f.state.setSaveStatus('saved');
|
||
|
||
f.frame(0.1);
|
||
expect(f.run.debugInfo().behaviors).toEqual([f.logic]);
|
||
expect(f.boxX()).toBeCloseTo(10);
|
||
// Not a copy, not a preview: the stored scene object moved, and that has to be saved.
|
||
expect(f.sceneOf().objects.find((o) => o.id === f.box)!.position.x).toBeCloseTo(10);
|
||
expect(f.state.saveStatus).toBe('dirty');
|
||
|
||
f.frame(0.1);
|
||
expect(f.boxX()).toBeCloseTo(20);
|
||
});
|
||
|
||
it('runs only the behaviors that ask for it', () => {
|
||
const f = scene();
|
||
const second = makeBehavior(f, 'Idle');
|
||
drift(f, second);
|
||
place(f, second.id, { x: 0, y: 0 }, f.box);
|
||
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
|
||
|
||
f.frame(0.1);
|
||
// Both behaviors own the same box: were the second one running too, it would drift twice as far.
|
||
expect(f.run.debugInfo().behaviors).toEqual([f.logic]);
|
||
expect(f.boxX()).toBeCloseTo(10);
|
||
});
|
||
|
||
it('stops the moment the switch is taken back', () => {
|
||
const f = scene();
|
||
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
|
||
f.frame(0.1);
|
||
expect(f.boxX()).toBeCloseTo(10);
|
||
|
||
f.state.execute(new ClearOverrideCommand(f.sceneId, f.logic, 'runInEditor'));
|
||
f.frame(0.1);
|
||
f.frame(0.1);
|
||
expect(f.run.debugInfo().running).toBe(false);
|
||
expect(f.boxX()).toBeCloseTo(10);
|
||
});
|
||
|
||
it('holds still while the project is playing – that world runs every behavior already', () => {
|
||
const f = scene();
|
||
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
|
||
f.state.enterPlay();
|
||
|
||
f.frame(0.1);
|
||
expect(f.run.debugInfo().running).toBe(false);
|
||
|
||
// The play copy carries the same ids, so the saved model must be the one left untouched.
|
||
f.state.exitPlay();
|
||
expect(f.boxX()).toBe(0);
|
||
});
|
||
|
||
it('does not run a behavior whose scene is not the loaded one', () => {
|
||
const f = scene();
|
||
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
|
||
f.frame(0.1);
|
||
expect(f.run.debugInfo().running).toBe(true);
|
||
|
||
const other = new CreateAssetCommand('scene', 'Level 2');
|
||
f.state.execute(other);
|
||
f.project.activeSceneId = other.asset.id;
|
||
f.frame(0.1);
|
||
f.frame(0.1);
|
||
expect(f.run.debugInfo().running).toBe(false);
|
||
expect(f.boxX()).toBeCloseTo(10);
|
||
});
|
||
});
|
||
|
||
describe('a behavior inside a placed container', () => {
|
||
it('is found although the scene only stores the container object', () => {
|
||
const f = containerScene();
|
||
expect(f.sceneOf().objects).toHaveLength(1);
|
||
expect(hasEditorRun(f.project, f.sceneOf())).toBe(false);
|
||
|
||
f.switchOn();
|
||
expect(hasEditorRun(f.project, f.sceneOf())).toBe(true);
|
||
});
|
||
|
||
it('drives the content, and the move is kept as an instance edit instead of baking it out', () => {
|
||
const f = containerScene();
|
||
f.switchOn();
|
||
f.state.setSaveStatus('saved');
|
||
|
||
f.frame(0.1);
|
||
expect(f.run.debugInfo().behaviors).toEqual([f.child(f.tLogic)]);
|
||
expect(f.boxX()).toBeCloseTo(10);
|
||
expect(f.state.saveStatus).toBe('dirty');
|
||
|
||
// Baking would spell the content out as stored objects and drop every instance edit, so what
|
||
// moved has to land on the instance – where the template still reaches the rest of it.
|
||
expect(f.sceneOf().objects).toHaveLength(1);
|
||
expect(f.instanceEdits()[f.tBox]?.position.x).toBeCloseTo(10);
|
||
|
||
f.frame(0.1);
|
||
expect(f.boxX()).toBeCloseTo(20);
|
||
expect(f.instanceEdits()[f.tBox]?.position.x).toBeCloseTo(20);
|
||
});
|
||
|
||
it('leaves the template alone, so a second instance of the same level stays put', () => {
|
||
const f = containerScene();
|
||
const second = place(f, f.containerId, { x: 500, y: 0 });
|
||
f.switchOn();
|
||
|
||
f.frame(0.1);
|
||
expect(f.boxX()).toBeCloseTo(10);
|
||
// The switch is an edit of the first instance only: the same box in the second one has neither
|
||
// the behavior running nor the movement.
|
||
expect(f.object(instanceChildId(second, f.tBox)).position.x).toBe(0);
|
||
expect(f.template(f.tBox).position.x).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('the keyboard of an editor run', () => {
|
||
it('is the game view’s, and only while something is running there', () => {
|
||
const f = containerScene();
|
||
expect(f.run.debugInfo().keyboard).toBe(false);
|
||
|
||
f.switchOn();
|
||
f.frame(0.1);
|
||
// Running, but the editor still owns the keys: the game view is not the focused tab.
|
||
expect(f.run.debugInfo()).toMatchObject({ running: true, keyboard: false });
|
||
|
||
f.keyboard.focused = true;
|
||
expect(f.run.debugInfo().keyboard).toBe(true);
|
||
});
|
||
|
||
it('hands the world to the game view while it runs, so clicks can reach it', () => {
|
||
const f = containerScene();
|
||
f.switchOn();
|
||
f.frame(0.1);
|
||
expect(f.keyboard.world).not.toBeNull();
|
||
|
||
f.state.execute(new ClearOverrideCommand(f.sceneId, f.child(f.tLogic), 'runInEditor'));
|
||
f.frame(0.1);
|
||
f.frame(0.1);
|
||
expect(f.keyboard.world).toBeNull();
|
||
});
|
||
});
|
||
|
||
/**
|
||
* A Variables asset while the editor runs. This world is built on the saved model, so it is the
|
||
* place where a value that belongs to the run could leak into the document – and must not.
|
||
*/
|
||
describe('shared variables of an editor run', () => {
|
||
/** A Variables asset holding `score`, and a behavior writing 1 into it when it starts. */
|
||
function sharedScene() {
|
||
const f = scene();
|
||
const shared = makeVariables(f, 'Score', ['score']);
|
||
const writer = makeBehavior(f, 'Writer');
|
||
const edit = graphEditor(f, writer);
|
||
const start = edit.addNode('OnStart');
|
||
const one = edit.addNode('Add', { a: 1, b: 0 });
|
||
const write = edit.variables(['score'], { write: true, params: { [VARIABLES_PARAM]: shared.id } });
|
||
edit.wire([
|
||
[start, 'exec', write, 'exec'],
|
||
[one, 'result', write, 'score'],
|
||
]);
|
||
const object = place(f, writer.id, { x: 0, y: 0 });
|
||
f.state.execute(new SetOverrideCommand(f.sceneId, object, 'runInEditor', true));
|
||
return {
|
||
...f,
|
||
sharedId: shared.id,
|
||
writerObject: object,
|
||
live: () => f.run.liveVariables(shared.id)?.score,
|
||
declared: () => (findAsset(f.project, shared.id) as VariablesAsset).props.score,
|
||
};
|
||
}
|
||
|
||
it('holds a value of its own, and leaves the document the one it started from', () => {
|
||
const f = sharedScene();
|
||
f.frame(0.1);
|
||
expect(f.live()).toBe(1);
|
||
for (let i = 0; i < 5; i++) f.frame(0.1);
|
||
expect(f.declared()).toBe(0);
|
||
});
|
||
|
||
it('takes over a value edited while it runs', () => {
|
||
const f = sharedScene();
|
||
f.frame(0.1);
|
||
f.state.execute(new SetAssetPropertyCommand(f.sharedId, 'score', 5));
|
||
f.frame(0.1);
|
||
expect(f.live()).toBe(5);
|
||
});
|
||
|
||
/** Without a run there is no live value, so the panels go back to showing the declaration. */
|
||
it('answers with nothing once the run has ended', () => {
|
||
const f = sharedScene();
|
||
f.frame(0.1);
|
||
expect(f.live()).toBe(1);
|
||
|
||
f.state.execute(new ClearOverrideCommand(f.sceneId, f.writerObject, 'runInEditor'));
|
||
f.frame(0.1);
|
||
expect(f.run.liveVariables(f.sharedId)).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('the game nodes in the editor run (#167)', () => {
|
||
it('ignores Pause game and says so: the scene keeps moving', () => {
|
||
const f = scene();
|
||
const edit = graphEditor(f, f.behavior);
|
||
edit.connect(edit.addNode('OnStart'), 'exec', edit.addNode('PauseGame'), 'exec');
|
||
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
|
||
|
||
f.frame(0.1);
|
||
f.frame(0.1);
|
||
expect(f.boxX()).toBeCloseTo(20);
|
||
expect(f.keyboard.world?.paused).toBe(false);
|
||
expect(f.keyboard.world?.log).toContain('Pause game is ignored in the editor run');
|
||
});
|
||
});
|