Let a behavior run while the editor is open

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.
This commit is contained in:
root committed 2026-09-09 17:13:07 +02:00
1 parent c74037b6e1
commit 8f31afac37
21 files changed
+718 -35

No files matched your search

+6 -4
View File
@@ -84,8 +84,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
],
},
],
// 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 {
+2 -2
View File
@@ -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. */
+22
View File
@@ -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));
});
}
+15 -1
View File
@@ -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();
}
}
+113
View File
@@ -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;
}
}
+1 -1
View File
@@ -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();
}
+27
View File
@@ -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),
+13 -5
View File
@@ -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.');
+9
View File
@@ -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.
+31 -10
View File
@@ -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<Entity>();
@@ -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 ------------------------------------------------------------------------------
+12
View File
@@ -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() };