diff --git a/README.md b/README.md index 7ce5ba25..7096888f 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Live: · Widget playground: = { ], }, ], - // Behaviors declare their own properties (variables) per asset – see `schemaForAsset`. - behavior: [], + // Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`. + behavior: [{ key: 'runInEditor', label: 'Run in editor', kind: 'boolean', default: false }], collider: [ { key: 'shape', @@ -133,9 +133,11 @@ export function schemaFor(type: AssetType): PropertySchema { return FULL_SCHEMAS[type]; } -/** The type schema, or for behaviors their declared variables. */ +/** The type schema, for behaviors followed by their declared variables. */ export function schemaForAsset(asset: Asset): PropertySchema { - return asset.type === 'behavior' ? [...COMMON_PROPS, ...asset.variables] : FULL_SCHEMAS[asset.type]; + return asset.type === 'behavior' + ? [...FULL_SCHEMAS.behavior, ...asset.variables] + : FULL_SCHEMAS[asset.type]; } export function propDef(asset: Asset, key: string): PropertyDef | undefined { diff --git a/src/core/properties/deviation.ts b/src/core/properties/deviation.ts index e8bcbc12..2abb2794 100644 --- a/src/core/properties/deviation.ts +++ b/src/core/properties/deviation.ts @@ -3,7 +3,7 @@ import { ancestorIdsOf, type ObjectTree } from '../model/hierarchy'; import { instanceTargetOf, isEmptyEdit, resolveObjects } from '../model/instance'; import { findAsset, type Project } from '../model/Project'; import type { SceneObject } from '../model/SceneObject'; -import { COMMON_PROPS, type PropertySchema, schemaForAsset } from './PropertySchema'; +import { type PropertySchema, schemaFor, schemaForAsset } from './PropertySchema'; import { deviatesFromAsset, isModified } from './resolve'; /** @@ -11,7 +11,7 @@ import { deviatesFromAsset, isModified } from './resolve'; * default is declared on the variable itself, so the asset value cannot differ from it. */ export function markedAssetSchema(asset: Asset): PropertySchema { - return asset.type === 'behavior' ? COMMON_PROPS : schemaForAsset(asset); + return asset.type === 'behavior' ? schemaFor('behavior') : schemaForAsset(asset); } /** Whether any asset property the inspector would mark gold differs from its default. */ diff --git a/src/core/properties/editorRun.ts b/src/core/properties/editorRun.ts new file mode 100644 index 00000000..37aed0d7 --- /dev/null +++ b/src/core/properties/editorRun.ts @@ -0,0 +1,22 @@ +/** + * The `runInEditor` switch of a behavior (see `ASSET_SCHEMAS.behavior`). A behavior with it on + * keeps running while the project is *not* playing, and then it edits the saved model: what it + * moves or writes is a real change that is saved and is not on the undo stack. That is what makes + * the switch useful (a behavior can build or arrange content) and why it is off by default. + */ +import type { ObjectTree } from '../model/hierarchy'; +import type { PropertyBag } from '../model/Properties'; +import { findAsset, type Project } from '../model/Project'; +import { resolveSceneObjectProps } from './resolve'; + +export function runsInEditor(props: PropertyBag): boolean { + return props.runInEditor === true; +} + +/** Whether a holder contains any behavior asking to run outside play mode. */ +export function hasEditorRun(project: Project, holder: ObjectTree): boolean { + return holder.objects.some((object) => { + const asset = findAsset(project, object.assetId); + return asset?.type === 'behavior' && runsInEditor(resolveSceneObjectProps(asset, object)); + }); +} diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 17775379..550102a0 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -7,6 +7,7 @@ import { GameViewport } from '../viewport/GameViewport'; import { SceneViewport } from '../viewport/SceneViewport'; import { attachAutosave } from './autosave'; import { ContainerEditor } from './container/ContainerEditor'; +import { EditorRun } from './EditorRun'; import { GraphEditor } from './graph/GraphEditor'; import { Inspector } from './inspector/Inspector'; import { AssetPanel } from './panels/AssetPanel'; @@ -47,6 +48,7 @@ export class Editor implements PlayHost { /** Frame rate, refreshed twice a second; null until the first measurement. */ fps: number | null = null; private readonly play: PlaySession; + private readonly editorRun: EditorRun; private readonly projects: ProjectSession; private readonly leftColumn: VStack; private readonly inspector: Inspector; @@ -76,6 +78,7 @@ export class Editor implements PlayHost { this.hierarchy = new HierarchyPanel(state); this.inspector = new Inspector(state); this.play = new PlaySession(state, this); + this.editorRun = new EditorRun(state); this.projects = new ProjectSession(state, store, () => { if (this.play.isPlaying) this.play.stop(); }); @@ -164,7 +167,12 @@ export class Editor implements PlayHost { ) this.showTab('container'); }); - state.on('project', () => this.play.syncFromModel()); + state.on('project', () => { + this.play.syncFromModel(); + this.editorRun.invalidate(); + }); + state.on('load', () => this.editorRun.invalidate()); + state.on('mode', () => this.editorRun.invalidate()); } showTab(tab: CentreTab): void { @@ -204,6 +212,7 @@ export class Editor implements PlayHost { private tick(dt: number): void { this.measureFps(dt); this.play.tick(dt); + this.editorRun.tick(dt); } /** Refreshes the toolbar readout twice a second. */ @@ -221,4 +230,9 @@ export class Editor implements PlayHost { worldSnapshot(): WorldSnapshot | null { return this.play.snapshot(); } + + /** What is running without Play, for the check scripts. */ + editorRunInfo(): { running: boolean; time: number; behaviors: string[] } { + return this.editorRun.debugInfo(); + } } diff --git a/src/editor/EditorRun.ts b/src/editor/EditorRun.ts new file mode 100644 index 00000000..e11fadbe --- /dev/null +++ b/src/editor/EditorRun.ts @@ -0,0 +1,113 @@ +/** + * Keeps the behaviors switched to "run in editor" running while the project is *not* playing. + * + * The one thing that makes this different from Play: the world is built from the saved model, not + * from a copy. What such a behavior moves or writes is a real edit – it is saved and it is not on + * the undo stack. The keyboard and the mouse stay with the editor, so input nodes never fire here. + */ +import { hasEditorRun, runsInEditor } from '../core/properties/editorRun'; +import { BehaviorSystem } from '../runtime/systems/BehaviorSystem'; +import { CollisionSystem } from '../runtime/systems/CollisionSystem'; +import { Input } from '../runtime/systems/Input'; +import { type ModelWrite, World } from '../runtime/World'; +import type { EditorState } from './state'; + +export class EditorRun { + private world: World | null = null; + private behaviors: BehaviorSystem | null = null; + /** Never attached: the editor owns the keyboard, so `Is key down` is always false in here. */ + private readonly input = new Input(); + /** The model changed, so *whether* anything runs is open again, not just the world's contents. */ + private stale = true; + /** Set while our own write travels through the listeners, so it does not count as a change. */ + private writing = false; + + constructor(private readonly state: EditorState) {} + + /** The model changed: a command, a load, or leaving play mode. */ + invalidate(): void { + if (!this.writing) this.stale = true; + } + + tick(dt: number): void { + if (this.state.mode !== 'edit') { + // Playing runs every behavior anyway – and on the runtime copy, which this world is not on. + this.stop(); + this.stale = true; + return; + } + if (this.stale) { + this.stale = false; + this.refresh(); + } + const world = this.world; + if (!world) return; + world.tick(Math.min(dt, 0.1)); + if (this.control(world)) return; + this.commit(world.syncToModel()); + } + + /** Facts for the check scripts: object ids of the behaviors currently running without Play. */ + debugInfo(): { running: boolean; time: number; behaviors: string[] } { + return { + running: this.world !== null, + time: this.world?.time ?? 0, + behaviors: this.behaviors?.runningIds() ?? [], + }; + } + + /** Builds, keeps or drops the world, depending on what the scene now asks for. */ + private refresh(): void { + const scene = this.state.activeScene; + if (!scene || !hasEditorRun(this.state.project, scene)) { + this.stop(); + return; + } + const world = this.world; + if (world && world.project === this.state.project && world.sceneId === scene.id) { + world.syncFromModel(); + return; + } + this.stop(); + const project = this.state.project; + // Baking a container's content into the scene would be an edit nobody asked for, so it is off + // here – which is why a behavior inside a placed container does not run outside play mode. + const fresh = World.instantiate(project, scene.id, { bakeContainers: false }); + const collisions = new CollisionSystem(); + this.behaviors = new BehaviorSystem(project, this.input, collisions, (e) => runsInEditor(e.props)); + fresh.addSystem(collisions); + fresh.addSystem(this.behaviors); + this.world = fresh; + // `On start` already ran while the system started, and it may have failed. + this.control(fresh); + } + + /** True when the run ended: a behavior failed, stopped the game, or asked to start over. */ + private control(world: World): boolean { + if (world.restartRequested) { + this.stop(); + this.stale = true; + return true; + } + if (!world.stopRequested) return false; + const error = world.log.find((line) => line.startsWith('Error in behavior')); + this.stop(); + this.state.setNotice(error ?? 'A behavior running in the editor stopped it.'); + return true; + } + + private commit(write: ModelWrite): void { + if (!write.values && !write.structure) return; + this.writing = true; + this.state.runtimeEdit(); + // Objects appeared or vanished: the panels have to rebuild, not just re-read their values. + if (write.structure) this.state.preview(); + this.writing = false; + } + + private stop(): void { + this.world?.destroy(); + this.world = null; + this.behaviors = null; + } +} diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index d5fa451a..ce76fa38 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -115,7 +115,7 @@ export class PlaySession { return; } // Mirror what the systems did into the runtime copy so the panels show live values. - if (world.syncToModel()) this.state.preview(); + if (world.syncToModel().structure) this.state.preview(); this.state.runtimeTick(); if (this.host.graphEditor.visible) this.host.graphEditor.canvas.redraw(); } diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index f8495a72..9d0741a1 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -50,6 +50,30 @@ export function installDebugHook(app: Application, state: EditorState, editor: E walk(app.stage); return found; }; + /** + * Centre of the control of an inspector row, addressed by its label. A row aligns its label to a + * *minimum* width, so a label wider than the column pushes its control right – which is why a + * fixed offset from the label's own text is the wrong way to click one. + */ + const fieldOf = (label: string): { x: number; y: number } | null => { + let found: Text | null = null; + const walk = (node: Container): void => { + if (found !== null || !node.visible) return; + if (node instanceof Text && node.text === label) { + found = node; + return; + } + for (const child of node.children) walk(child); + }; + walk(app.stage); + // TypeScript does not track the assignment inside `walk`, hence the cast. + let owner: Container | null = (found as Text | null)?.parent ?? null; + while (owner && !(owner instanceof Widget)) owner = owner.parent; + const row = owner?.parent; + if (!row || !owner) return null; + const control = row.children[row.children.indexOf(owner) + 1]; + return control instanceof Widget ? centreOf(control) : null; + }; /** * The visible text of the overlay layer, in drawing order – everything an open popup shows. * Menus hide filtered-out entries, so this is also what a search box currently leaves. @@ -134,6 +158,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E objectsOf, sceneObjects: () => (state.activeScene ? objectsOf(state.activeScene.id) : []), locate, + inspectorField: fieldOf, textColor, overlayTexts, overlayRect, @@ -188,6 +213,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E dropPreview: () => containerEditor.dropInfo(), }, world: () => editor.worldSnapshot(), + /** Behaviors running without Play (`Run in editor`), and how long they have been at it. */ + editorRun: () => editor.editorRunInfo(), /** Rows the hierarchy shows – collapsed subtrees are not in here. */ hierarchyOrder: () => hierarchy.rowOrder(), hierarchyChevron: (id: string) => hierarchy.chevronPosition(id), diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index f6e01339..ccbb1f8d 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -33,6 +33,7 @@ import { COMMON_PROPS, isCommonProp, type PropertyDef, + schemaFor, schemaForAsset, } from '../../core/properties/PropertySchema'; import { @@ -167,8 +168,13 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object } // The master switch, right where it is looked for: above everything type-specific. - for (const def of COMMON_PROPS) - propertyRow(host, holderId, objectId, asset, def, resolveSceneObjectProps(asset, object)); + const resolved = resolveSceneObjectProps(asset, object); + for (const def of COMMON_PROPS) propertyRow(host, holderId, objectId, asset, def, resolved); + // A behavior's own switches sit with it rather than in the section below: that one lists what + // the behavior *declares*, and `Run in editor` is not something it declared. + if (asset.type === 'behavior') + for (const def of schemaFor('behavior').filter((d) => !isCommonProp(d.key))) + propertyRow(host, holderId, objectId, asset, def, resolved); addTitle(host, object.parentId ? 'POSITION (LOCAL TO PARENT)' : 'POSITION'); for (const axis of POSITION_AXES) { @@ -230,10 +236,12 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object }); } - // Asset properties (or behavior variables) with overrides; the common switch is shown above. - const schema = schemaForAsset(asset); + // Asset properties (or behavior variables) with overrides; the switches are shown above. const values = resolveSceneObjectProps(asset, object); - const own = schema.filter((def) => !isCommonProp(def.key)); + const own = + asset.type === 'behavior' + ? asset.variables + : schemaForAsset(asset).filter((def) => !isCommonProp(def.key)); if (own.length > 0 || asset.type === 'behavior') addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES'); if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.'); diff --git a/src/editor/state.ts b/src/editor/state.ts index 39436aca..0ec90194 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -201,6 +201,15 @@ export class EditorState { this.emit('runtime'); } + /** + * A behavior running outside play mode (see `EditorRun`) wrote into the saved model. The change + * is saved like any other, but nothing was pushed onto the undo stack – it cannot be undone. + */ + runtimeEdit(): void { + this.setSaveStatus('dirty'); + this.emit('runtime'); + } + private markDirty(): void { this.validateSelection(); // Play-mode edits live in the runtime copy and are never saved. diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 4ba59449..3dd052bc 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -13,6 +13,21 @@ import { resolveSceneObjectProps } from '../core/properties/resolve'; import type { Entity } from './Entity'; import type { System } from './systems/System'; +export interface WorldOptions { + /** + * Whether a placed container's content may be written into the scene (see `syncFromModel`). + * Baking is a real, irreversible edit, so the editor run switches it off – at the price of + * behaviors inside a placed container not running there. + */ + bakeContainers?: boolean; +} + +/** What `syncToModel` wrote: `structure` = objects appeared or vanished, `values` = the rest. */ +export interface ModelWrite { + structure: boolean; + values: boolean; +} + /** * The running game: entities instantiated from one scene plus the systems ticking them. * @@ -51,12 +66,13 @@ export class World { private constructor( readonly project: Project, readonly sceneId: string, + private readonly bakeContainers: boolean, ) {} /** Builds a world from the given scene. Throws when the scene does not exist. */ - static instantiate(project: Project, sceneId: string): World { + static instantiate(project: Project, sceneId: string, options: WorldOptions = {}): World { if (!findScene(project, sceneId)) throw new Error(`Scene ${sceneId} not found`); - const world = new World(project, sceneId); + const world = new World(project, sceneId, options.bakeContainers ?? true); world.syncFromModel(); return world; } @@ -80,7 +96,7 @@ export class World { // Entities are built from stored objects, and a behavior may move or destroy one of them – // which content resolved from a container template could not remember. So it is written out // once, into the play-mode copy of the model that is thrown away afterwards. - if (bakeInstances(this.project, scene)) this.structureChanged = true; + if (this.bakeContainers && bakeInstances(this.project, scene)) this.structureChanged = true; const background = scene.props.backgroundColor; this.backgroundColor = typeof background === 'number' ? background : 0x000000; const keep = new Set(); @@ -163,12 +179,12 @@ export class World { /** * Writes the entities' state into the scene: positions, property values that differ from * what the model resolves (stored as overrides, so the inspector marks them) and removed - * entities (their objects are dropped). Returns true when the object list changed – by a - * destroyed entity here or by a `spawn` since the last call. + * entities (their objects are dropped). */ - syncToModel(): boolean { + syncToModel(): ModelWrite { const scene = findScene(this.project, this.sceneId); - if (!scene) return false; + if (!scene) return { structure: false, values: false }; + let values = false; const spawned = this.structureChanged; this.structureChanged = false; const alive = new Set(this.entities.map((e) => e.objectId)); @@ -183,13 +199,18 @@ export class World { const asset = object && findAsset(this.project, entity.assetId); if (!object || !asset) continue; const p = object.position; - if (p.x !== entity.x || p.y !== entity.y || p.z !== entity.z) + if (p.x !== entity.x || p.y !== entity.y || p.z !== entity.z) { object.position = { x: entity.x, y: entity.y, z: entity.z }; + values = true; + } const base = resolveSceneObjectProps(asset, object); for (const [key, value] of Object.entries(entity.props)) - if (key in base && base[key] !== value) object.overrides[key] = value; + if (key in base && base[key] !== value) { + object.overrides[key] = value; + values = true; + } } - return spawned || scene.objects.length !== before; + return { structure: spawned || scene.objects.length !== before, values }; } // --- simulation ------------------------------------------------------------------------------ diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index f23bdc50..dac43b67 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -41,6 +41,11 @@ export class BehaviorSystem implements System { private readonly input: Input, /** Source of this frame's collision events; null = no collision detection. */ private readonly collisions: CollisionSystem | null = null, + /** + * Which behaviors this system is responsible for. Playing runs them all; the editor run + * takes only those marked "run in editor" (see `properties/editorRun.ts`). + */ + private readonly shouldRun: (entity: Entity) => boolean = () => true, ) {} start(world: World): void { @@ -66,6 +71,7 @@ export class BehaviorSystem implements System { if ( entity.type !== 'behavior' || !isHandleEnabled(entity) || + !this.shouldRun(entity) || this.instances.some((i) => i.entity === entity) ) continue; @@ -89,6 +95,7 @@ export class BehaviorSystem implements System { for (const instance of this.instances) { if (!world.entities.includes(instance.entity)) continue; // destroyed during play if (!isHandleEnabled(instance.entity)) continue; // switched off (itself or a parent) + if (!this.shouldRun(instance.entity)) continue; // "run in editor" taken back while running this.run( instance, (i, ctx) => { @@ -141,6 +148,11 @@ export class BehaviorSystem implements System { fire('OnCollisionExit', this.collisions.exits); } + /** Object ids of the behaviors this system is currently running. */ + runningIds(): string[] { + return this.instances.map((i) => i.entity.objectId); + } + /** Live highlight data for the graph editor: merged over all instances of the asset. */ traceFor(assetId: string): Trace { const merged: Trace = { edges: new Map(), nodes: new Map() }; diff --git a/tests/core/container.test.ts b/tests/core/container.test.ts index 396d61a7..c5524ac2 100644 --- a/tests/core/container.test.ts +++ b/tests/core/container.test.ts @@ -21,6 +21,7 @@ import { descendantsOf, subtreeRoots, treeOrder } from '../../src/core/model/hie import { instanceChildId } from '../../src/core/model/instance'; import { findAsset, findScene } from '../../src/core/model/Project'; import { createSceneObject } from '../../src/core/model/SceneObject'; +import { World } from '../../src/runtime/World'; import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers'; /** Three boxes: a at (100,100,z1), b at (10,0) and c at (0,5) parented under b (local (-10,5)). */ @@ -202,10 +203,24 @@ describe('placing a container', () => { const grandchild = world.entities.find((e) => e.parent && children.includes(e.parent))!; expect(worldPositionOf(children.find((e) => e.z === 1)!)).toEqual({ x: 545, y: 550, z: 1 }); expect(worldPositionOf(grandchild)).toEqual({ x: 445, y: 455, z: 0 }); - expect(world.syncToModel()).toBe(true); + expect(world.syncToModel().structure).toBe(true); // Play bakes the placed container into stored objects, and the spawn adds a second set. expect(f.sceneOf().objects).toHaveLength(8); }); + + it('leaves the instance alone when baking is off, so the editor run cannot spend it', () => { + const f = templateSetup(); + f.stack.execute(new SetOverrideCommand(f.sceneId, f.child(f.tA), 'width', 12)); + expect(f.sceneOf().objects[0]!.instanceEdits[f.tA]?.overrides.width).toBe(12); + + // Baking would write the content out as plain objects and drop the edits – a real, silent + // change to the saved project, which is why the editor run instantiates without it. + const world = World.instantiate(f.project, f.sceneId, { bakeContainers: false }); + world.tick(1 / 60); + expect(world.syncToModel().structure).toBe(false); + expect(f.sceneOf().objects).toHaveLength(1); + expect(f.sceneOf().objects[0]!.instanceEdits[f.tA]).toBeDefined(); + }); }); describe('editing a container template', () => { diff --git a/tests/core/enabled.test.ts b/tests/core/enabled.test.ts index 5962d11d..b9814717 100644 --- a/tests/core/enabled.test.ts +++ b/tests/core/enabled.test.ts @@ -38,7 +38,7 @@ function wallAndMover() { describe('the enabled switch', () => { it('is a common property of every placeable type, defaults to on and overrides per object', () => { expect(schemaFor('sprite')[0]).toMatchObject({ key: 'enabled', kind: 'boolean', default: true }); - expect(schemaFor('behavior').map((d) => d.key)).toEqual(['enabled']); + expect(schemaFor('behavior').map((d) => d.key)).toEqual(['enabled', 'runInEditor']); expect(schemaFor('scene').some((d) => d.key === 'enabled')).toBe(false); const f = makeSpriteScene(); diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index 5bfa4a65..744855af 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -307,7 +307,7 @@ describe('variable commands', () => { stack.execute(new RenameVariableCommand(behavior.id, 'speed', 'velocity')); expect(behavior.variables[0]!.key).toBe('velocity'); - expect(behavior.props).toEqual({ enabled: true, velocity: 0 }); + expect(behavior.props).toEqual({ enabled: true, runInEditor: false, velocity: 0 }); expect(object(logic).overrides).toEqual({ velocity: 5 }); expect(findNode(behavior.graph, nodes.speed.id)!.params.variable).toBe('velocity'); stack.undo(); diff --git a/tests/core/world-sync.test.ts b/tests/core/world-sync.test.ts index f4d0826e..24cad15a 100644 --- a/tests/core/world-sync.test.ts +++ b/tests/core/world-sync.test.ts @@ -58,7 +58,9 @@ describe('world ↔ model synchronisation (editing while playing)', () => { entityA.x = 123; entityA.props.width = 999; // what a Set property node would do entityA.props.height = 100; // unchanged → no override - expect(world.syncToModel()).toBe(false); + // No object appeared or vanished, but a position and an override were written – which is + // what tells the editor run that it has something to save. + expect(world.syncToModel()).toEqual({ structure: false, values: true }); expect(object(a).position).toEqual({ x: 123, y: 0, z: 0 }); expect(object(a).overrides).toEqual({ width: 999 }); @@ -74,7 +76,7 @@ describe('world ↔ model synchronisation (editing while playing)', () => { // Destroy (node) removes the entity; the sync drops its object – a structural change. world.remove(world.findByObjectId(b)!); - expect(world.syncToModel()).toBe(true); + expect(world.syncToModel().structure).toBe(true); expect(sceneOf().objects.map((o) => o.id)).toEqual([a]); }); @@ -97,8 +99,8 @@ describe('world ↔ model synchronisation (editing while playing)', () => { expect(object.position).toEqual({ x: 10, y: 20, z: 1 }); // The spawn is a structural change the panels must see – reported once. - expect(world.syncToModel()).toBe(true); - expect(world.syncToModel()).toBe(false); + expect(world.syncToModel()).toEqual({ structure: true, values: true }); + expect(world.syncToModel()).toEqual({ structure: false, values: false }); // A full sync from the model keeps the spawned entity (identity included). world.syncFromModel(); expect(world.findByObjectId(child.objectId)).toBe(child); @@ -106,7 +108,7 @@ describe('world ↔ model synchronisation (editing while playing)', () => { // Destroying the spawned entity drops its object again. world.remove(child); - expect(world.syncToModel()).toBe(true); + expect(world.syncToModel().structure).toBe(true); expect(sceneOf().objects.some((o) => o.id === child.objectId)).toBe(false); // Unknown or unplaceable assets spawn nothing. diff --git a/tests/editor/editor-run.test.ts b/tests/editor/editor-run.test.ts new file mode 100644 index 00000000..81402d63 --- /dev/null +++ b/tests/editor/editor-run.test.ts @@ -0,0 +1,148 @@ +/** + * "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); + }); +}); diff --git a/todo-notes/done/execute-behavior-in-editor-mode.md b/todo-notes/done/execute-behavior-in-editor-mode.md new file mode 100644 index 00000000..09b07bcf --- /dev/null +++ b/todo-notes/done/execute-behavior-in-editor-mode.md @@ -0,0 +1,92 @@ +ein toggle bei einem behavior object in einer scene (passender name fehlt mir noch): +- wenn der toggle aktiviert ist, soll das behavior außerhalb des play modes trotzdem laufen (sprich immer) +- das behavior ist natürlich nicht aktiv wenn die scene nicht geladen ist wo sich das behavior befindet (sprich genauso wie im play mode, nur ohne play mode) + +--- + +**Umgesetzt (2026-09-09).** Der Schalter heisst **`Run in editor`** und steht im Inspector eines +Behavior-Objekts direkt unter `Enabled`. Ist er an, laeuft das Behavior weiter, waehrend das +Projekt *nicht* spielt (`src/editor/EditorRun.ts`, vom Editor neben der Play-Session getickt). + +## Die eine Entscheidung, die der Schalter erzwingt + +Play arbeitet auf einer **Wegwerf-Kopie** — deshalb ist Spielen fuer das gespeicherte Projekt +gefahrlos. Ausserhalb von Play gibt es diese Kopie nicht, und damit stand die Frage im Raum, was +ein laufendes Behavior ueberhaupt anfassen darf. Entschieden wurde (Rueckfrage an den User) fuer +die **Unity-Semantik** (`ExecuteInEditMode`): + +> Was das Behavior bewegt oder schreibt, ist eine **echte Aenderung am gespeicherten Modell**. +> Sie wird autosaved, die Panels zeigen sie — und **Undo holt sie nicht zurueck**. + +Das macht den Schalter maechtig (ein Behavior kann Inhalt bauen oder anordnen) und ist genau der +Grund, warum er **standardmaessig aus** ist. Der E2E-Check haelt beide Haelften fest: der Server +liefert die verschobene Position aus, und nach zwei Undos ist der Schalter weg, die Bewegung nicht. + +## Was der Editor-Lauf bewusst *nicht* bekommt + +| | | +|---|---| +| **Eingaben** | Tastatur und Maus bleiben beim Editor, sonst wuerde Tippen in einem Textfeld Behaviors ausloesen. `On key …`, `On click` und `Is key down` feuern hier nie; `On start`, `On update`, `On timer` und Kollisionen schon. | +| **Container-Inhalt** | `syncFromModel` schreibt den Inhalt platzierter Container als normale Objekte aus (*baking*) und loescht dabei deren `instanceEdits` — im Editor waere das eine stille, irreversible Aenderung. Der Lauf instanziiert deshalb mit `bakeContainers: false`. **Preis:** ein Behavior *innerhalb* eines platzierten Containers laeuft ausserhalb von Play nicht. | +| **ein zweites Leben** | Nur die Behaviors der **geladenen** Szene laufen, und nur im Edit-Modus. Play stoppt den Editor-Lauf — dort laeuft ohnehin jedes Behavior. Eine Exception im Graphen oder ein `Stop game`-Node beendet den Lauf und schreibt den Grund in die Notice-Zeile, statt ihn 60× pro Sekunde zu wiederholen. | + +Der zweite Punkt der Notiz faellt damit von selbst heraus: die Welt wird aus der aktiven Szene +gebaut, ein Behavior einer anderen Szene hat gar keine Entity. Beim Szenenwechsel wird der Lauf +neu aufgebaut oder verworfen. + +## Kein Save-Sturm + +Ein Behavior, das jeden Frame etwas verschiebt, macht das Modell jeden Frame schmutzig — 60× +pro Sekunde speichern waere die naheliegende Katastrophe. Zwei Dinge verhindern das: + +- `syncToModel()` meldet jetzt, **ob** ueberhaupt geschrieben wurde (`{structure, values}` statt + eines blossen `boolean`). Ein Behavior, das nur liest oder wartet, macht gar nichts schmutzig. +- `setSaveStatus('dirty')` emittiert nur bei **Wechsel**, der Autosave-Timer wird also nicht jeden + Frame neu gestellt. Gemessen: **2 Speicherungen in 2 Sekunden**, nicht ~120. + +## Ein Darstellungsfehler, den erst der Screenshot gezeigt hat + +`schemaForAsset` liefert fuer ein Behavior `COMMON_PROPS + Typschema + Variablen`. Der +Object-Inspector zeigte alles ausser `enabled` unter der Ueberschrift **VARIABLES** — der neue +Schalter landete also dort, wo nur steht, was das Behavior *deklariert*, und die Notiz „The +behavior declares no variables." verschwand fuer immer. Beides ist repariert: die Typschema-Zeilen +eines Behaviors stehen jetzt bei `Enabled`, die Sektion darunter fuehrt wieder nur die Variablen. +Aufgefallen ist es nur, weil ich mir den Screenshot angesehen habe — die Checks waren zufrieden. + +## Ein Messgeraet statt eines geratenen Offsets + +Der erste Anlauf klickte die Checkbox ueber `locate('Enabled').x + 55`. Das trifft nur Zeilen, +deren Label in die 64-px-Spalte passt — `Run in editor` ist breiter und schiebt seine Checkbox +11 px nach rechts, also ging der Klick daneben und fuenf Checks fielen um. Neu ist deshalb +`__gm.inspectorField(label)`: es liefert die Bildschirmmitte des **Controls** einer Inspector- +Zeile, gefunden ueber sein Label. Damit ist jeder kuenftige Inspector-Check unabhaengig von der +Labelbreite. + +## Belegt + +Acht Unit-Tests (`tests/editor/editor-run.test.ts`, plus einer in `container.test.ts` fuer das +Nicht-Backen). Gegengeprueft: dreht man den Filter auf „alle Behaviors" und die Szenen-Bedingung +weg, fallen **vier von sieben** um; mit `bakeContainers: true` faellt der Container-Test. +Dazu zehn E2E-Checks in Modul 17: + +``` +PASS a placed behavior does not run outside play mode until it is asked to + – {"running":false,"time":0,"behaviors":[]} x=0 +PASS the inspector offers the switch on a behavior object, with Enabled and above the variables + – {"enabledBox":{"x":1070,"y":221},"runBox":{"x":1081,"y":253},"variables":{…,"y":509.5}} +PASS switching it on is an ordinary override on the object – {"runInEditor":true} +PASS the behavior runs although Play was never pressed + – {"running":true,"time":0.46,"behaviors":["er-logic"]} mode=edit +PASS the owner drifts on, and the moved object is the stored one – x 48.33 → 108.32 +PASS what the behavior moved is saved, not just shown – stored x=308.31 (started at 0) +PASS saving stays debounced while it runs, instead of once per frame – 2 saves in 2 s +PASS taking the switch back stops the run and the object stands still – x=329.98 +PASS undo takes back the switch, never the movement it caused – overrides={} x=351.65, started at 0 +PASS the step leaves nothing of its own behind +``` + +Der Schritt baut sich seine eigene Kiste samt Behavior (`On update → Move(owner)`) statt den +Mover der Fixture zu nehmen — der laeuft ueber die Pfeiltasten, und die sieht der Editor-Lauf +absichtlich nie. Am Ende raeumt er alles wieder weg. + +Screenshot `36-editor-run`. diff --git a/todo-notes/execute-behavior-in-editor-mode.md b/todo-notes/execute-behavior-in-editor-mode.md deleted file mode 100644 index 0ae8b003..00000000 --- a/todo-notes/execute-behavior-in-editor-mode.md +++ /dev/null @@ -1,3 +0,0 @@ -ein toggle bei einem behavior object in einer scene (passender name fehlt mir noch): -- wenn der toggle aktiviert ist, soll das behavior außerhalb des play modes trotzdem laufen (sprich immer) -- das behavior ist natürlich nicht aktiv wenn die scene nicht geladen ist wo sich das behavior befindet (sprich genauso wie im play mode, nur ohne play mode) diff --git a/tools/e2e/17-editor-run.mjs b/tools/e2e/17-editor-run.mjs new file mode 100644 index 00000000..740b3927 --- /dev/null +++ b/tools/e2e/17-editor-run.mjs @@ -0,0 +1,180 @@ +import { edge, node } from '../lib/builders.mjs'; + +/** + * "Run in editor": a behavior that keeps running while the project is not playing, and whose + * work lands in the saved project. + * + * The step brings its own box and its own behavior instead of using the fixture's Mover: that one + * moves along the arrow keys, and the editor run deliberately never sees the keyboard. Everything + * it adds is removed again at the end. + */ + +const DRIFT_PER_SECOND = 100; + +/** `On update → Move(owner, dx = 1, scale = 100)`: the owner drifts right, without any input. */ +const driftGraph = { + nodes: [ + node('d-update', 'OnUpdate', 0, 0), + node('d-owner', 'Owner', 0, 120), + node('d-move', 'Move', 340, 0, { dx: 1, scale: DRIFT_PER_SECOND }), + ], + edges: [ + edge('d-e1', 'd-update', 'exec', 'd-move', 'exec'), + edge('d-e2', 'd-update', 'dt', 'd-move', 'dt'), + edge('d-e3', 'd-owner', 'object', 'd-move', 'object'), + ], +}; + +export async function editorRun(t) { + const { check, sleep, evaluate, json, shot, click, mutate, undo, object, api, projectId } = t; + + // 18a. A box with a drifting behavior below it. Written into the model directly: what is under + // test is the switch, not another way of building a graph. + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + const proto = scene.objects[0]; + const asset = structuredClone(p.assets.find((a) => a.id === 'mover')); + Object.assign(asset, { + id: 'drifter', + name: 'Drifter', + props: { enabled: true, runInEditor: false }, + variables: [], + graph: ${JSON.stringify(driftGraph)}, + }); + p.assets.push(asset); + const make = (id, assetId, parentId, y) => { + const o = structuredClone(proto); + Object.assign(o, { + id, + assetId, + name: id === 'er-box' ? 'Drift box' : 'Drifter', + position: { x: 0, y, z: 0 }, + parentId, + overrides: {}, + instanceEdits: {}, + }); + return o; + }; + scene.objects.push(make('er-box', 'crate', '', 400), make('er-logic', 'drifter', 'er-box', 0)); + `); + await sleep(400); + + const boxX = async () => (await object('er-box')).position.x; + const startX = await boxX(); + await sleep(500); + const idle = await json('__gm.editorRun()'); + check( + 'a placed behavior does not run outside play mode until it is asked to', + idle.running === false && (await boxX()) === startX, + `${JSON.stringify(idle)} x=${startX}`, + ); + + // 18b. The switch itself, through the inspector. It belongs with Enabled and above the + // VARIABLES section: it is a switch of the behavior, not something the behavior declares. + await evaluate( + `__gm.state.select({ kind: 'object', holderId: __gm.state.project.activeSceneId, objectId: 'er-logic' })`, + ); + await sleep(200); + const enabledBox = await json(`__gm.inspectorField('Enabled')`); + const runBox = await json(`__gm.inspectorField('Run in editor')`); + const variables = await json(`__gm.locate('VARIABLES')`); + check( + 'the inspector offers the switch on a behavior object, with Enabled and above the variables', + Boolean(runBox) && + Boolean(enabledBox) && + Boolean(variables) && + runBox.y > enabledBox.y && + runBox.y < variables.y, + JSON.stringify({ enabledBox, runBox, variables }), + ); + await click(runBox.x, runBox.y); + await sleep(300); + + const logic = await object('er-logic'); + check( + 'switching it on is an ordinary override on the object', + logic.overrides.runInEditor === true, + JSON.stringify(logic.overrides), + ); + + const running = await json('__gm.editorRun()'); + const mode = await evaluate('__gm.state.mode'); + check( + 'the behavior runs although Play was never pressed', + running.running === true && running.behaviors.join() === 'er-logic' && mode === 'edit', + `${JSON.stringify(running)} mode=${mode}`, + ); + + // 18c. It really drives its owner, and it is the stored scene object that moves – no runtime + // copy is involved here, which is the whole difference to playing. + const before = await boxX(); + await sleep(600); + const after = await boxX(); + check( + 'the owner drifts on, and the moved object is the stored one', + after - before > DRIFT_PER_SECOND * 0.3 && (await evaluate('__gm.state.mode')) === 'edit', + `x ${before} → ${after}`, + ); + await shot('36-editor-run'); + + // 18d. The point of this semantic: the change is a real edit, so it reaches the server. + const first = await api(`projects/${projectId}`); + await sleep(2000); + const second = await api(`projects/${projectId}`); + const savedX = second.project.assets.find((a) => a.type === 'scene').objects.find((o) => o.id === 'er-box') + .position.x; + check( + 'what the behavior moved is saved, not just shown', + savedX > startX, + `stored x=${savedX} (started at ${startX})`, + ); + // A behavior writing every frame must not turn into a save per frame. That it saves at all is + // the check above; this one is only about the ceiling – 60 frames a second would be ~120. + const saves = second.revision - first.revision; + check( + 'saving stays debounced while it runs, instead of once per frame', + saves <= 6, + `${saves} saves in 2 s`, + ); + + // 18e. Taking the switch back stops it again. + await click(runBox.x, runBox.y); + await sleep(300); + const stoppedAt = await boxX(); + await sleep(500); + const stopped = await json('__gm.editorRun()'); + check( + 'taking the switch back stops the run and the object stands still', + stopped.running === false && (await boxX()) === stoppedAt, + `${JSON.stringify(stopped)} x=${stoppedAt}`, + ); + + // 18f. Undo covers the two clicks on the switch – but not a millimetre of what the behavior did + // while it was on. That is the price of editing the model directly, better stated here than + // discovered later. Note the first undo takes back the *switching off*, so the behavior + // briefly runs again between the two: undo rewinds the switch, never the clock. + await undo(); + await undo(); + await sleep(300); + const afterUndo = await object('er-box'); + check( + 'undo takes back the switch, never the movement it caused', + (await object('er-logic')).overrides.runInEditor === undefined && + afterUndo.position.x >= stoppedAt && + afterUndo.position.x > startX, + `overrides=${JSON.stringify((await object('er-logic')).overrides)} x=${afterUndo.position.x}, started at ${startX}`, + ); + + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + scene.objects = scene.objects.filter((o) => o.id !== 'er-box' && o.id !== 'er-logic'); + p.assets = p.assets.filter((a) => a.id !== 'drifter'); + `); + await sleep(300); + check( + 'the step leaves nothing of its own behind', + (await json(`__gm.sceneObjects().filter((o) => o.id.startsWith('er-')).length`)) === 0, + ); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 168fa57f..770d9f31 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -19,6 +19,7 @@ import { frames } from './13-frames.mjs'; import { overflow } from './14-overflow.mjs'; import { aspect } from './15-aspect.mjs'; import { containerTab } from './16-container.mjs'; +import { editorRun } from './17-editor-run.mjs'; export const STEPS = [ hierarchy, @@ -37,4 +38,5 @@ export const STEPS = [ overflow, aspect, containerTab, + editorRun, ];