Editing while playing: play runs on a runtime copy, new Game tab (Scene | Game | Graph)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-07 16:34:26 +02:00
1 parent cb8885359f
commit c3a533c4e5
14 files changed
+408 -118

No files matched your search

+77 -30
View File
@@ -6,11 +6,16 @@ import type { System } from './systems/System';
/**
* The running game: entities instantiated from one scene plus the systems ticking them.
* Created on Play, thrown away on Stop – the project model is never touched.
*
* 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.
*/
export class World {
readonly entities: Entity[] = [];
readonly backgroundColor: number;
backgroundColor = 0x000000;
/** Set by a behavior (Stop game node); the editor leaves play mode on the next tick. */
stopRequested = false;
/** Messages logged by behaviors (Log node), newest last. */
@@ -18,40 +23,82 @@ export class World {
private readonly systems: System[] = [];
private elapsed = 0;
private constructor(readonly sceneId: string, backgroundColor: number) {
this.backgroundColor = backgroundColor;
}
private constructor(readonly project: Project, readonly sceneId: string) {}
/** Builds a world from the given scene. Throws when the scene does not exist. */
static instantiate(project: Project, sceneId: string): World {
const scene = findScene(project, sceneId);
if (!scene) throw new Error(`Scene ${sceneId} not found`);
const background = scene.props.backgroundColor;
const world = new World(sceneId, typeof background === 'number' ? background : 0x000000);
const parentIds = new Map<Entity, string>();
for (const object of scene.objects) {
const asset = findAsset(project, object.assetId);
if (!asset || asset.type === 'scene') continue;
const entity: Entity = {
id: `e-${object.id}`,
objectId: object.id,
assetId: asset.id,
type: asset.type,
name: object.name || asset.name,
x: object.position.x,
y: object.position.y,
z: object.position.z,
props: resolveSceneObjectProps(asset, object),
parent: null,
};
world.entities.push(entity);
if (object.parentId) parentIds.set(entity, object.parentId);
}
// Link parents once every entity exists (storage order is not guaranteed to be parent-first).
for (const [entity, parentId] of parentIds) entity.parent = world.findByObjectId(parentId) ?? null;
if (!findScene(project, sceneId)) throw new Error(`Scene ${sceneId} not found`);
const world = new World(project, sceneId);
world.syncFromModel();
return world;
}
/**
* Makes the entities match the scene: one entity per placed object (created on demand,
* dropped when the object is gone), positions, names and resolved properties taken from
* the model, parent links re-established. Property bags are updated in place so systems
* holding a reference keep seeing the current values.
*/
syncFromModel(): void {
const scene = findScene(this.project, this.sceneId);
if (!scene) return;
const background = scene.props.backgroundColor;
this.backgroundColor = typeof background === 'number' ? background : 0x000000;
const keep = new Set<Entity>();
for (const object of scene.objects) {
const asset = findAsset(this.project, object.assetId);
if (!asset || asset.type === 'scene') continue;
let entity = this.findByObjectId(object.id);
if (!entity) {
entity = { id: `e-${object.id}`, objectId: object.id, assetId: asset.id, type: asset.type, name: '', x: 0, y: 0, z: 0, props: {}, parent: null };
this.entities.push(entity);
}
entity.assetId = asset.id;
entity.type = asset.type;
entity.name = object.name || asset.name;
entity.x = object.position.x;
entity.y = object.position.y;
entity.z = object.position.z;
const resolved = resolveSceneObjectProps(asset, object);
for (const key of Object.keys(entity.props)) if (!(key in resolved)) delete entity.props[key];
Object.assign(entity.props, resolved);
keep.add(entity);
}
for (let i = this.entities.length - 1; i >= 0; i--) 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 = scene.objects.find((o) => o.id === entity.objectId);
entity.parent = object?.parentId ? this.findByObjectId(object.parentId) ?? null : null;
}
}
/**
* 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.
*/
syncToModel(): boolean {
const scene = findScene(this.project, this.sceneId);
if (!scene) return false;
const alive = new Set(this.entities.map((e) => e.objectId));
const before = scene.objects.length;
for (let i = scene.objects.length - 1; i >= 0; i--) {
const object = scene.objects[i]!;
const asset = findAsset(this.project, object.assetId);
if (asset && asset.type !== 'scene' && !alive.has(object.id)) scene.objects.splice(i, 1);
}
for (const entity of this.entities) {
const object = scene.objects.find((o) => o.id === entity.objectId);
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) object.position = { x: entity.x, y: entity.y, 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;
}
return scene.objects.length !== before;
}
/** World position of an entity: local position plus those of all ancestors. */
worldPosition(entity: Entity): { x: number; y: number; z: number } {
return worldPositionOf(entity);