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

+6
View File
@@ -62,6 +62,12 @@ export interface NodeContext {
*/
moveAndSlide(target: EntityHandle, dx: number, dy: number): { blockedX: boolean; blockedY: boolean };
log(message: string): void;
/**
* Places a new instance of an asset into the running scene and returns it. The position is
* local to `parent` (world when none). Returns null when the asset does not exist or cannot
* be placed (a scene). Spawned behaviors run their `On start` on the next tick.
*/
spawn(assetId: string, position: { x: number; y: number; z: number }, parent: EntityHandle | null): EntityHandle | null;
destroy(target: EntityHandle): void;
stop(): void;
}
+16
View File
@@ -346,6 +346,22 @@ registerNode({
return { next: 'exec' };
},
});
registerNode({
type: 'Spawn',
label: 'Spawn',
category: 'action',
description: 'Creates a new instance of an asset in the running scene at (x, y, z) – local to the parent when one is connected, world coordinates otherwise. Outputs the new object.',
inputs: [exec(), num('x'), num('y'), num('z'), obj('parent')],
outputs: [exec(), obj('object')],
// Placeholder options: the graph inspector lists the project's placeable assets.
params: [{ key: 'asset', label: 'Asset', kind: 'enum', default: '', options: [] }],
run: (ctx, i, p) => {
const assetId = String(p.asset ?? '');
const spawned = assetId ? ctx.spawn(assetId, { x: asNumber(i.x), y: asNumber(i.y), z: asNumber(i.z) }, asEntity(i.parent)) : null;
if (!spawned) ctx.log(assetId ? `Spawn: asset ${assetId} not found` : 'Spawn: no asset selected');
return { outputs: { object: spawned }, next: 'exec' };
},
});
registerNode({
type: 'Log',
label: 'Log',
+1 -1
View File
@@ -131,7 +131,7 @@ export class GraphEditor extends Widget {
this.views.set(node.id, view);
this.nodeLayer.addChild(view);
}
view.sync(node, asset.graph, asset);
view.sync(node, asset.graph, asset, this.state.project);
}
for (const [id, view] of this.views) {
if (!seen.has(id)) {
+13 -7
View File
@@ -1,6 +1,8 @@
import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js';
import { edgesInto, type Graph, type GraphNode, inlineInputs, inlineValue, type NodeCategory, type NodeDef, type PortDef, type PortType } from '../../core/graph';
import type { BehaviorAsset } from '../../core/model/Asset';
import { findAsset, type Project } from '../../core/model/Project';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Theme, textStyle, Widget } from '../../ui';
import { keyLabel } from '../inspector/fieldFactory';
@@ -59,7 +61,7 @@ export class NodeView extends Widget {
super();
this.cursor = 'grab';
this.addChild(this.box, this.ports);
this.sync(node, { nodes: [node], edges: [] }, null);
this.sync(node, { nodes: [node], edges: [] }, null, null);
}
get titleText(): string {
@@ -72,9 +74,9 @@ export class NodeView extends Widget {
this.hitArea = new Rectangle(-PORT_HIT_RADIUS, 0, w + PORT_HIT_RADIUS * 2, h);
}
/** Re-reads params and connections; rebuilds the text lines. */
sync(node: GraphNode, graph: Graph, asset: BehaviorAsset | null): void {
this.paramLines = this.def.params.map((p) => `${p.label}: ${formatParam(p.kind, node.params[p.key] ?? p.default, asset)}`);
/** Re-reads params and connections; rebuilds the text lines. `project` resolves asset references (Spawn). */
sync(node: GraphNode, graph: Graph, asset: BehaviorAsset | null, project: Project | null): void {
this.paramLines = this.def.params.map((p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`);
this.connected = new Set(inlineInputs(this.def).filter((p) => edgesInto(graph, node.id, p.key).length > 0).map((p) => p.key));
const rows = this.rowCount();
const height = TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + rows * ROW_HEIGHT + PAD;
@@ -169,10 +171,14 @@ export class NodeView extends Widget {
}
}
function formatParam(kind: string, value: unknown, asset: BehaviorAsset | null): string {
function formatParam(param: PropertyDef, value: unknown, asset: BehaviorAsset | null, project: Project | null): string {
if (value === undefined || value === '') return '—';
if (kind === 'key') return keyLabel(String(value));
if (kind === 'enum' && asset && !asset.variables.some((v) => v.key === value) && asset.variables.length > 0 && String(value).length > 3) return `${value}?`;
if (param.kind === 'key') return keyLabel(String(value));
if (param.kind === 'enum' && param.key === 'asset') {
const target = project ? findAsset(project, String(value)) : undefined;
return target ? target.name : `${value}?`;
}
if (param.kind === 'enum' && param.key === 'variable' && asset && !asset.variables.some((v) => v.key === value) && asset.variables.length > 0 && String(value).length > 3) return `${value}?`;
return formatValue(value);
}
+9 -1
View File
@@ -12,6 +12,7 @@ import {
import { AddVariableCommand, nextVariableName, RemoveVariableCommand, RenameVariableCommand } from '../../core/commands/variableCommands';
import { edgesInto, inlineInputDef, inlineInputs, nodeDef } from '../../core/graph';
import type { Asset, BehaviorAsset, SceneAsset } from '../../core/model/Asset';
import { isPlaceableType } from '../../core/model/AssetTypes';
import { isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
import type { Position, PropertyValue } from '../../core/model/Properties';
import { findAsset, findScene, findSceneObject } from '../../core/model/Project';
@@ -395,11 +396,18 @@ export class Inspector extends Panel {
this.body.add(new Button('Delete node', { variant: 'danger', onClick: () => this.state.execute(new RemoveNodeCommand(assetId, nodeId)) }));
}
/** Variable params pick from the asset's variables; everything else is used as declared. */
/**
* Variable params pick from the asset's variables, asset params (Spawn) from the project's
* placeable assets; everything else is used as declared.
*/
private resolveParamDef(param: PropertyDef, asset: Asset): PropertyDef {
if (param.kind === 'enum' && param.key === 'variable' && asset.type === 'behavior') {
return { ...param, options: asset.variables.map((v) => ({ value: v.key, label: v.key })) };
}
if (param.kind === 'enum' && param.key === 'asset') {
const options = this.state.project.assets.filter((a) => isPlaceableType(a.type)).map((a) => ({ value: a.id, label: `${a.name} (${a.type})` }));
return { ...param, options };
}
return param;
}
+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);