/** * "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(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(); }); });