Hold a running game, and let the editor run reach into containers

Pause is a sub-state of play, not a third mode: the toolbar button and
Ctrl+Enter stop ticking the world, the clock included. A held frame still
drops the input edges, so resuming does not fire a pause worth of keys in
one go.

The editor run missed the case it is most wanted for. hasEditorRun only
looked at the stored scene objects, so a behavior inside a placed
container never started one; the world now builds from resolveObjects and
writes back through objectRef, keeping the move as an instance edit
instead of baking the instance out.

Keys and clicks go to the run while the Game tab is on screen, where no
key means anything to the editor, and are handed back on leaving it -
held keys released, so nothing keeps walking unpressed.
This commit is contained in:
root committed 2026-09-09 18:16:28 +02:00
1 parent 8f31afac37
commit 9c4148dc1b
19 files changed
+838 -78

No files matched your search

+35 -28
View File
@@ -2,8 +2,8 @@ import { isHandleEnabled, type PointerState, worldPositionOf } from '../core/gra
import { newId } from '../core/ids';
import type { Asset } from '../core/model/Asset';
import { isPlaceableType } from '../core/model/AssetTypes';
import { bakeInstances, materializeInstance } from '../core/model/instance';
import { findObject, treeOrder } from '../core/model/hierarchy';
import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance';
import { treeOrder } from '../core/model/hierarchy';
import { descendantsBelow } from '../core/model/walk';
import { findAsset, findScene, type Project } from '../core/model/Project';
import { createSceneObject, type SceneObject } from '../core/model/SceneObject';
@@ -16,8 +16,8 @@ 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.
* Baking is a real, irreversible edit, so the editor run switches it off; its content still runs,
* but what it writes goes into the instance edits and it cannot destroy itself for good.
*/
bakeContainers?: boolean;
}
@@ -31,11 +31,11 @@ export interface ModelWrite {
/**
* The running game: entities instantiated from one scene plus the systems ticking them.
*
* The world mirrors the scene of the project it was built from – in the editor that is the
* play-mode *copy* of the model, never the saved one. `syncFromModel()` pulls editor changes
* (moved, placed, removed or re-parented objects, edited properties) into the entities;
* `syncToModel()` pushes what the systems changed (positions, variables, destroyed entities)
* back into that copy, so the panels show live values. Stop throws both away.
* The world mirrors the scene of the project it was built from: while playing that is a copy of
* the model, thrown away on stop; the editor run builds on the saved one, where the writes stay.
* `syncFromModel()` pulls editor changes (moved, placed, removed or re-parented objects, edited
* properties) into the entities; `syncToModel()` pushes what the systems changed (positions,
* variables, destroyed entities) back, so the panels show live values.
*/
export class World {
readonly entities: Entity[] = [];
@@ -93,14 +93,15 @@ export class World {
syncFromModel(): void {
const scene = findScene(this.project, this.sceneId);
if (!scene) return;
// 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.
// Baking spells a placed container's content out as stored objects, which is what lets a
// behavior destroy one of them for good. Play does it (on its throwaway copy); the editor run
// must not, so there the content stays resolved and `objectRef` writes into the instance edits.
if (this.bakeContainers && bakeInstances(this.project, scene)) this.structureChanged = true;
const background = scene.props.backgroundColor;
this.backgroundColor = typeof background === 'number' ? background : 0x000000;
const objects = new Map(resolveObjects(this.project, scene).map((o) => [o.id, o]));
const keep = new Set<Entity>();
for (const object of scene.objects) {
for (const object of objects.values()) {
const asset = findAsset(this.project, object.assetId);
if (!asset || !isPlaceableType(asset.type)) continue;
keep.add(this.materialize(object, asset));
@@ -109,7 +110,7 @@ export class World {
if (!keep.has(this.entities[i]!)) this.entities.splice(i, 1);
// Link parents once every entity exists (storage order is not guaranteed to be parent-first).
for (const entity of this.entities) {
const object = findObject(scene, entity.objectId);
const object = objects.get(entity.objectId);
entity.parent = object?.parentId ? (this.findByObjectId(object.parentId) ?? null) : null;
}
}
@@ -179,7 +180,7 @@ 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).
* entities (their objects are dropped – only stored ones, see `WorldOptions.bakeContainers`).
*/
syncToModel(): ModelWrite {
const scene = findScene(this.project, this.sceneId);
@@ -194,21 +195,22 @@ export class World {
const asset = findAsset(this.project, object.assetId);
if (asset && isPlaceableType(asset.type) && !alive.has(object.id)) scene.objects.splice(i, 1);
}
for (const entity of this.entities) {
const object = findObject(scene, entity.objectId);
const asset = object && findAsset(this.project, entity.assetId);
if (!object || !asset) continue;
for (const object of resolveObjects(this.project, scene)) {
const entity = this.findByObjectId(object.id);
const asset = entity && findAsset(this.project, entity.assetId);
if (!entity || !asset) continue;
const p = object.position;
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 moved = p.x !== entity.x || p.y !== entity.y || p.z !== entity.z;
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;
values = true;
}
const drifted = Object.keys(entity.props).filter((k) => k in base && base[k] !== entity.props[k]);
if (!moved && drifted.length === 0) continue;
// Only now, because a ref for resolved content costs a resolve of its own – and because a
// direct write to such an object would land in the freshly built copy and be lost.
const ref = objectRef(this.project, scene, object.id);
if (!ref) continue;
if (moved) ref.setPosition({ x: entity.x, y: entity.y, z: entity.z });
for (const key of drifted) ref.setOverride(key, entity.props[key]);
values = true;
}
return { structure: spawned || scene.objects.length !== before, values };
}
@@ -227,6 +229,11 @@ export class World {
this.elapsed += dt;
for (const system of this.systems) system.update(this, dt);
// The pointer edges belong to exactly one tick, whatever the systems did with them.
this.dropPointerEdges();
}
/** Forgets a pending click edge without running a tick (used while paused). */
dropPointerEdges(): void {
this.pointer.pressed = false;
this.pointer.released = false;
}