Behaviors with Blueprint-style node graph, graph editor tab and BehaviorSystem

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-07 12:44:43 +02:00
1 parent d4b1ffa051
commit 922774778b
36 files changed
+2969 -147

No files matched your search

+22 -4
View File
@@ -1,9 +1,10 @@
import { createEmptyGraph } from '../graph/Graph';
import type { Asset } from '../model/Asset';
import type { AssetType } from '../model/AssetTypes';
import type { PropertyValue } from '../model/Properties';
import { findAsset, findUsages, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { coerceValue, defaultsFor, schemaFor } from '../properties/PropertySchema';
import { coerceValue, defaultsFor, schemaForAsset } from '../properties/PropertySchema';
import { newId } from '../ids';
import { type Command, CommandError } from './Command';
@@ -16,6 +17,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'camera' };
case 'sprite':
return { ...base, type: 'sprite' };
case 'behavior':
return { ...base, type: 'behavior', variables: [], graph: createEmptyGraph() };
}
}
@@ -47,6 +50,7 @@ export class DeleteAssetCommand implements Command {
readonly label: string;
private removed?: { asset: Asset; index: number };
private removedUsages: { sceneId: string; object: SceneObject; index: number }[] = [];
private clearedOwners: { sceneId: string; objectId: string; ownerId: string }[] = [];
private previousActiveScene = '';
constructor(private readonly assetId: string) {
@@ -62,9 +66,18 @@ export class DeleteAssetCommand implements Command {
object,
index: scene.objects.indexOf(object),
}));
this.clearedOwners = [];
for (const usage of this.removedUsages) {
const scene = findAsset(project, usage.sceneId);
if (scene && scene.type === 'scene') scene.objects = scene.objects.filter((o) => o.id !== usage.object.id);
if (!scene || scene.type !== 'scene') continue;
scene.objects = scene.objects.filter((o) => o.id !== usage.object.id);
// Objects that pointed at a removed object as their owner lose that reference.
for (const other of scene.objects) {
if (other.ownerId === usage.object.id) {
this.clearedOwners.push({ sceneId: scene.id, objectId: other.id, ownerId: other.ownerId });
other.ownerId = '';
}
}
}
this.removed = { asset, index };
project.assets.splice(index, 1);
@@ -83,6 +96,11 @@ export class DeleteAssetCommand implements Command {
const scene = findAsset(project, usage.sceneId);
if (scene && scene.type === 'scene') scene.objects.splice(usage.index, 0, usage.object);
}
for (const cleared of this.clearedOwners) {
const scene = findAsset(project, cleared.sceneId);
const object = scene && scene.type === 'scene' ? scene.objects.find((o) => o.id === cleared.objectId) : undefined;
if (object) object.ownerId = cleared.ownerId;
}
project.activeSceneId = this.previousActiveScene;
}
}
@@ -114,7 +132,7 @@ export class SetAssetPropertyCommand implements Command {
execute(project: Project): void {
const asset = requireAsset(project, this.assetId);
const def = schemaFor(asset.type).find((d) => d.key === this.key);
const def = schemaForAsset(asset).find((d) => d.key === this.key);
if (!def) throw new CommandError(`Property "${this.key}" is not defined for ${asset.type}`);
this.previous = asset.props[this.key];
asset.props[this.key] = coerceValue(def, this.value);
@@ -138,7 +156,7 @@ export class ResetAssetPropertyCommand implements Command {
execute(project: Project): void {
const asset = requireAsset(project, this.assetId);
const def = schemaFor(asset.type).find((d) => d.key === this.key);
const def = schemaForAsset(asset).find((d) => d.key === this.key);
if (!def) throw new CommandError(`Property "${this.key}" is not defined for ${asset.type}`);
this.previous = asset.props[this.key];
asset.props[this.key] = def.default;
+173
View File
@@ -0,0 +1,173 @@
import { edgesInto, edgesOfNode, findNode, type GraphEdge, type GraphNode, portsCompatible } from '../graph/Graph';
import { requireNodeDef } from '../graph/NodeRegistry';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import type { Project } from '../model/Project';
import { coerceValue, defaultsOf } from '../properties/PropertySchema';
import { newId } from '../ids';
import { type Command, CommandError } from './Command';
import { requireBehavior } from './variableCommands';
/** Adds a node of the given type at graph coordinates. Params start at their defaults. */
export class AddNodeCommand implements Command {
readonly label: string;
readonly node: GraphNode;
constructor(private readonly assetId: string, type: string, x: number, y: number, params: PropertyBag = {}) {
const def = requireNodeDef(type);
this.node = { id: newId(), type, x: Math.round(x), y: Math.round(y), params: { ...defaultsOf(def.params), ...params } };
this.label = `Add ${def.label}`;
}
execute(project: Project): void {
requireBehavior(project, this.assetId).graph.nodes.push(this.node);
}
undo(project: Project): void {
const graph = requireBehavior(project, this.assetId).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== this.node.id);
}
}
/** Removes a node and every edge touching it. */
export class RemoveNodeCommand implements Command {
readonly label = 'Remove node';
private removed?: { node: GraphNode; index: number; edges: { edge: GraphEdge; index: number }[] };
constructor(private readonly assetId: string, private readonly nodeId: string) {}
execute(project: Project): void {
const graph = requireBehavior(project, this.assetId).graph;
const index = graph.nodes.findIndex((n) => n.id === this.nodeId);
const node = graph.nodes[index];
if (!node) throw new CommandError(`Node ${this.nodeId} not found`);
const edges = edgesOfNode(graph, this.nodeId).map((edge) => ({ edge, index: graph.edges.indexOf(edge) }));
this.removed = { node, index, edges };
graph.nodes.splice(index, 1);
graph.edges = graph.edges.filter((e) => e.from.node !== this.nodeId && e.to.node !== this.nodeId);
}
undo(project: Project): void {
if (!this.removed) return;
const graph = requireBehavior(project, this.assetId).graph;
graph.nodes.splice(this.removed.index, 0, this.removed.node);
for (const { edge, index } of [...this.removed.edges].sort((a, b) => a.index - b.index)) graph.edges.splice(index, 0, edge);
}
}
export class MoveNodeCommand implements Command {
readonly label = 'Move node';
private previous = { x: 0, y: 0 };
constructor(private readonly assetId: string, private readonly nodeId: string, private readonly x: number, private readonly y: number, previous?: { x: number; y: number }) {
if (previous) this.previous = { ...previous };
}
execute(project: Project): void {
const node = requireNode(project, this.assetId, this.nodeId);
this.previous = { x: node.x, y: node.y };
node.x = Math.round(this.x);
node.y = Math.round(this.y);
}
undo(project: Project): void {
const node = requireNode(project, this.assetId, this.nodeId);
node.x = this.previous.x;
node.y = this.previous.y;
}
}
/** Sets a node param or the inline value of an unconnected data input. */
export class SetNodeParamCommand implements Command {
readonly label: string;
private previous: PropertyValue | undefined;
constructor(private readonly assetId: string, private readonly nodeId: string, private readonly key: string, private readonly value: PropertyValue) {
this.label = `Set ${key}`;
}
execute(project: Project): void {
const node = requireNode(project, this.assetId, this.nodeId);
const def = requireNodeDef(node.type);
const param = def.params.find((p) => p.key === this.key);
this.previous = node.params[this.key];
// Inline input values are stored as-is; declared params are coerced against their definition.
node.params[this.key] = param && param.kind !== 'enum' ? coerceValue(param, this.value) : this.value;
}
undo(project: Project): void {
const node = requireNode(project, this.assetId, this.nodeId);
if (this.previous === undefined) delete node.params[this.key];
else node.params[this.key] = this.previous;
}
}
/**
* Connects an output to an input. Validates port existence and type compatibility; a data
* input holds at most one edge, so an existing one is replaced (and restored on undo).
*/
export class ConnectCommand implements Command {
readonly label = 'Connect';
readonly edge: GraphEdge;
private replaced: { edge: GraphEdge; index: number }[] = [];
constructor(private readonly assetId: string, from: { node: string; port: string }, to: { node: string; port: string }) {
this.edge = { id: newId(), from: { ...from }, to: { ...to } };
}
execute(project: Project): void {
const graph = requireBehavior(project, this.assetId).graph;
const fromNode = findNode(graph, this.edge.from.node);
const toNode = findNode(graph, this.edge.to.node);
if (!fromNode || !toNode) throw new CommandError('Node not found');
if (fromNode === toNode) throw new CommandError('A node cannot connect to itself');
const out = requireNodeDef(fromNode.type).outputs.find((p) => p.key === this.edge.from.port);
const inp = requireNodeDef(toNode.type).inputs.find((p) => p.key === this.edge.to.port);
if (!out || !inp) throw new CommandError('Port not found');
if (!portsCompatible(out.type, inp.type)) throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`);
if (graph.edges.some((e) => sameEnds(e, this.edge))) throw new CommandError('Already connected');
this.replaced = [];
if (inp.type !== 'exec') {
for (const existing of edgesInto(graph, this.edge.to.node, this.edge.to.port)) {
this.replaced.push({ edge: existing, index: graph.edges.indexOf(existing) });
}
graph.edges = graph.edges.filter((e) => !this.replaced.some((r) => r.edge === e));
}
graph.edges.push(this.edge);
}
undo(project: Project): void {
const graph = requireBehavior(project, this.assetId).graph;
graph.edges = graph.edges.filter((e) => e.id !== this.edge.id);
for (const { edge, index } of [...this.replaced].sort((a, b) => a.index - b.index)) graph.edges.splice(index, 0, edge);
}
}
export class DisconnectCommand implements Command {
readonly label = 'Disconnect';
private removed?: { edge: GraphEdge; index: number };
constructor(private readonly assetId: string, private readonly edgeId: string) {}
execute(project: Project): void {
const graph = requireBehavior(project, this.assetId).graph;
const index = graph.edges.findIndex((e) => e.id === this.edgeId);
const edge = graph.edges[index];
if (!edge) throw new CommandError(`Edge ${this.edgeId} not found`);
this.removed = { edge, index };
graph.edges.splice(index, 1);
}
undo(project: Project): void {
if (this.removed) requireBehavior(project, this.assetId).graph.edges.splice(this.removed.index, 0, this.removed.edge);
}
}
function requireNode(project: Project, assetId: string, nodeId: string): GraphNode {
const node = findNode(requireBehavior(project, assetId).graph, nodeId);
if (!node) throw new CommandError(`Node ${nodeId} not found`);
return node;
}
function sameEnds(a: GraphEdge, b: GraphEdge): boolean {
return a.from.node === b.from.node && a.from.port === b.from.port && a.to.node === b.to.node && a.to.port === b.to.port;
}
+40 -4
View File
@@ -2,7 +2,7 @@ import { isPlaceableType } from '../model/AssetTypes';
import type { Position, PropertyValue, Vec2 } from '../model/Properties';
import { findScene, findSceneObject, type Project } from '../model/Project';
import { createSceneObject, type SceneObject } from '../model/SceneObject';
import { coerceValue, schemaFor } from '../properties/PropertySchema';
import { coerceValue, schemaForAsset } from '../properties/PropertySchema';
import { newId } from '../ids';
import { type Command, CommandError } from './Command';
import { requireAsset } from './assetCommands';
@@ -13,7 +13,7 @@ function requireScene(project: Project, sceneId: string) {
return scene;
}
function requireObject(project: Project, sceneId: string, objectId: string): SceneObject {
export function requireObject(project: Project, sceneId: string, objectId: string): SceneObject {
const object = findSceneObject(requireScene(project, sceneId), objectId);
if (!object) throw new CommandError(`Scene object ${objectId} not found`);
return object;
@@ -42,6 +42,7 @@ export class PlaceAssetCommand implements Command {
export class RemoveSceneObjectCommand implements Command {
readonly label = 'Remove object';
private removed?: { object: SceneObject; index: number };
private clearedOwners: string[] = [];
constructor(private readonly sceneId: string, private readonly objectId: string) {}
@@ -52,11 +53,24 @@ export class RemoveSceneObjectCommand implements Command {
if (!object) throw new CommandError(`Scene object ${this.objectId} not found`);
this.removed = { object, index };
scene.objects.splice(index, 1);
// Objects owned by the removed one lose their owner reference.
this.clearedOwners = [];
for (const other of scene.objects) {
if (other.ownerId === this.objectId) {
other.ownerId = '';
this.clearedOwners.push(other.id);
}
}
}
undo(project: Project): void {
if (!this.removed) return;
requireScene(project, this.sceneId).objects.splice(this.removed.index, 0, this.removed.object);
const scene = requireScene(project, this.sceneId);
scene.objects.splice(this.removed.index, 0, this.removed.object);
for (const id of this.clearedOwners) {
const other = findSceneObject(scene, id);
if (other) other.ownerId = this.objectId;
}
}
}
@@ -119,7 +133,7 @@ export class SetOverrideCommand implements Command {
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
const asset = requireAsset(project, object.assetId);
const def = schemaFor(asset.type).find((d) => d.key === this.key);
const def = schemaForAsset(asset).find((d) => d.key === this.key);
if (!def) throw new CommandError(`Property "${this.key}" is not defined for ${asset.type}`);
this.previous = object.overrides[this.key];
object.overrides[this.key] = coerceValue(def, this.value);
@@ -152,3 +166,25 @@ export class ClearOverrideCommand implements Command {
requireObject(project, this.sceneId, this.objectId).overrides[this.key] = this.previous;
}
}
/** Points an object (typically a behavior) at another object of the same scene, or at nothing (''). */
export class SetOwnerCommand implements Command {
readonly label = 'Set owner';
private previous = '';
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly ownerId: string) {}
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
if (this.ownerId) {
if (this.ownerId === this.objectId) throw new CommandError('An object cannot own itself');
requireObject(project, this.sceneId, this.ownerId);
}
this.previous = object.ownerId;
object.ownerId = this.ownerId;
}
undo(project: Project): void {
requireObject(project, this.sceneId, this.objectId).ownerId = this.previous;
}
}
+132
View File
@@ -0,0 +1,132 @@
import { nodeDef } from '../graph/NodeRegistry';
import type { BehaviorAsset } from '../model/Asset';
import type { PropertyValue } from '../model/Properties';
import { findAsset, findUsages, type Project } from '../model/Project';
import { type PropertyDef, type VariableKind, variableDef } from '../properties/PropertySchema';
import { type Command, CommandError } from './Command';
export function requireBehavior(project: Project, assetId: string): BehaviorAsset {
const asset = findAsset(project, assetId);
if (!asset || asset.type !== 'behavior') throw new CommandError(`Behavior ${assetId} not found`);
return asset;
}
/** "var1", "var2", … – the first variable name not yet used by the behavior. */
export function nextVariableName(asset: BehaviorAsset, base = 'var'): string {
const taken = new Set(asset.variables.map((v) => v.key));
for (let i = 1; ; i++) {
const candidate = `${base}${i}`;
if (!taken.has(candidate)) return candidate;
}
}
/** Declares a new variable on a behavior; its default value is written to `props`. */
export class AddVariableCommand implements Command {
readonly label: string;
readonly def: PropertyDef;
constructor(private readonly assetId: string, key: string, kind: VariableKind) {
this.def = variableDef(key, kind);
this.label = `Add variable ${key}`;
}
execute(project: Project): void {
const asset = requireBehavior(project, this.assetId);
if (!isValidVariableName(this.def.key)) throw new CommandError('Variable names must start with a letter and contain only letters, digits or _');
if (asset.variables.some((v) => v.key === this.def.key)) throw new CommandError(`Variable "${this.def.key}" already exists`);
asset.variables.push(this.def);
asset.props[this.def.key] = this.def.default;
}
undo(project: Project): void {
const asset = requireBehavior(project, this.assetId);
asset.variables = asset.variables.filter((v) => v.key !== this.def.key);
delete asset.props[this.def.key];
}
}
/** Removes a variable together with its default and every instance override. */
export class RemoveVariableCommand implements Command {
readonly label: string;
private removed?: { def: PropertyDef; index: number; value: PropertyValue | undefined };
private overrides: { sceneId: string; objectId: string; value: PropertyValue }[] = [];
constructor(private readonly assetId: string, private readonly key: string) {
this.label = `Remove variable ${key}`;
}
execute(project: Project): void {
const asset = requireBehavior(project, this.assetId);
const index = asset.variables.findIndex((v) => v.key === this.key);
const def = asset.variables[index];
if (!def) throw new CommandError(`Variable "${this.key}" not found`);
this.removed = { def, index, value: asset.props[this.key] };
asset.variables.splice(index, 1);
delete asset.props[this.key];
this.overrides = [];
for (const { scene, object } of findUsages(project, this.assetId)) {
const value = object.overrides[this.key];
if (value === undefined) continue;
this.overrides.push({ sceneId: scene.id, objectId: object.id, value });
delete object.overrides[this.key];
}
}
undo(project: Project): void {
if (!this.removed) return;
const asset = requireBehavior(project, this.assetId);
asset.variables.splice(this.removed.index, 0, this.removed.def);
if (this.removed.value !== undefined) asset.props[this.key] = this.removed.value;
for (const o of this.overrides) {
const object = findUsages(project, this.assetId).find((u) => u.scene.id === o.sceneId && u.object.id === o.objectId)?.object;
if (object) object.overrides[this.key] = o.value;
}
}
}
/** Renames a variable everywhere: definition, default, instance overrides and graph nodes referring to it. */
export class RenameVariableCommand implements Command {
readonly label: string;
constructor(private readonly assetId: string, private readonly from: string, private readonly to: string) {
this.label = `Rename variable ${from}`;
}
execute(project: Project): void {
this.rename(project, this.from, this.to);
}
undo(project: Project): void {
this.rename(project, this.to, this.from);
}
private rename(project: Project, from: string, to: string): void {
const asset = requireBehavior(project, this.assetId);
if (from === to) return;
if (!isValidVariableName(to)) throw new CommandError('Variable names must start with a letter and contain only letters, digits or _');
const def = asset.variables.find((v) => v.key === from);
if (!def) throw new CommandError(`Variable "${from}" not found`);
if (asset.variables.some((v) => v.key === to)) throw new CommandError(`Variable "${to}" already exists`);
def.key = to;
def.label = to;
if (asset.props[from] !== undefined) {
asset.props[to] = asset.props[from]!;
delete asset.props[from];
}
for (const { object } of findUsages(project, this.assetId)) {
if (object.overrides[from] === undefined) continue;
object.overrides[to] = object.overrides[from]!;
delete object.overrides[from];
}
for (const node of asset.graph.nodes) {
const def = nodeDef(node.type);
for (const param of def?.params ?? []) {
if (param.key === 'variable' && node.params.variable === from) node.params.variable = to;
}
}
}
}
export function isValidVariableName(name: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
}
+70
View File
@@ -0,0 +1,70 @@
import type { PropertyBag } from '../model/Properties';
/**
* Port data types. `exec` carries control flow (Blueprint-style white arrows); the others
* carry values. `any` accepts every value type (variables, logging).
*/
export type PortType = 'exec' | 'number' | 'boolean' | 'string' | 'object' | 'any';
export interface PortRef {
node: string;
port: string;
}
/**
* One node of a behavior graph. `type` names a NodeDef in the registry; `params` holds
* the node's own settings (e.g. which key) and inline values of unconnected data inputs,
* both keyed by the param / port key.
*/
export interface GraphNode {
id: string;
type: string;
x: number;
y: number;
params: PropertyBag;
}
/** A connection from an output port to an input port. */
export interface GraphEdge {
id: string;
from: PortRef;
to: PortRef;
}
export interface Graph {
nodes: GraphNode[];
edges: GraphEdge[];
}
export function createEmptyGraph(): Graph {
return { nodes: [], edges: [] };
}
export function findNode(graph: Graph, nodeId: string): GraphNode | undefined {
return graph.nodes.find((n) => n.id === nodeId);
}
export function findEdge(graph: Graph, edgeId: string): GraphEdge | undefined {
return graph.edges.find((e) => e.id === edgeId);
}
/** Edges arriving at one input port (data inputs allow at most one, exec inputs any number). */
export function edgesInto(graph: Graph, nodeId: string, port: string): GraphEdge[] {
return graph.edges.filter((e) => e.to.node === nodeId && e.to.port === port);
}
/** Edges leaving one output port. */
export function edgesOutOf(graph: Graph, nodeId: string, port: string): GraphEdge[] {
return graph.edges.filter((e) => e.from.node === nodeId && e.from.port === port);
}
/** Every edge touching a node (used when removing it). */
export function edgesOfNode(graph: Graph, nodeId: string): GraphEdge[] {
return graph.edges.filter((e) => e.from.node === nodeId || e.to.node === nodeId);
}
/** Whether a value may flow from an output of type `from` into an input of type `to`. */
export function portsCompatible(from: PortType, to: PortType): boolean {
if (from === 'exec' || to === 'exec') return from === to;
return from === to || from === 'any' || to === 'any';
}
+112
View File
@@ -0,0 +1,112 @@
import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph';
import { hasExecInput, isEventNode, type NodeContext, requireNodeDef, type Value } from './NodeRegistry';
/**
* Guards against runaway flows (a Sequence looping back into itself, …). The interpreter
* recurses along exec edges, so the limit also keeps the call stack shallow.
*/
const MAX_STEPS_PER_FIRE = 1000;
export interface TraceListener {
/** An exec edge was followed. */
onEdge(edgeId: string): void;
/** A node ran (exec) or was evaluated (data). */
onNode(nodeId: string): void;
}
/**
* Runs one behavior graph for one instance. Blueprint semantics: an event node starts a
* *pulse*; the interpreter follows exec edges, and whenever a node needs a data input it
* pulls the value from the connected output – evaluating pure data nodes lazily and
* reading exec nodes' outputs from the pulse cache. Per-node state (timers) lives here,
* so every instance has its own.
*/
export class GraphInterpreter {
/** Per-node persistent state across pulses (timer accumulators etc.). */
readonly nodeState = new Map<string, Record<string, number>>();
private outputCache = new Map<string, Record<string, Value>>();
private steps = 0;
trace: TraceListener | null = null;
constructor(readonly graph: Graph) {}
/** Nodes of one event type, in graph order. */
eventNodes(type: string): GraphNode[] {
return this.graph.nodes.filter((n) => n.type === type);
}
/**
* Fires every event node of `type` whose params satisfy `filter`, seeding its outputs with
* `outputs` (e.g. `{ dt }`). Returns how many event nodes fired.
*/
fire(type: string, ctx: NodeContext, outputs: Record<string, Value> = {}, filter?: (node: GraphNode) => boolean): number {
let fired = 0;
for (const node of this.eventNodes(type)) {
if (filter && !filter(node)) continue;
this.outputCache = new Map();
this.steps = 0;
this.outputCache.set(node.id, { ...outputs });
this.trace?.onNode(node.id);
this.follow(node, 'exec', ctx);
fired++;
}
return fired;
}
/** Follows the exec edges leaving `port` of `node`, running each target in order. */
private follow(node: GraphNode, port: string, ctx: NodeContext): void {
for (const edge of edgesOutOf(this.graph, node.id, port)) {
const target = this.node(edge.to.node);
if (!target) continue;
this.trace?.onEdge(edge.id);
this.execute(target, ctx);
}
}
private execute(node: GraphNode, ctx: NodeContext): void {
if (++this.steps > MAX_STEPS_PER_FIRE) throw new Error('Behavior graph exceeded the step limit (infinite loop?)');
const def = requireNodeDef(node.type);
if (!def.run) return;
const inputs = this.collectInputs(node, ctx);
const result = def.run(ctx, inputs, node.params);
this.outputCache.set(node.id, result.outputs ?? {});
this.trace?.onNode(node.id);
const next = result.next === undefined ? [] : Array.isArray(result.next) ? result.next : [result.next];
for (const port of next) this.follow(node, port, ctx);
}
/** Resolves every data input: connected → pulled from the source, else the inline param. */
private collectInputs(node: GraphNode, ctx: NodeContext): Record<string, Value> {
const def = requireNodeDef(node.type);
const inputs: Record<string, Value> = {};
for (const port of def.inputs) {
if (port.type === 'exec') continue;
const edge = edgesInto(this.graph, node.id, port.key)[0];
if (edge) inputs[port.key] = this.readOutput(edge.from.node, edge.from.port, ctx);
else inputs[port.key] = node.params[port.key] ?? null;
}
return inputs;
}
/** Value of one output port: from the pulse cache, or by evaluating a pure data node. */
private readOutput(nodeId: string, port: string, ctx: NodeContext): Value {
const cached = this.outputCache.get(nodeId);
if (cached) return cached[port] ?? null;
const source = this.node(nodeId);
if (!source) return null;
const def = requireNodeDef(source.type);
// Exec nodes that have not run in this pulse have no value yet.
if (isEventNode(def) || hasExecInput(def) || !def.run) return null;
if (++this.steps > MAX_STEPS_PER_FIRE) throw new Error('Behavior graph exceeded the step limit (data cycle?)');
// Mark before evaluating so a data cycle reads null instead of recursing forever.
this.outputCache.set(nodeId, {});
const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params).outputs ?? {};
this.outputCache.set(nodeId, outputs);
this.trace?.onNode(nodeId);
return outputs[port] ?? null;
}
private node(id: string): GraphNode | undefined {
return this.graph.nodes.find((n) => n.id === id);
}
}
+154
View File
@@ -0,0 +1,154 @@
import type { PropertyBag, PropertyValue } from '../model/Properties';
import type { PropertyDef } from '../properties/PropertySchema';
import type { PortType } from './Graph';
/** A value travelling along a data edge. Objects are live entity handles (play mode only). */
export type Value = PropertyValue | EntityHandle | null;
/**
* What the graph sees of a running entity. The runtime's Entity satisfies this shape; unit
* tests use plain objects. Mutating `x`/`y`/`props` is how actions take effect.
*/
export interface EntityHandle {
id: string;
name: string;
x: number;
y: number;
z: number;
props: PropertyBag;
}
/**
* Services a graph may use while running. Implemented by the runtime (BehaviorSystem) and
* by test doubles. Everything the graph does to the world goes through here, so the core
* stays free of runtime imports.
*/
export interface NodeContext {
/** The behavior entity itself. */
self: EntityHandle;
/** The scene object the behavior was attached to, if any. */
owner: EntityHandle | null;
/** Mutable variable state of this behavior instance. */
variables: PropertyBag;
/** Seconds since the world started and since the last tick. */
time: number;
dt: number;
findByName(name: string): EntityHandle | null;
isKeyDown(key: string): boolean;
log(message: string): void;
destroy(target: EntityHandle): void;
stop(): void;
}
export interface PortDef {
key: string;
label: string;
type: PortType;
}
export type NodeCategory = 'event' | 'flow' | 'data' | 'variable' | 'action';
export interface RunResult {
/** Values for the node's data outputs (cached for the rest of the pulse). */
outputs?: Record<string, Value>;
/** Exec output(s) to continue along; omitted = the node ends the flow. */
next?: string | string[];
}
/**
* Describes one node type: its ports, its inline settings and how it runs. The editor
* palette, port rendering, connection validation and the inspector fields are all derived
* from this – adding a node type means adding one definition, no UI code.
*/
export interface NodeDef {
type: string;
label: string;
category: NodeCategory;
/** Shown in the palette. */
description: string;
inputs: PortDef[];
outputs: PortDef[];
/** Node settings that are not ports (key name, operator, variable). */
params: PropertyDef[];
/**
* Executes the node. Pure data nodes (no exec pins) are evaluated on demand whenever an
* input downstream needs their value; exec nodes run when the flow reaches them.
* Event nodes have no `run`; the interpreter seeds their outputs when it fires them.
*/
run?(ctx: NodeContext, inputs: Record<string, Value>, params: PropertyBag): RunResult;
}
const defs = new Map<string, NodeDef>();
export function registerNode(def: NodeDef): NodeDef {
if (defs.has(def.type)) throw new Error(`Node type "${def.type}" registered twice`);
defs.set(def.type, def);
return def;
}
export function nodeDef(type: string): NodeDef | undefined {
return defs.get(type);
}
export function requireNodeDef(type: string): NodeDef {
const def = defs.get(type);
if (!def) throw new Error(`Unknown node type "${type}"`);
return def;
}
export function allNodeDefs(): NodeDef[] {
return [...defs.values()];
}
export function isEventNode(def: NodeDef): boolean {
return def.category === 'event';
}
export function hasExecInput(def: NodeDef): boolean {
return def.inputs.some((p) => p.type === 'exec');
}
/** Data inputs that may carry an inline value when unconnected (numbers, strings, booleans, any). */
export function inlineInputs(def: NodeDef): PortDef[] {
return def.inputs.filter((p) => p.type !== 'exec' && p.type !== 'object');
}
/** A PropertyDef for editing an inline input value of the given port. */
export function inlineInputDef(port: PortDef): PropertyDef {
switch (port.type) {
case 'boolean':
return { key: port.key, label: port.label, kind: 'boolean', default: false };
case 'string':
case 'any':
return { key: port.key, label: port.label, kind: 'text', default: '' };
default:
return { key: port.key, label: port.label, kind: 'number', default: 0, step: 1 };
}
}
// --- value helpers for node implementations ---------------------------------------------
export function asNumber(value: Value | undefined): number {
if (typeof value === 'number') return value;
if (typeof value === 'boolean') return value ? 1 : 0;
if (typeof value === 'string') {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
return 0;
}
export function asBoolean(value: Value | undefined): boolean {
if (typeof value === 'string') return value !== '' && value !== 'false' && value !== '0';
return Boolean(value);
}
export function asString(value: Value | undefined): string {
if (value === null || value === undefined) return '';
if (typeof value === 'object') return value.name;
return String(value);
}
export function asEntity(value: Value | undefined): EntityHandle | null {
return value && typeof value === 'object' ? value : null;
}
+6
View File
@@ -0,0 +1,6 @@
// Importing the graph package registers the built-in node set as a side effect.
import './nodes';
export * from './Graph';
export * from './NodeRegistry';
export { GraphInterpreter, type TraceListener } from './GraphInterpreter';
+256
View File
@@ -0,0 +1,256 @@
import type { PropertyDef } from '../properties/PropertySchema';
import { asBoolean, asEntity, asNumber, asString, type PortDef, registerNode } from './NodeRegistry';
/**
* The built-in node set. Each entry is data + a `run` function; everything else (palette,
* ports, inspector fields, validation) is derived. Importing this module registers them.
*/
const exec = (key = 'exec', label = ''): PortDef => ({ key, label, type: 'exec' });
const num = (key: string, label = key): PortDef => ({ key, label, type: 'number' });
const bool = (key: string, label = key): PortDef => ({ key, label, type: 'boolean' });
const obj = (key: string, label = key): PortDef => ({ key, label, type: 'object' });
const any = (key: string, label = key): PortDef => ({ key, label, type: 'any' });
const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' };
/** Placeholder: the graph inspector replaces the options with the asset's variables. */
const variableParam: PropertyDef = { key: 'variable', label: 'Variable', kind: 'enum', default: '', options: [] };
// --- events -------------------------------------------------------------------------------
registerNode({ type: 'OnStart', label: 'On start', category: 'event', description: 'Runs once when the game starts.', inputs: [], outputs: [exec()], params: [] });
registerNode({ type: 'OnUpdate', label: 'On update', category: 'event', description: 'Runs every frame.', inputs: [], outputs: [exec(), num('dt', 'dt')], params: [] });
registerNode({ type: 'OnKeyDown', label: 'On key down', category: 'event', description: 'Runs once when the key is pressed.', inputs: [], outputs: [exec()], params: [keyParam] });
registerNode({ type: 'OnKeyUp', label: 'On key up', category: 'event', description: 'Runs once when the key is released.', inputs: [], outputs: [exec()], params: [keyParam] });
registerNode({ type: 'OnKeyHeld', label: 'On key held', category: 'event', description: 'Runs every frame while the key is held.', inputs: [], outputs: [exec(), num('dt', 'dt')], params: [keyParam] });
registerNode({
type: 'OnTimer',
label: 'On timer',
category: 'event',
description: 'Runs every N seconds.',
inputs: [],
outputs: [exec()],
params: [{ key: 'interval', label: 'Interval (s)', kind: 'number', default: 1, min: 0.01, step: 0.1 }],
});
// --- flow ---------------------------------------------------------------------------------
registerNode({
type: 'Branch',
label: 'Branch',
category: 'flow',
description: 'Continues along "then" or "else" depending on the condition.',
inputs: [exec(), bool('condition')],
outputs: [exec('then', 'then'), exec('else', 'else')],
params: [],
run: (_ctx, inputs) => ({ next: asBoolean(inputs.condition) ? 'then' : 'else' }),
});
registerNode({
type: 'Sequence',
label: 'Sequence',
category: 'flow',
description: 'Runs its outputs one after another.',
inputs: [exec()],
outputs: [exec('a', '1'), exec('b', '2'), exec('c', '3')],
params: [],
run: () => ({ next: ['a', 'b', 'c'] }),
});
// --- data: references --------------------------------------------------------------------
registerNode({ type: 'Self', label: 'Self', category: 'data', description: 'This behavior object.', inputs: [], outputs: [obj('object')], params: [], run: (ctx) => ({ outputs: { object: ctx.self } }) });
registerNode({ type: 'Owner', label: 'Owner', category: 'data', description: 'The object this behavior is attached to.', inputs: [], outputs: [obj('object')], params: [], run: (ctx) => ({ outputs: { object: ctx.owner } }) });
registerNode({
type: 'ObjectByName',
label: 'Object by name',
category: 'data',
description: 'Finds a scene object by its name.',
inputs: [],
outputs: [obj('object')],
params: [{ key: 'name', label: 'Name', kind: 'text', default: '' }],
run: (ctx, _inputs, params) => ({ outputs: { object: ctx.findByName(String(params.name ?? '')) } }),
});
// --- variables ----------------------------------------------------------------------------
registerNode({
type: 'GetVariable',
label: 'Get variable',
category: 'variable',
description: 'Reads one of this behavior\'s variables.',
inputs: [],
outputs: [any('value')],
params: [variableParam],
run: (ctx, _inputs, params) => ({ outputs: { value: ctx.variables[String(params.variable ?? '')] ?? null } }),
});
registerNode({
type: 'SetVariable',
label: 'Set variable',
category: 'variable',
description: 'Writes one of this behavior\'s variables.',
inputs: [exec(), any('value')],
outputs: [exec()],
params: [variableParam],
run: (ctx, inputs, params) => {
const key = String(params.variable ?? '');
const current = ctx.variables[key];
const value = inputs.value;
if (key) {
if (typeof current === 'number') ctx.variables[key] = asNumber(value);
else if (typeof current === 'boolean') ctx.variables[key] = asBoolean(value);
else ctx.variables[key] = asString(value);
}
return { next: 'exec' };
},
});
// --- data: properties and positions --------------------------------------------------------
registerNode({
type: 'GetProperty',
label: 'Get property',
category: 'data',
description: 'Reads a property (width, color, …) of an object.',
inputs: [obj('object')],
outputs: [any('value')],
params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }],
run: (_ctx, inputs, params) => ({ outputs: { value: asEntity(inputs.object)?.props[String(params.property ?? '')] ?? null } }),
});
registerNode({
type: 'SetProperty',
label: 'Set property',
category: 'action',
description: 'Writes a property (width, color, …) of an object.',
inputs: [exec(), obj('object'), any('value')],
outputs: [exec()],
params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }],
run: (_ctx, inputs, params) => {
const target = asEntity(inputs.object);
const key = String(params.property ?? '');
if (target && key) {
const current = target.props[key];
const value = inputs.value;
if (typeof current === 'number') target.props[key] = asNumber(value);
else if (typeof current === 'boolean') target.props[key] = asBoolean(value);
else target.props[key] = asString(value);
}
return { next: 'exec' };
},
});
registerNode({
type: 'GetPosition',
label: 'Get position',
category: 'data',
description: 'The x/y position of an object.',
inputs: [obj('object')],
outputs: [num('x'), num('y')],
params: [],
run: (_ctx, inputs) => {
const target = asEntity(inputs.object);
return { outputs: { x: target?.x ?? 0, y: target?.y ?? 0 } };
},
});
// --- data: literals and math ---------------------------------------------------------------
registerNode({ type: 'Number', label: 'Number', category: 'data', description: 'A constant number.', inputs: [], outputs: [num('value')], params: [{ key: 'value', label: 'Value', kind: 'number', default: 0, step: 1 }], run: (_c, _i, p) => ({ outputs: { value: asNumber(p.value) } }) });
registerNode({ type: 'Boolean', label: 'Boolean', category: 'data', description: 'A constant true/false.', inputs: [], outputs: [bool('value')], params: [{ key: 'value', label: 'Value', kind: 'boolean', default: false }], run: (_c, _i, p) => ({ outputs: { value: asBoolean(p.value) } }) });
registerNode({ type: 'String', label: 'String', category: 'data', description: 'A constant text.', inputs: [], outputs: [{ key: 'value', label: 'value', type: 'string' }], params: [{ key: 'value', label: 'Value', kind: 'text', default: '' }], run: (_c, _i, p) => ({ outputs: { value: asString(p.value) } }) });
const MATH: [string, string, string, (a: number, b: number) => number][] = [
['Add', 'Add', 'a + b', (a, b) => a + b],
['Subtract', 'Subtract', 'a − b', (a, b) => a - b],
['Multiply', 'Multiply', 'a × b', (a, b) => a * b],
['Divide', 'Divide', 'a ÷ b (0 when b is 0)', (a, b) => (b === 0 ? 0 : a / b)],
];
for (const [type, label, description, fn] of MATH) {
registerNode({ type, label, category: 'data', description, inputs: [num('a'), num('b')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: fn(asNumber(i.a), asNumber(i.b)) } }) });
}
const COMPARE: Record<string, (a: number, b: number) => boolean> = {
'==': (a, b) => a === b,
'!=': (a, b) => a !== b,
'<': (a, b) => a < b,
'<=': (a, b) => a <= b,
'>': (a, b) => a > b,
'>=': (a, b) => a >= b,
};
registerNode({
type: 'Compare',
label: 'Compare',
category: 'data',
description: 'Compares two numbers.',
inputs: [num('a'), num('b')],
outputs: [bool('result')],
params: [{ key: 'op', label: 'Operator', kind: 'enum', default: '<', options: Object.keys(COMPARE).map((op) => ({ value: op, label: op })) }],
run: (_c, i, p) => ({ outputs: { result: (COMPARE[String(p.op)] ?? COMPARE['=='])!(asNumber(i.a), asNumber(i.b)) } }),
});
registerNode({ type: 'And', label: 'And', category: 'data', description: 'Both true.', inputs: [bool('a'), bool('b')], outputs: [bool('result')], params: [], run: (_c, i) => ({ outputs: { result: asBoolean(i.a) && asBoolean(i.b) } }) });
registerNode({ type: 'Or', label: 'Or', category: 'data', description: 'Either true.', inputs: [bool('a'), bool('b')], outputs: [bool('result')], params: [], run: (_c, i) => ({ outputs: { result: asBoolean(i.a) || asBoolean(i.b) } }) });
registerNode({ type: 'Not', label: 'Not', category: 'data', description: 'Inverts a boolean.', inputs: [bool('value')], outputs: [bool('result')], params: [], run: (_c, i) => ({ outputs: { result: !asBoolean(i.value) } }) });
registerNode({ type: 'KeyIsDown', label: 'Key is down', category: 'data', description: 'Whether a key is currently held.', inputs: [], outputs: [bool('down')], params: [keyParam], run: (ctx, _i, p) => ({ outputs: { down: ctx.isKeyDown(String(p.key ?? '')) } }) });
// --- actions --------------------------------------------------------------------------------
registerNode({
type: 'Move',
label: 'Move',
category: 'action',
description: 'Moves an object by dx/dy.',
inputs: [exec(), obj('object'), num('dx'), num('dy')],
outputs: [exec()],
params: [],
run: (_c, i) => {
const target = asEntity(i.object);
if (target) {
target.x += asNumber(i.dx);
target.y += asNumber(i.dy);
}
return { next: 'exec' };
},
});
registerNode({
type: 'SetPosition',
label: 'Set position',
category: 'action',
description: 'Places an object at x/y.',
inputs: [exec(), obj('object'), num('x'), num('y')],
outputs: [exec()],
params: [],
run: (_c, i) => {
const target = asEntity(i.object);
if (target) {
target.x = asNumber(i.x);
target.y = asNumber(i.y);
}
return { next: 'exec' };
},
});
registerNode({
type: 'Destroy',
label: 'Destroy',
category: 'action',
description: 'Removes an object from the running game.',
inputs: [exec(), obj('object')],
outputs: [exec()],
params: [],
run: (ctx, i) => {
const target = asEntity(i.object);
if (target) ctx.destroy(target);
return { next: 'exec' };
},
});
registerNode({
type: 'Log',
label: 'Log',
category: 'action',
description: 'Prints a message to the console.',
inputs: [exec(), any('message')],
outputs: [exec()],
params: [],
run: (ctx, i) => {
ctx.log(asString(i.message));
return { next: 'exec' };
},
});
registerNode({ type: 'StopGame', label: 'Stop game', category: 'action', description: 'Ends play mode.', inputs: [exec()], outputs: [], params: [], run: (ctx) => { ctx.stop(); return {}; } });
+18 -1
View File
@@ -1,3 +1,5 @@
import type { Graph } from '../graph/Graph';
import type { PropertyDef } from '../properties/PropertySchema';
import type { AssetType } from './AssetTypes';
import type { PropertyBag } from './Properties';
import type { SceneObject } from './SceneObject';
@@ -24,8 +26,23 @@ export interface SceneAsset extends AssetBase {
objects: SceneObject[];
}
export type Asset = SpriteAsset | CameraAsset | SceneAsset;
/**
* A behavior is logic: a node graph plus the variables it declares. Variables are ordinary
* properties – `variables` is the asset's own property schema, `props` holds their default
* values, and placed instances override them like any other property.
*/
export interface BehaviorAsset extends AssetBase {
type: 'behavior';
variables: PropertyDef[];
graph: Graph;
}
export type Asset = SpriteAsset | CameraAsset | SceneAsset | BehaviorAsset;
export function isSceneAsset(asset: Asset): asset is SceneAsset {
return asset.type === 'scene';
}
export function isBehaviorAsset(asset: Asset): asset is BehaviorAsset {
return asset.type === 'behavior';
}
+10 -2
View File
@@ -1,5 +1,5 @@
/** All asset types known in phase 1. Scenes are assets too, but cannot be placed in scenes. */
export const ASSET_TYPES = ['scene', 'camera', 'sprite'] as const;
/** All asset types. Scenes are assets too, but cannot be placed in scenes. */
export const ASSET_TYPES = ['scene', 'camera', 'sprite', 'behavior'] as const;
export type AssetType = (typeof ASSET_TYPES)[number];
@@ -13,3 +13,11 @@ export function isPlaceableType(type: AssetType): type is PlaceableAssetType {
export function isAssetType(value: unknown): value is AssetType {
return typeof value === 'string' && (ASSET_TYPES as readonly string[]).includes(value);
}
/**
* Types drawn as editor symbols (icon in the overlay layer) instead of as scene content.
* They exist in the scene for their position, not for their looks.
*/
export function isSymbolType(type: AssetType): boolean {
return type === 'camera' || type === 'behavior';
}
+13 -1
View File
@@ -13,9 +13,21 @@ export interface SceneObject {
/** World position plus draw order (`z`). */
position: Position;
overrides: PropertyBag;
/**
* Another object of the same scene this one acts on (used by behaviors: "the owner").
* Empty string = none. Any object may carry one, only behaviors interpret it.
*/
ownerId: string;
}
/** Creates a scene object; `z` defaults to 0 when the caller only has a 2D point. */
export function createSceneObject(id: string, assetId: string, position: Vec2 | Position): SceneObject {
return { id, assetId, name: '', position: { x: position.x, y: position.y, z: 'z' in position ? position.z : 0 }, overrides: {} };
return {
id,
assetId,
name: '',
position: { x: position.x, y: position.y, z: 'z' in position ? position.z : 0 },
overrides: {},
ownerId: '',
};
}
+40 -2
View File
@@ -1,3 +1,4 @@
import type { Asset } from '../model/Asset';
import type { AssetType } from '../model/AssetTypes';
import type { PropertyBag, PropertyValue } from '../model/Properties';
@@ -9,12 +10,20 @@ export type PropertyDef =
| { key: string; label: string; kind: 'number'; default: number; min?: number; max?: number; step?: number }
| { key: string; label: string; kind: 'text'; default: string }
| { key: string; label: string; kind: 'color'; default: number }
| { key: string; label: string; kind: 'boolean'; default: boolean };
| { key: string; label: string; kind: 'boolean'; default: boolean }
/** One of a fixed set of string values, shown as a dropdown. */
| { key: string; label: string; kind: 'enum'; default: string; options: { value: string; label: string }[] }
/** A keyboard key name (`KeyboardEvent.key`), captured by pressing it. */
| { key: string; label: string; kind: 'key'; default: string };
export type PropertyKind = PropertyDef['kind'];
export type PropertySchema = readonly PropertyDef[];
/** Kinds a behavior variable may have (what the node graph can compute with). */
export const VARIABLE_KINDS = ['number', 'boolean', 'text'] as const;
export type VariableKind = (typeof VARIABLE_KINDS)[number];
/** Property schema per asset type. Position is deliberately absent: it belongs to scene objects. */
export const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }],
@@ -28,6 +37,8 @@ export const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
{ key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
{ key: 'color', label: 'Color', kind: 'color', default: 0xe0553f },
],
// Behaviors declare their own properties (variables) per asset – see `schemaForAsset`.
behavior: [],
};
/** Properties every scene object has regardless of its asset type. */
@@ -36,16 +47,38 @@ export const SCENE_OBJECT_SCHEMA: PropertySchema = [
{ key: 'y', label: 'Y', kind: 'number', default: 0, step: 1 },
];
/** Schema shared by every asset of a type. Behaviors add per-asset variables on top. */
export function schemaFor(type: AssetType): PropertySchema {
return ASSET_SCHEMAS[type];
}
/** The effective schema of one asset: the type schema, plus declared variables for behaviors. */
export function schemaForAsset(asset: Asset): PropertySchema {
return asset.type === 'behavior' ? asset.variables : ASSET_SCHEMAS[asset.type];
}
export function defaultsFor(type: AssetType): PropertyBag {
return defaultsOf(ASSET_SCHEMAS[type]);
}
export function defaultsOf(schema: PropertySchema): PropertyBag {
const bag: PropertyBag = {};
for (const def of ASSET_SCHEMAS[type]) bag[def.key] = def.default;
for (const def of schema) bag[def.key] = def.default;
return bag;
}
/** Builds a variable definition of the given kind with the kind's natural default. */
export function variableDef(key: string, kind: VariableKind, defaultValue?: PropertyValue): PropertyDef {
switch (kind) {
case 'number':
return { key, label: key, kind, default: typeof defaultValue === 'number' ? defaultValue : 0 };
case 'boolean':
return { key, label: key, kind, default: Boolean(defaultValue ?? false) };
case 'text':
return { key, label: key, kind, default: defaultValue === undefined ? '' : String(defaultValue) };
}
}
/** Clamp / coerce a raw value so it satisfies the definition. Unknown kinds are returned as-is. */
export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue {
switch (def.kind) {
@@ -63,6 +96,11 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
case 'boolean':
return Boolean(value);
case 'text':
case 'key':
return String(value);
case 'enum': {
const s = String(value);
return def.options.some((o) => o.value === s) ? s : def.default;
}
}
}
+4 -4
View File
@@ -1,7 +1,7 @@
import type { Asset } from '../model/Asset';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import type { SceneObject } from '../model/SceneObject';
import { schemaFor } from './PropertySchema';
import { schemaForAsset } from './PropertySchema';
/**
* Effective property values of an asset: schema defaults filled with the asset's own values.
@@ -9,7 +9,7 @@ import { schemaFor } from './PropertySchema';
*/
export function resolveAssetProps(asset: Asset): PropertyBag {
const bag: PropertyBag = {};
for (const def of schemaFor(asset.type)) {
for (const def of schemaForAsset(asset)) {
const own = asset.props[def.key];
bag[def.key] = own !== undefined ? own : def.default;
}
@@ -22,7 +22,7 @@ export function resolveAssetProps(asset: Asset): PropertyBag {
*/
export function resolveSceneObjectProps(asset: Asset, object: SceneObject): PropertyBag {
const bag = resolveAssetProps(asset);
for (const def of schemaFor(asset.type)) {
for (const def of schemaForAsset(asset)) {
const override = object.overrides[def.key];
if (override !== undefined) bag[def.key] = override;
}
@@ -38,7 +38,7 @@ export function isOverridden(object: SceneObject, key: string): boolean {
* counterpart of an override. The inspector highlights such values and offers a reset.
*/
export function isModified(asset: Asset, key: string): boolean {
const def = schemaFor(asset.type).find((d) => d.key === key);
const def = schemaForAsset(asset).find((d) => d.key === key);
if (!def) return false;
const own = asset.props[key];
return own !== undefined && own !== def.default;
+82 -15
View File
@@ -1,9 +1,11 @@
// The graph package index registers the built-in nodes; unknown node types are dropped below.
import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode, nodeDef } from '../graph';
import type { Asset } from '../model/Asset';
import { type AssetType, isAssetType } from '../model/AssetTypes';
import { isAssetType } from '../model/AssetTypes';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { coerceValue, schemaFor } from '../properties/PropertySchema';
import { coerceValue, type PropertyDef, type PropertySchema, schemaFor, VARIABLE_KINDS, variableDef, type VariableKind } from '../properties/PropertySchema';
/**
* Turns untrusted JSON (server file, import) into a well-formed Project. Unknown keys are
@@ -14,22 +16,34 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
const r = asRecord(raw);
const rawAssets = (Array.isArray(r.assets) ? r.assets : []).map(asRecord);
// Pass 1: asset ids and types, needed to coerce scene-object overrides in pass 2.
const types = new Map<string, AssetType>();
// Pass 1: asset ids and their effective schemas (behaviors declare their own variables),
// needed to coerce scene-object overrides in pass 2.
const schemas = new Map<string, PropertySchema>();
const variables = new Map<string, PropertyDef[]>();
for (const a of rawAssets) {
if (typeof a.id === 'string' && a.id && isAssetType(a.type)) types.set(a.id, a.type);
if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue;
if (a.type === 'behavior') {
const vars = normalizeVariables(a.variables);
variables.set(a.id, vars);
schemas.set(a.id, vars);
} else {
schemas.set(a.id, schemaFor(a.type));
}
}
// Pass 2: build assets.
const assets: Asset[] = [];
for (const a of rawAssets) {
const id = typeof a.id === 'string' ? a.id : '';
const type = types.get(id);
if (!type) continue;
const schema = schemas.get(id);
if (!schema || !isAssetType(a.type)) continue;
const type = a.type;
const name = typeof a.name === 'string' ? a.name : type;
const props = normalizeBag(a.props, type, false);
const props = normalizeBag(a.props, schema, false);
if (type === 'scene') {
assets.push({ id, type, name, props, objects: normalizeObjects(a.objects, types) });
assets.push({ id, type, name, props, objects: normalizeObjects(a.objects, schemas) });
} else if (type === 'behavior') {
assets.push({ id, type, name, props, variables: variables.get(id) ?? [], graph: normalizeGraph(a.graph) });
} else {
assets.push({ id, type, name, props });
}
@@ -55,29 +69,82 @@ export function serializeProject(project: Project): unknown {
return JSON.parse(JSON.stringify(project));
}
function normalizeObjects(raw: unknown, types: Map<string, AssetType>): SceneObject[] {
function normalizeObjects(raw: unknown, schemas: Map<string, PropertySchema>): SceneObject[] {
const objects: SceneObject[] = [];
for (const item of Array.isArray(raw) ? raw : []) {
const o = asRecord(item);
if (typeof o.id !== 'string' || !o.id || typeof o.assetId !== 'string') continue;
const type = types.get(o.assetId);
if (!type || type === 'scene') continue; // dangling reference or illegal scene-in-scene
const schema = schemas.get(o.assetId);
if (!schema || schema === schemaFor('scene')) continue; // dangling reference or illegal scene-in-scene
const p = asRecord(o.position);
objects.push({
id: o.id,
assetId: o.assetId,
name: typeof o.name === 'string' ? o.name : '',
position: { x: toNumber(p.x), y: toNumber(p.y), z: toNumber(p.z) },
overrides: normalizeBag(o.overrides, type, true),
overrides: normalizeBag(o.overrides, schema, true),
ownerId: typeof o.ownerId === 'string' ? o.ownerId : '',
});
}
// Owners must exist in the same scene and differ from the object itself.
const ids = new Set(objects.map((o) => o.id));
for (const o of objects) if (o.ownerId && (o.ownerId === o.id || !ids.has(o.ownerId))) o.ownerId = '';
return objects;
}
function normalizeBag(raw: unknown, type: AssetType, sparse: boolean): PropertyBag {
function normalizeVariables(raw: unknown): PropertyDef[] {
const defs: PropertyDef[] = [];
const seen = new Set<string>();
for (const item of Array.isArray(raw) ? raw : []) {
const v = asRecord(item);
const key = typeof v.key === 'string' ? v.key : '';
const kind = (VARIABLE_KINDS as readonly string[]).includes(String(v.kind)) ? (v.kind as VariableKind) : 'number';
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key)) continue;
seen.add(key);
const def = variableDef(key, kind);
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue) as never;
defs.push(def);
}
return defs;
}
function normalizeGraph(raw: unknown): Graph {
const g = asRecord(raw);
const graph = createEmptyGraph();
const nodeIds = new Set<string>();
for (const item of Array.isArray(g.nodes) ? g.nodes : []) {
const n = asRecord(item);
const id = typeof n.id === 'string' ? n.id : '';
const type = typeof n.type === 'string' ? n.type : '';
if (!id || nodeIds.has(id) || !nodeDef(type)) continue; // unknown node types are dropped
nodeIds.add(id);
const params: PropertyBag = {};
for (const [key, value] of Object.entries(asRecord(n.params))) {
if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') params[key] = value;
}
const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params };
graph.nodes.push(node);
}
const edgeIds = new Set<string>();
for (const item of Array.isArray(g.edges) ? g.edges : []) {
const e = asRecord(item);
const from = asRecord(e.from);
const to = asRecord(e.to);
const id = typeof e.id === 'string' ? e.id : '';
if (!id || edgeIds.has(id)) continue;
if (typeof from.node !== 'string' || typeof from.port !== 'string' || typeof to.node !== 'string' || typeof to.port !== 'string') continue;
if (!nodeIds.has(from.node) || !nodeIds.has(to.node)) continue;
edgeIds.add(id);
const edge: GraphEdge = { id, from: { node: from.node, port: from.port }, to: { node: to.node, port: to.port } };
graph.edges.push(edge);
}
return graph;
}
function normalizeBag(raw: unknown, schema: PropertySchema, sparse: boolean): PropertyBag {
const r = asRecord(raw);
const bag: PropertyBag = {};
for (const def of schemaFor(type)) {
for (const def of schema) {
const value = r[def.key];
if (value === undefined || value === null) {
if (!sparse) bag[def.key] = def.default;