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.
149 lines
5.7 KiB
TypeScript
149 lines
5.7 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 } from '../../src/core/commands/assetCommands';
|
||
import { ClearOverrideCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
import type { BehaviorAsset } 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 { 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);
|
||
const run = new EditorRun(state);
|
||
return {
|
||
...f,
|
||
behavior,
|
||
box,
|
||
logic,
|
||
state,
|
||
run,
|
||
/** 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,
|
||
};
|
||
}
|
||
|
||
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: [] });
|
||
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);
|
||
});
|
||
});
|