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

+107 -3
View File
@@ -4,13 +4,16 @@
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } 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 } from '../../src/core/model/Asset';
import type { BehaviorAsset, ContainerAsset } 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 { graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture } from '../helpers';
@@ -37,7 +40,12 @@ function scene() {
const state = new EditorState();
state.loadProject(f.project, 1);
const run = new EditorRun(state);
/** 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,
@@ -45,6 +53,7 @@ function scene() {
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();
@@ -54,6 +63,30 @@ function scene() {
};
}
/**
* 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();
@@ -71,7 +104,7 @@ describe('the run-in-editor switch', () => {
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: [] });
expect(f.run.debugInfo()).toEqual({ running: false, time: 0, behaviors: [], keyboard: false });
expect(f.boxX()).toBe(0);
});
@@ -146,3 +179,74 @@ describe('the run-in-editor switch', () => {
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();
});
});
+92
View File
@@ -0,0 +1,92 @@
/**
* Pausing play. The hold itself is a sub-state of play mode, so these tests watch that it can
* only exist while playing and that leaving play always clears it – a stuck "paused" would
* leave the editor unable to run anything.
*/
import { describe, expect, it } from 'vitest';
import { EditorState } from '../../src/editor/state';
import { makeSpriteScene, place, startWorld } from '../helpers';
function playing() {
const f = makeSpriteScene('Box');
place(f, f.spriteId, { x: 0, y: 0 });
const state = new EditorState();
state.loadProject(f.project, 1);
state.enterPlay();
return { f, state };
}
describe('pausing the game', () => {
it('holds and releases while playing', () => {
const { state } = playing();
expect(state.paused).toBe(false);
state.setPaused(true);
expect(state.paused).toBe(true);
state.setPaused(false);
expect(state.paused).toBe(false);
});
it('cannot be paused while editing', () => {
const f = makeSpriteScene('Box');
const state = new EditorState();
state.loadProject(f.project, 1);
state.setPaused(true);
expect(state.paused).toBe(false);
});
it('tells the toolbar so its button can change label', () => {
const { state } = playing();
let changes = 0;
state.on('mode', () => (changes += 1));
state.setPaused(true);
expect(changes).toBe(1);
// Setting the same value again is not a change and must not redraw.
state.setPaused(true);
expect(changes).toBe(1);
});
it('is over when play is over, so the next run starts moving', () => {
const { state } = playing();
state.setPaused(true);
state.exitPlay();
expect(state.paused).toBe(false);
state.enterPlay();
expect(state.paused).toBe(false);
});
it('does not survive loading another project', () => {
const { state } = playing();
state.setPaused(true);
state.loadProject(makeSpriteScene('Other').project, 1);
expect(state.paused).toBe(false);
});
});
describe('the world while it is not ticked', () => {
it('keeps its clock and its positions', () => {
const f = makeSpriteScene('Box');
place(f, f.spriteId, { x: 0, y: 0 });
const { world } = startWorld(f);
world.tick(0.5);
const box = world.entities[0]!;
const time = world.time;
const x = box.x;
// What a paused frame does: no tick at all.
expect(world.time).toBe(time);
expect(box.x).toBe(x);
world.tick(0.5);
expect(world.time).toBeCloseTo(time + 0.5, 5);
});
it('forgets a click that arrived while nothing was ticking', () => {
const f = makeSpriteScene('Box');
place(f, f.spriteId, { x: 0, y: 0 });
const { world } = startWorld(f);
world.setPointerDown(true);
expect(world.pointer.pressed).toBe(true);
world.dropPointerEdges();
expect(world.pointer.pressed).toBe(false);
// The button is still held – only the one-frame edge is gone.
expect(world.pointer.down).toBe(true);
});
});