Spawn node: place assets at runtime into the play copy, Spawner demo behavior

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-07 16:57:19 +02:00
1 parent c3a533c4e5
commit 91ee2feadd
13 files changed
+219 -37

No files matched your search

+51 -17
View File
@@ -1,5 +1,9 @@
import { worldPositionOf } from '../core/graph';
import { newId } from '../core/ids';
import type { Asset } from '../core/model/Asset';
import { isPlaceableType } from '../core/model/AssetTypes';
import { findAsset, findScene, type Project } from '../core/model/Project';
import { createSceneObject, type SceneObject } from '../core/model/SceneObject';
import { resolveSceneObjectProps } from '../core/properties/resolve';
import type { Entity } from './Entity';
import type { System } from './systems/System';
@@ -22,6 +26,8 @@ export class World {
readonly log: string[] = [];
private readonly systems: System[] = [];
private elapsed = 0;
/** Set when `spawn` added an object; reported (and cleared) by the next `syncToModel`. */
private structureChanged = false;
private constructor(readonly project: Project, readonly sceneId: string) {}
@@ -48,21 +54,7 @@ export class World {
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);
keep.add(this.materialize(object, asset));
}
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).
@@ -72,14 +64,56 @@ export class World {
}
}
/** The entity for a scene object, created when missing; position, name and props re-read. */
private materialize(object: SceneObject, asset: Asset): Entity {
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);
return entity;
}
/**
* Creates a new instance of an asset while running (Spawn node): the object is added to
* the scene of the runtime copy – so the panels list it and a full sync keeps it – and its
* entity is created right away, without touching the other entities (they may have moved
* during this tick already). Position is local to `parent`, world when there is none.
* Returns null for unknown or unplaceable assets.
*/
spawn(assetId: string, position: { x: number; y: number; z: number }, parent: Entity | null): Entity | null {
const scene = findScene(this.project, this.sceneId);
const asset = findAsset(this.project, assetId);
if (!scene || !asset || !isPlaceableType(asset.type)) return null;
const parentEntity = parent && this.entities.includes(parent) ? parent : null;
const object = createSceneObject(newId(), asset.id, position, parentEntity?.objectId ?? '');
scene.objects.push(object);
const entity = this.materialize(object, asset);
entity.parent = parentEntity;
this.structureChanged = true;
return entity;
}
/**
* 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.
* 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.
*/
syncToModel(): boolean {
const scene = findScene(this.project, this.sceneId);
if (!scene) return false;
const spawned = this.structureChanged;
this.structureChanged = 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--) {
@@ -96,7 +130,7 @@ export class World {
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;
return spawned || scene.objects.length !== before;
}
/** World position of an entity: local position plus those of all ancestors. */
+4
View File
@@ -161,6 +161,10 @@ export class BehaviorSystem implements System {
world.log.push(message);
console.log(`[behavior ${entity.name}]`, message);
},
spawn: (assetId, position, parent) => {
const parentEntity = parent ? world.entities.find((e) => e === parent || e.id === parent.id) ?? null : null;
return world.spawn(assetId, position, parentEntity);
},
destroy: (target: EntityHandle) => {
const found = world.entities.find((e) => e === target || e.id === target.id);
if (found) world.remove(found);