Files
feuerwerk/tests/editor/editor-run.test.ts
T
root 9c4148dc1b 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.
2026-09-09 18:16:28 +02:00

253 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* "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 } 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 } 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';
/** 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(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(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();
});
});