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

+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() };