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:
1 parent
c3a533c4e5
commit
91ee2feadd
13 files changed
+219
-37
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user