diff --git a/README.md b/README.md
index 9b5ca45c..13465637 100644
--- a/README.md
+++ b/README.md
@@ -10,8 +10,9 @@ Live: · Widget playground: 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;
diff --git a/src/core/commands/graphCommands.ts b/src/core/commands/graphCommands.ts
new file mode 100644
index 00000000..a51d275a
--- /dev/null
+++ b/src/core/commands/graphCommands.ts
@@ -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;
+}
diff --git a/src/core/commands/sceneObjectCommands.ts b/src/core/commands/sceneObjectCommands.ts
index ed846707..8ecf4b52 100644
--- a/src/core/commands/sceneObjectCommands.ts
+++ b/src/core/commands/sceneObjectCommands.ts
@@ -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;
+ }
+}
diff --git a/src/core/commands/variableCommands.ts b/src/core/commands/variableCommands.ts
new file mode 100644
index 00000000..56b4b75e
--- /dev/null
+++ b/src/core/commands/variableCommands.ts
@@ -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);
+}
diff --git a/src/core/graph/Graph.ts b/src/core/graph/Graph.ts
new file mode 100644
index 00000000..cffc77fb
--- /dev/null
+++ b/src/core/graph/Graph.ts
@@ -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';
+}
diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts
new file mode 100644
index 00000000..0d793f67
--- /dev/null
+++ b/src/core/graph/GraphInterpreter.ts
@@ -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>();
+ private outputCache = new Map>();
+ 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 = {}, 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 {
+ const def = requireNodeDef(node.type);
+ const inputs: Record = {};
+ 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);
+ }
+}
diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts
new file mode 100644
index 00000000..075cc156
--- /dev/null
+++ b/src/core/graph/NodeRegistry.ts
@@ -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;
+ /** 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, params: PropertyBag): RunResult;
+}
+
+const defs = new Map();
+
+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;
+}
diff --git a/src/core/graph/index.ts b/src/core/graph/index.ts
new file mode 100644
index 00000000..26ef34bd
--- /dev/null
+++ b/src/core/graph/index.ts
@@ -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';
diff --git a/src/core/graph/nodes.ts b/src/core/graph/nodes.ts
new file mode 100644
index 00000000..08b3e3b3
--- /dev/null
+++ b/src/core/graph/nodes.ts
@@ -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 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 {}; } });
diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts
index ee0b2927..8321810c 100644
--- a/src/core/model/Asset.ts
+++ b/src/core/model/Asset.ts
@@ -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';
+}
diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts
index 076bf03d..17ec10ba 100644
--- a/src/core/model/AssetTypes.ts
+++ b/src/core/model/AssetTypes.ts
@@ -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';
+}
diff --git a/src/core/model/SceneObject.ts b/src/core/model/SceneObject.ts
index 8bc5f450..7e4f2658 100644
--- a/src/core/model/SceneObject.ts
+++ b/src/core/model/SceneObject.ts
@@ -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: '',
+ };
}
diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts
index 09fe57bc..8ed14e78 100644
--- a/src/core/properties/PropertySchema.ts
+++ b/src/core/properties/PropertySchema.ts
@@ -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 = {
scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }],
@@ -28,6 +37,8 @@ export const ASSET_SCHEMAS: Record = {
{ 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;
+ }
}
}
diff --git a/src/core/properties/resolve.ts b/src/core/properties/resolve.ts
index 82d1603a..9858336b 100644
--- a/src/core/properties/resolve.ts
+++ b/src/core/properties/resolve.ts
@@ -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;
diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts
index 73133917..228d4225 100644
--- a/src/core/store/serialize.ts
+++ b/src/core/store/serialize.ts
@@ -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();
+ // 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();
+ const variables = new Map();
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): SceneObject[] {
+function normalizeObjects(raw: unknown, schemas: Map): 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();
+ 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();
+ 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();
+ 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;
diff --git a/src/editor/App.ts b/src/editor/App.ts
index 88bcfd57..4473d2ef 100644
--- a/src/editor/App.ts
+++ b/src/editor/App.ts
@@ -1,13 +1,17 @@
import { type Application, Container, Text } from 'pixi.js';
import { DeleteAssetCommand, SetActiveSceneCommand } from '../core/commands/assetCommands';
+import { RemoveNodeCommand } from '../core/commands/graphCommands';
import { RemoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
import { listScenes } from '../core/model/Project';
import { HttpProjectStore } from '../core/store/HttpProjectStore';
import type { ProjectStore } from '../core/store/ProjectStore';
+import { BehaviorSystem } from '../runtime/systems/BehaviorSystem';
+import { Input } from '../runtime/systems/Input';
import { World } from '../runtime/World';
-import { Dialog, HStack, Menu, type MenuItem, Splitter, ui, VStack, type Widget } from '../ui';
+import { Button, Dialog, HStack, Menu, type MenuItem, Splitter, Theme, ui, VStack, type Widget } from '../ui';
import { Viewport } from '../viewport/Viewport';
import { attachAutosave } from './autosave';
+import { GraphEditor } from './graph/GraphEditor';
import { AssetPanel } from './panels/AssetPanel';
import { HierarchyPanel } from './panels/HierarchyPanel';
import { Inspector } from './panels/Inspector';
@@ -16,12 +20,14 @@ import { EditorState } from './state';
const API_ENDPOINT = '/game-maker/api/index.php';
-/** Composes panels, viewport and runtime into the one application that edits and plays. */
+type CentreTab = 'scene' | 'graph';
+
+/** Composes panels, viewport, graph editor and runtime into the one application that edits and plays. */
export async function startEditor(app: Application, root: VStack): Promise {
const state = new EditorState();
const store: ProjectStore = new HttpProjectStore(API_ENDPOINT);
const editor = new Editor(app, root, state, store);
- installDebugHook(app, state, editor.viewport);
+ installDebugHook(app, state, editor);
await editor.boot();
}
@@ -29,7 +35,8 @@ export async function startEditor(app: Application, root: VStack): Promise
* `window.__gm` – used by tools/e2e.mjs to find widgets by their visible text and to
* inspect state. Harmless in production (read-only access).
*/
-function installDebugHook(app: Application, state: EditorState, viewport: Viewport): void {
+function installDebugHook(app: Application, state: EditorState, editor: Editor): void {
+ const { viewport, graphEditor } = editor;
const viewportCenter = () => {
const p = viewport.getGlobalPosition();
return { x: p.x + viewport.w / 2, y: p.y + viewport.h / 2 };
@@ -50,17 +57,42 @@ function installDebugHook(app: Application, state: EditorState, viewport: Viewpo
const p = node.getGlobalPosition();
return { x: p.x + node.width / 2, y: p.y + node.height / 2 };
};
- (window as unknown as { __gm: unknown }).__gm = { state, locate, viewportCenter, viewportInfo: () => viewport.debugInfo(), layerOf: (id: string) => viewport.layerOf(id), drawOrder: () => viewport.drawOrder() };
+ (window as unknown as { __gm: unknown }).__gm = {
+ state,
+ locate,
+ viewportCenter,
+ viewportInfo: () => viewport.debugInfo(),
+ layerOf: (id: string) => viewport.layerOf(id),
+ drawOrder: () => viewport.drawOrder(),
+ graph: {
+ current: () => graphEditor.currentAssetId,
+ portAt: (nodeId: string, port: string, isOutput: boolean) => graphEditor.portGlobal(nodeId, port, isOutput),
+ nodeAt: (nodeId: string) => graphEditor.nodeGlobal(nodeId),
+ center: () => {
+ const p = graphEditor.getGlobalPosition();
+ return { x: p.x + graphEditor.w / 2, y: p.y + graphEditor.h / 2 };
+ },
+ },
+ world: () => editor.worldSnapshot(),
+ tab: () => editor.tab,
+ showTab: (tab: CentreTab) => editor.showTab(tab),
+ };
}
class Editor {
readonly viewport: Viewport;
+ readonly graphEditor: GraphEditor;
+ tab: CentreTab = 'scene';
private readonly leftColumn: VStack;
private readonly inspector: Inspector;
private readonly leftSplitter = new Splitter('x');
private readonly rightSplitter = new Splitter('x');
private readonly workspace: HStack;
+ private readonly centre: VStack;
+ private readonly tabButtons: Record;
private world: World | null = null;
+ private behaviors: BehaviorSystem | null = null;
+ private readonly input = new Input();
private leftWidth = 240;
private rightWidth = 300;
@@ -74,11 +106,30 @@ class Editor {
this.workspace = new HStack();
this.viewport = new Viewport(state);
+ this.graphEditor = new GraphEditor(state);
+ this.graphEditor.traceProvider = (assetId) => this.behaviors?.traceFor(assetId) ?? null;
+ this.graphEditor.timeProvider = () => this.world?.time ?? 0;
this.leftColumn = new VStack({ gap: 1 });
this.leftColumn.add(new AssetPanel(state, () => this.viewport.worldCenter()), 1);
this.leftColumn.add(new HierarchyPanel(state), 1);
this.inspector = new Inspector(state);
+ // Centre: a tab strip (Scene | Graph) above the viewport or the graph editor.
+ this.centre = new VStack();
+ const tabs = new HStack({ gap: 2, padding: 4, align: 'center' });
+ tabs.minHeight = Theme.controlHeight + 8;
+ this.tabButtons = {
+ scene: new Button('Scene', { variant: 'ghost', width: 90, onClick: () => this.showTab('scene') }),
+ graph: new Button('Graph', { variant: 'ghost', width: 90, onClick: () => this.showTab('graph') }),
+ };
+ tabs.add(this.tabButtons.scene);
+ tabs.add(this.tabButtons.graph);
+ this.centre.add(tabs);
+ this.centre.add(this.viewport, 1);
+ this.centre.add(this.graphEditor, 1);
+ this.graphEditor.visible = false;
+ this.tabButtons.scene.setToggled(true);
+
this.leftColumn.minWidth = this.leftWidth;
this.inspector.minWidth = this.rightWidth;
this.leftSplitter.onDrag = (delta) => {
@@ -93,7 +144,7 @@ class Editor {
};
this.workspace.add(this.leftColumn);
this.workspace.add(this.leftSplitter);
- this.workspace.add(this.viewport, 1);
+ this.workspace.add(this.centre, 1);
this.workspace.add(this.rightSplitter);
this.workspace.add(this.inspector);
root.add(this.workspace, 1);
@@ -103,6 +154,20 @@ class Editor {
this.installShortcuts();
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
state.on('mode', () => this.applyMode());
+ // Selecting a graph node switches to the graph; selecting a behavior does not force it.
+ state.on('selection', () => {
+ if (state.selection?.kind === 'node' && this.tab !== 'graph') this.showTab('graph');
+ });
+ }
+
+ showTab(tab: CentreTab): void {
+ this.tab = tab;
+ this.viewport.visible = tab === 'scene';
+ this.graphEditor.visible = tab === 'graph';
+ this.tabButtons.scene.setToggled(tab === 'scene');
+ this.tabButtons.graph.setToggled(tab === 'graph');
+ this.centre.relayout();
+ ui().focus.validate();
}
// --- project lifecycle ---------------------------------------------------------------
@@ -185,25 +250,44 @@ class Editor {
const scene = this.state.activeScene;
if (!scene) return;
ui().focus.blur();
- // No systems yet: the world is static. Behaviour arrives with scripting in a later phase.
this.world = World.instantiate(this.state.project, scene.id);
+ this.input.attach();
+ this.behaviors = new BehaviorSystem(this.state.project, this.input);
+ this.world.addSystem(this.behaviors);
this.viewport.setWorld(this.world);
this.state.setMode('play');
this.state.setNotice(this.world.firstOfType('camera') ? 'Playing · Esc stops' : 'This scene has no camera – nothing is rendered · Esc stops');
+ if (this.world.stopRequested) this.stop();
}
private stop(): void {
- this.world?.destroy();
+ const world = this.world;
this.world = null;
+ this.behaviors = null;
+ this.input.detach();
+ world?.destroy();
this.viewport.setWorld(null);
this.state.setMode('edit');
- this.state.setNotice('');
+ const lastError = world?.log.find((line) => line.startsWith('Error in behavior'));
+ this.state.setNotice(lastError ?? '');
}
private tick(dt: number): void {
if (!this.world) return;
this.world.tick(Math.min(dt, 0.1));
+ this.input.endFrame();
+ if (this.world.stopRequested) {
+ this.stop();
+ return;
+ }
this.viewport.redraw();
+ if (this.graphEditor.visible) this.graphEditor.redraw();
+ }
+
+ /** Read-only view of the running world for tests. */
+ worldSnapshot(): { entities: { objectId: string; x: number; y: number; props: Record }[]; log: string[] } | null {
+ if (!this.world) return null;
+ return { entities: this.world.entities.map((e) => ({ objectId: e.objectId, x: e.x, y: e.y, props: { ...e.props } })), log: [...this.world.log] };
}
private applyMode(): void {
@@ -238,6 +322,7 @@ class Editor {
if (event.key === 'Delete' || event.key === 'Backspace') {
const s = this.state.selection;
if (s?.kind === 'object') this.state.execute(new RemoveSceneObjectCommand(s.sceneId, s.objectId));
+ else if (s?.kind === 'node') this.state.execute(new RemoveNodeCommand(s.assetId, s.nodeId));
else if (s?.kind === 'asset') {
const asset = this.state.project.assets.find((a) => a.id === s.assetId);
if (asset) new Dialog(`Delete "${asset.name}"?`, 'Placed instances are removed as well.', [
diff --git a/src/editor/graph/GraphEditor.ts b/src/editor/graph/GraphEditor.ts
new file mode 100644
index 00000000..9334a85c
--- /dev/null
+++ b/src/editor/graph/GraphEditor.ts
@@ -0,0 +1,396 @@
+import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
+import { AddNodeCommand, ConnectCommand, DisconnectCommand, MoveNodeCommand, RemoveNodeCommand } from '../../core/commands/graphCommands';
+import { edgesInto, edgesOutOf, type GraphEdge, type NodeDef, nodeDef, portsCompatible } from '../../core/graph';
+import type { BehaviorAsset } from '../../core/model/Asset';
+import { findAsset } from '../../core/model/Project';
+import type { Trace } from '../../runtime/systems/BehaviorSystem';
+import { TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
+import { Menu, type Size, Theme, textStyle, Widget } from '../../ui';
+import type { EditorState } from '../state';
+import { NodePalette } from './NodePalette';
+import { NodeView, portColor } from './NodeView';
+
+const GRID_STEP = 24;
+
+interface PortAnchor {
+ nodeId: string;
+ port: string;
+ isOutput: boolean;
+}
+
+/**
+ * The node graph editor for one behavior asset. Shows the graph of whatever behavior the
+ * selection refers to (asset, placed object or node) and keeps showing it while unrelated
+ * things are selected, so the user can flip between Scene and Graph tabs freely.
+ *
+ * Interactions: drag nodes, drag from port to port to connect, right-click the canvas for
+ * the node palette, right-click a node or port for delete/disconnect, wheel to zoom, drag
+ * the empty canvas to pan. All edits go through commands (undoable).
+ */
+export class GraphEditor extends Widget {
+ private readonly bg = new Graphics();
+ private readonly grid = new Graphics();
+ private readonly clip = new Graphics();
+ private readonly canvas = new Container();
+ private readonly edges = new Graphics();
+ private readonly nodeLayer = new Container();
+ private readonly tempEdge = new Graphics();
+ private readonly hint = new Text({ text: '', style: textStyle('muted') });
+ private readonly views = new Map();
+ private assetId: string | null = null;
+ private pan = { x: 0, y: 0 };
+ private zoom = 1;
+ private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
+ private dragging: { nodeId: string; start: { x: number; y: number }; pointer: { x: number; y: number }; moved: boolean } | null = null;
+ private connecting: { from: PortAnchor; pointer: { x: number; y: number } } | null = null;
+ /** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */
+ private needsFit = true;
+ /** Supplies live execution data while playing (null otherwise). */
+ traceProvider: (assetId: string) => Trace | null = () => null;
+ timeProvider: () => number = () => 0;
+
+ constructor(private readonly state: EditorState) {
+ super();
+ this.canvas.addChild(this.edges, this.nodeLayer, this.tempEdge);
+ this.addChild(this.bg, this.grid, this.canvas, this.hint, this.clip);
+ this.canvas.mask = this.clip;
+ this.cursor = 'default';
+
+ this.on('pointerdown', (e: FederatedPointerEvent) => this.onCanvasPointerDown(e));
+ this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
+ this.on('pointerup', (e: FederatedPointerEvent) => this.onPointerUp(e));
+ this.on('pointerupoutside', (e: FederatedPointerEvent) => this.onPointerUp(e));
+ this.on('wheel', (e: FederatedWheelEvent) => {
+ const local = this.toLocal(e.global);
+ const before = this.screenToGraph(local.x, local.y);
+ this.zoom = Math.max(0.25, Math.min(2.5, this.zoom * Math.exp(-e.deltaY * 0.0015)));
+ const after = this.screenToGraph(local.x, local.y);
+ this.pan.x += (after.x - before.x) * this.zoom;
+ this.pan.y += (after.y - before.y) * this.zoom;
+ this.applyView();
+ e.preventDefault();
+ });
+
+ state.on('selection', () => this.followSelection());
+ state.on('load', () => { this.assetId = null; this.followSelection(); });
+ state.on('project', () => this.sync());
+ state.on('mode', () => this.redraw());
+ }
+
+ /** The behavior whose graph is shown, if any. */
+ get currentAssetId(): string | null {
+ return this.assetId;
+ }
+
+ override measure(): Size {
+ return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
+ }
+
+ // --- model binding ----------------------------------------------------------------------
+
+ private asset(): BehaviorAsset | null {
+ const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
+ return asset && asset.type === 'behavior' ? asset : null;
+ }
+
+ private followSelection(): void {
+ const wanted = this.state.selectedBehaviorId;
+ if (wanted && wanted !== this.assetId) {
+ this.assetId = wanted;
+ this.sync();
+ this.needsFit = true;
+ this.fitView();
+ } else if (!this.asset()) {
+ this.assetId = null;
+ this.sync();
+ } else {
+ this.redraw();
+ }
+ }
+
+ /** Rebuilds node views from the model (cheap: views are reused by node id). */
+ private sync(): void {
+ const asset = this.asset();
+ if (!asset) {
+ this.assetId = null;
+ for (const view of this.views.values()) view.destroy({ children: true });
+ this.views.clear();
+ this.redraw();
+ return;
+ }
+ const seen = new Set();
+ for (const node of asset.graph.nodes) {
+ const def = nodeDef(node.type);
+ if (!def) continue;
+ seen.add(node.id);
+ let view = this.views.get(node.id);
+ if (!view || view.node !== node) {
+ view?.destroy({ children: true });
+ view = new NodeView(node, def);
+ view.on('pointerdown', (e: FederatedPointerEvent) => this.onNodePointerDown(view!, e));
+ this.views.set(node.id, view);
+ this.nodeLayer.addChild(view);
+ }
+ view.sync(node, asset.graph, asset);
+ }
+ for (const [id, view] of this.views) {
+ if (!seen.has(id)) {
+ view.destroy({ children: true });
+ this.views.delete(id);
+ }
+ }
+ this.redraw();
+ }
+
+ /** Centres the graph's nodes (or the origin) in the view. Waits for a real size. */
+ private fitView(): void {
+ if (this.w === 0 || this.h === 0) return;
+ this.needsFit = false;
+ const asset = this.asset();
+ if (!asset || asset.graph.nodes.length === 0) {
+ this.pan = { x: this.w / 2 - 80, y: this.h / 3 };
+ this.zoom = 1;
+ } else {
+ const xs = asset.graph.nodes.map((n) => n.x);
+ const ys = asset.graph.nodes.map((n) => n.y);
+ const cx = (Math.min(...xs) + Math.max(...xs) + 176) / 2;
+ const cy = (Math.min(...ys) + Math.max(...ys) + 80) / 2;
+ this.zoom = 1;
+ this.pan = { x: this.w / 2 - cx, y: this.h / 2 - cy };
+ }
+ this.applyView();
+ }
+
+ private applyView(): void {
+ this.canvas.position.set(Math.round(this.pan.x), Math.round(this.pan.y));
+ this.canvas.scale.set(this.zoom);
+ this.redraw();
+ }
+
+ private screenToGraph(sx: number, sy: number): { x: number; y: number } {
+ return { x: (sx - this.pan.x) / this.zoom, y: (sy - this.pan.y) / this.zoom };
+ }
+
+ // --- pointer -----------------------------------------------------------------------------
+
+ private onCanvasPointerDown(e: FederatedPointerEvent): void {
+ if (e.target !== this) return;
+ const local = this.toLocal(e.global);
+ if (e.button === 2) {
+ if (this.assetId && this.state.mode === 'edit') this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y));
+ return;
+ }
+ if (e.button === 0 || e.button === 1) {
+ if (e.button === 0 && this.assetId && this.state.mode === 'edit') this.state.select({ kind: 'asset', assetId: this.assetId });
+ this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
+ }
+ }
+
+ private onNodePointerDown(view: NodeView, e: FederatedPointerEvent): void {
+ if (!this.assetId || this.state.mode !== 'edit') return;
+ e.stopPropagation();
+ const local = view.toLocal(e.global);
+ const hit = view.portAt(local.x, local.y);
+ if (e.button === 2) {
+ if (hit) this.openPortMenu(view, hit.port.key, hit.isOutput, e.global.x, e.global.y);
+ else this.openNodeMenu(view, e.global.x, e.global.y);
+ return;
+ }
+ if (e.button !== 0) return;
+ if (hit) {
+ const p = this.toLocal(e.global);
+ this.connecting = { from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }, pointer: this.screenToGraph(p.x, p.y) };
+ this.redraw();
+ return;
+ }
+ this.state.select({ kind: 'node', assetId: this.assetId, nodeId: view.node.id });
+ const p = this.toLocal(e.global);
+ this.dragging = { nodeId: view.node.id, start: { x: view.node.x, y: view.node.y }, pointer: this.screenToGraph(p.x, p.y), moved: false };
+ this.nodeLayer.addChild(view); // raise
+ }
+
+ private onPointerMove(e: FederatedPointerEvent): void {
+ if (this.panning) {
+ this.pan.x = this.panning.panX + (e.global.x - this.panning.startX);
+ this.pan.y = this.panning.panY + (e.global.y - this.panning.startY);
+ this.applyView();
+ return;
+ }
+ const local = this.toLocal(e.global);
+ const g = this.screenToGraph(local.x, local.y);
+ if (this.dragging) {
+ const dx = g.x - this.dragging.pointer.x;
+ const dy = g.y - this.dragging.pointer.y;
+ if (!this.dragging.moved && Math.hypot(dx, dy) * this.zoom < 3) return;
+ this.dragging.moved = true;
+ const node = this.asset()?.graph.nodes.find((n) => n.id === this.dragging?.nodeId);
+ if (!node) return;
+ node.x = Math.round(this.dragging.start.x + dx);
+ node.y = Math.round(this.dragging.start.y + dy);
+ this.state.preview();
+ } else if (this.connecting) {
+ this.connecting.pointer = g;
+ this.redraw();
+ }
+ }
+
+ private onPointerUp(e: FederatedPointerEvent): void {
+ this.panning = null;
+ const drag = this.dragging;
+ this.dragging = null;
+ if (drag && this.assetId) {
+ const node = this.asset()?.graph.nodes.find((n) => n.id === drag.nodeId);
+ if (node && drag.moved) {
+ const next = { x: node.x, y: node.y };
+ node.x = drag.start.x;
+ node.y = drag.start.y;
+ this.state.execute(new MoveNodeCommand(this.assetId, drag.nodeId, next.x, next.y, drag.start));
+ }
+ }
+ const connecting = this.connecting;
+ this.connecting = null;
+ if (connecting && this.assetId) {
+ const target = this.portUnder(e.global.x, e.global.y);
+ if (target && target.isOutput !== connecting.from.isOutput) {
+ const out = connecting.from.isOutput ? connecting.from : target;
+ const inp = connecting.from.isOutput ? target : connecting.from;
+ this.state.execute(new ConnectCommand(this.assetId, { node: out.nodeId, port: out.port }, { node: inp.nodeId, port: inp.port }));
+ }
+ }
+ this.redraw();
+ }
+
+ private portUnder(globalX: number, globalY: number): PortAnchor | null {
+ for (const view of this.views.values()) {
+ const local = view.toLocal({ x: globalX, y: globalY });
+ const hit = view.portAt(local.x, local.y);
+ if (hit) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput };
+ }
+ return null;
+ }
+
+ // --- menus -------------------------------------------------------------------------------
+
+ private openPalette(globalX: number, globalY: number, at: { x: number; y: number }): void {
+ new NodePalette((def: NodeDef) => {
+ if (!this.assetId) return;
+ const command = new AddNodeCommand(this.assetId, def.type, at.x, at.y);
+ if (this.state.execute(command)) this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id });
+ }).open(globalX, globalY);
+ }
+
+ private openNodeMenu(view: NodeView, globalX: number, globalY: number): void {
+ const assetId = this.assetId;
+ if (!assetId) return;
+ this.state.select({ kind: 'node', assetId, nodeId: view.node.id });
+ new Menu([{ label: 'Delete node', danger: true, onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)) }]).open(globalX, globalY);
+ }
+
+ private openPortMenu(view: NodeView, port: string, isOutput: boolean, globalX: number, globalY: number): void {
+ const asset = this.asset();
+ if (!asset) return;
+ const edges = isOutput ? edgesOutOf(asset.graph, view.node.id, port) : edgesInto(asset.graph, view.node.id, port);
+ if (edges.length === 0) return;
+ new Menu([{ label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`, danger: true, onSelect: () => { for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id)); } }]).open(globalX, globalY);
+ }
+
+ // --- drawing -----------------------------------------------------------------------------
+
+ override layout(): void {
+ this.hint.position.set(Theme.padding, this.h - Theme.padding - 16);
+ if (this.needsFit) this.fitView();
+ }
+
+ override redraw(): void {
+ const c = Theme.colors;
+ this.bg.clear().rect(0, 0, this.w, this.h).fill(c.graphBackground);
+ this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
+ this.drawGrid();
+ const asset = this.asset();
+ this.canvas.visible = asset !== null;
+ if (!asset) {
+ this.hint.text = 'Select a behavior asset (or a placed behavior) to edit its graph.';
+ return;
+ }
+ const playing = this.state.mode === 'play';
+ this.hint.text = asset.graph.nodes.length === 0 ? `${asset.name} · right-click to add a node` : `${asset.name}${playing ? ' · live' : ''}`;
+
+ const trace = playing ? this.traceProvider(asset.id) : null;
+ const now = this.timeProvider();
+ const isHot = (map: Map | undefined, id: string) => {
+ const t = map?.get(id);
+ return t !== undefined && now - t <= TRACE_FADE;
+ };
+ const selectedNode = this.state.selection?.kind === 'node' ? this.state.selection.nodeId : null;
+ for (const view of this.views.values()) {
+ view.selected = view.node.id === selectedNode;
+ view.hot = isHot(trace?.nodes, view.node.id);
+ view.redraw();
+ }
+
+ this.edges.clear();
+ for (const edge of asset.graph.edges) this.drawEdge(edge, isHot(trace?.edges, edge.id));
+
+ this.tempEdge.clear();
+ if (this.connecting) {
+ const view = this.views.get(this.connecting.from.nodeId);
+ const p = view?.portPosition(this.connecting.from.port, this.connecting.from.isOutput);
+ if (view && p) {
+ const a = { x: view.x + p.x, y: view.y + p.y };
+ const b = this.connecting.pointer;
+ const [from, to] = this.connecting.from.isOutput ? [a, b] : [b, a];
+ bezier(this.tempEdge, from.x, from.y, to.x, to.y);
+ this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
+ }
+ }
+ }
+
+ private drawEdge(edge: GraphEdge, hot: boolean): void {
+ const from = this.views.get(edge.from.node);
+ const to = this.views.get(edge.to.node);
+ const a = from?.portPosition(edge.from.port, true);
+ const b = to?.portPosition(edge.to.port, false);
+ if (!from || !to || !a || !b) return;
+ const type = from.def.outputs.find((p) => p.key === edge.from.port)?.type ?? 'any';
+ const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any';
+ const valid = portsCompatible(type, inType);
+ bezier(this.edges, from.x + a.x, from.y + a.y, to.x + b.x, to.y + b.y);
+ this.edges.stroke({ width: hot ? 4 : type === 'exec' ? 2.5 : 2, color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger, alpha: type === 'exec' || hot ? 1 : 0.85 });
+ }
+
+ private drawGrid(): void {
+ this.grid.clear();
+ const step = GRID_STEP * this.zoom;
+ if (step < 8) return;
+ const startX = ((this.pan.x % step) + step) % step;
+ const startY = ((this.pan.y % step) + step) % step;
+ for (let x = startX; x < this.w; x += step) this.grid.moveTo(x, 0).lineTo(x, this.h);
+ for (let y = startY; y < this.h; y += step) this.grid.moveTo(0, y).lineTo(this.w, y);
+ this.grid.stroke({ width: 1, color: 0xffffff, alpha: 0.04 });
+ }
+
+ // --- test hooks -----------------------------------------------------------------------------
+
+ /** Global screen position of a port (tests drive connections with it). */
+ portGlobal(nodeId: string, port: string, isOutput: boolean): { x: number; y: number } | null {
+ const view = this.views.get(nodeId);
+ const p = view?.portPosition(port, isOutput);
+ if (!view || !p) return null;
+ const g = view.toGlobal({ x: p.x, y: p.y });
+ return { x: g.x, y: g.y };
+ }
+
+ /** Global screen position of a node's title bar centre. */
+ nodeGlobal(nodeId: string): { x: number; y: number } | null {
+ const view = this.views.get(nodeId);
+ if (!view) return null;
+ const g = view.toGlobal({ x: view.w / 2, y: 12 });
+ return { x: g.x, y: g.y };
+ }
+}
+
+/** Horizontal S-curve between two points, as used by every node editor. */
+function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void {
+ const dx = Math.max(40, Math.abs(x2 - x1) * 0.5);
+ g.moveTo(x1, y1).bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2);
+}
diff --git a/src/editor/graph/NodePalette.ts b/src/editor/graph/NodePalette.ts
new file mode 100644
index 00000000..ed0e7018
--- /dev/null
+++ b/src/editor/graph/NodePalette.ts
@@ -0,0 +1,55 @@
+import { allNodeDefs, type NodeCategory, type NodeDef } from '../../core/graph';
+import { ListView, Popup, ScrollContainer, TextField, Theme, ui } from '../../ui';
+
+const CATEGORY_ORDER: NodeCategory[] = ['event', 'flow', 'variable', 'data', 'action'];
+
+/** Searchable list of node types; right-click menu of the graph editor. */
+export class NodePalette extends Popup {
+ private readonly search: TextField;
+ private readonly list = new ListView();
+ private filtered: NodeDef[] = [];
+
+ constructor(private readonly onPick: (def: NodeDef) => void) {
+ super({ padding: 4, gap: 4 });
+ this.minWidth = 240;
+ this.search = new TextField('', {
+ placeholder: 'Search nodes…',
+ onInput: () => this.refresh(),
+ onChange: () => {
+ const first = this.filtered[0];
+ if (first) this.pick(first);
+ },
+ });
+ this.content.add(this.search);
+ const scroll = new ScrollContainer();
+ scroll.minHeight = 260;
+ scroll.setContent(this.list);
+ this.content.add(scroll, 1);
+ this.list.rowHeight = Theme.rowHeight - 2;
+ this.list.emptyText = 'No matching node';
+ this.list.onSelect = (id) => {
+ const def = this.filtered.find((d) => d.type === id);
+ if (def) this.pick(def);
+ };
+ this.refresh();
+ }
+
+ override open(globalX: number, globalY: number): void {
+ super.open(globalX, globalY);
+ ui().focus.focus(this.search);
+ }
+
+ private refresh(): void {
+ const query = this.search.value.trim().toLowerCase();
+ this.filtered = allNodeDefs()
+ .filter((d) => !query || d.label.toLowerCase().includes(query) || d.type.toLowerCase().includes(query) || d.description.toLowerCase().includes(query))
+ .sort((a, b) => CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category) || a.label.localeCompare(b.label));
+ this.list.setItems(this.filtered.map((d) => ({ id: d.type, label: d.label, hint: d.category })));
+ (this.content.items[1] as ScrollContainer | undefined)?.relayoutContent();
+ }
+
+ private pick(def: NodeDef): void {
+ this.close();
+ this.onPick(def);
+ }
+}
diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts
new file mode 100644
index 00000000..5944276c
--- /dev/null
+++ b/src/editor/graph/NodeView.ts
@@ -0,0 +1,191 @@
+import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js';
+import { edgesInto, type Graph, type GraphNode, inlineInputs, type NodeCategory, type NodeDef, type PortDef, type PortType } from '../../core/graph';
+import type { BehaviorAsset } from '../../core/model/Asset';
+import { Theme, textStyle, Widget } from '../../ui';
+import { keyLabel } from '../inspector/fieldFactory';
+
+export const NODE_WIDTH = 176;
+export const TITLE_HEIGHT = 24;
+export const ROW_HEIGHT = 20;
+export const PORT_RADIUS = 5;
+/** Distance (graph units) within which a press counts as hitting a port. */
+export const PORT_HIT_RADIUS = 9;
+const PAD = 6;
+
+export function categoryColor(category: NodeCategory): number {
+ const c = Theme.colors;
+ switch (category) {
+ case 'event': return c.categoryEvent;
+ case 'flow': return c.categoryFlow;
+ case 'data': return c.categoryData;
+ case 'variable': return c.categoryVariable;
+ case 'action': return c.categoryAction;
+ }
+}
+
+export function portColor(type: PortType): number {
+ const c = Theme.colors;
+ switch (type) {
+ case 'exec': return c.portExec;
+ case 'number': return c.portNumber;
+ case 'boolean': return c.portBoolean;
+ case 'string': return c.portString;
+ case 'object': return c.portObject;
+ case 'any': return c.portAny;
+ }
+}
+
+export interface PortHit {
+ port: PortDef;
+ isOutput: boolean;
+}
+
+/**
+ * Draws one graph node: a title bar coloured by category, a line per setting and a row per
+ * port pair (inputs left, outputs right). Everything is derived from the NodeDef; the view
+ * never interprets what a node does. Positioned in graph coordinates by the GraphEditor.
+ */
+export class NodeView extends Widget {
+ private readonly box = new Graphics();
+ private readonly ports = new Graphics();
+ private readonly texts: Text[] = [];
+ private paramLines: string[] = [];
+ selected = false;
+ hot = false;
+ /** Whether a data input currently has a connection (inline value hidden). */
+ private connected = new Set();
+
+ constructor(readonly node: GraphNode, readonly def: NodeDef) {
+ super();
+ this.cursor = 'grab';
+ this.addChild(this.box, this.ports);
+ this.sync(node, { nodes: [node], edges: [] }, null);
+ }
+
+ get titleText(): string {
+ return this.def.label;
+ }
+
+ /** Ports sit on the left/right edges; widen the hit area so they can be grabbed. */
+ override resize(w: number, h: number): void {
+ super.resize(w, h);
+ 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], asset)}`);
+ 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;
+ this.rebuildTexts();
+ this.resize(NODE_WIDTH, height);
+ this.position.set(node.x, node.y);
+ }
+
+ private rowCount(): number {
+ return Math.max(this.def.inputs.length, this.def.outputs.length);
+ }
+
+ /** Local position of a port's centre. */
+ portPosition(key: string, isOutput: boolean): { x: number; y: number } | null {
+ const list = isOutput ? this.def.outputs : this.def.inputs;
+ const index = list.findIndex((p) => p.key === key);
+ if (index < 0) return null;
+ return { x: isOutput ? this.w : 0, y: this.rowY(index) };
+ }
+
+ private rowY(index: number): number {
+ return TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + index * ROW_HEIGHT + ROW_HEIGHT / 2;
+ }
+
+ /** Which port (if any) sits under a local point. */
+ portAt(localX: number, localY: number): PortHit | null {
+ for (const [list, isOutput] of [[this.def.inputs, false], [this.def.outputs, true]] as const) {
+ for (let i = 0; i < list.length; i++) {
+ const px = isOutput ? this.w : 0;
+ if (Math.hypot(localX - px, localY - this.rowY(i)) <= PORT_HIT_RADIUS) return { port: list[i]!, isOutput };
+ }
+ }
+ return null;
+ }
+
+ private rebuildTexts(): void {
+ for (const t of this.texts) t.destroy();
+ this.texts.length = 0;
+ const add = (text: string, style: Parameters[0], x: number, y: number, align: 'left' | 'right' = 'left', maxWidth = NODE_WIDTH - PAD * 2) => {
+ const node = new Text({ text: fit(text, style, maxWidth), style: textStyle(style) });
+ node.position.set(align === 'left' ? x : x - node.width, y);
+ this.addChild(node);
+ this.texts.push(node);
+ return node;
+ };
+ add(this.def.label, 'accent', PAD + 2, (TITLE_HEIGHT - 16) / 2);
+ let y = TITLE_HEIGHT + PAD;
+ for (const line of this.paramLines) {
+ add(line, 'small', PAD + 2, y + 2);
+ y += ROW_HEIGHT;
+ }
+ const rows = this.rowCount();
+ for (let i = 0; i < rows; i++) {
+ const input = this.def.inputs[i];
+ const output = this.def.outputs[i];
+ const rowTop = y + i * ROW_HEIGHT + 2;
+ if (input) {
+ let label = input.label;
+ if (input.type !== 'exec' && input.type !== 'object' && !this.connected.has(input.key)) {
+ const value = this.node.params[input.key];
+ if (value !== undefined && value !== '') label += ` ${formatValue(value)}`;
+ }
+ add(label || '', input.type === 'exec' ? 'body' : 'muted', PAD + PORT_RADIUS + 4, rowTop, 'left', NODE_WIDTH / 2 + 20);
+ }
+ if (output) add(output.label || '', output.type === 'exec' ? 'body' : 'muted', this.w - PAD - PORT_RADIUS - 4, rowTop, 'right', NODE_WIDTH / 2 - 10);
+ }
+ }
+
+ override redraw(): void {
+ const c = Theme.colors;
+ this.box.clear();
+ this.box.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 });
+ this.box.roundRect(0, 0, this.w, this.h, Theme.radius).fill(c.node);
+ this.box.roundRect(0, 0, this.w, TITLE_HEIGHT, Theme.radius).fill(categoryColor(this.def.category));
+ this.box.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius).fill(categoryColor(this.def.category));
+ const border = this.hot ? c.hot : this.selected ? c.nodeSelected : c.nodeBorder;
+ this.box.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: this.selected || this.hot ? 2 : 1, color: border });
+
+ this.ports.clear();
+ for (const [list, isOutput] of [[this.def.inputs, false], [this.def.outputs, true]] as const) {
+ list.forEach((port, i) => {
+ const x = isOutput ? this.w : 0;
+ const y = this.rowY(i);
+ if (port.type === 'exec') {
+ this.ports.moveTo(x - 5, y - 5).lineTo(x + 1, y - 5).lineTo(x + 6, y).lineTo(x + 1, y + 5).lineTo(x - 5, y + 5).closePath().fill(portColor('exec'));
+ } else {
+ this.ports.circle(x, y, PORT_RADIUS).fill(portColor(port.type));
+ this.ports.circle(x, y, PORT_RADIUS).stroke({ width: 1, color: c.node });
+ }
+ });
+ }
+ }
+}
+
+function formatParam(kind: string, value: unknown, asset: BehaviorAsset | 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}?`;
+ return formatValue(value);
+}
+
+function formatValue(value: unknown): string {
+ if (typeof value === 'number') return Number.isInteger(value) ? String(value) : value.toFixed(2);
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
+ return String(value);
+}
+
+function fit(text: string, style: Parameters[0], maxWidth: number): string {
+ const s = textStyle(style);
+ if (CanvasTextMetrics.measureText(text, s).width <= maxWidth) return text;
+ let cut = text;
+ while (cut.length > 1 && CanvasTextMetrics.measureText(cut + '…', s).width > maxWidth) cut = cut.slice(0, -1);
+ return cut + '…';
+}
diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts
index 8fba5aac..49540597 100644
--- a/src/editor/inspector/fieldFactory.ts
+++ b/src/editor/inspector/fieldFactory.ts
@@ -1,6 +1,6 @@
import type { PropertyValue } from '../../core/model/Properties';
import type { PropertyDef } from '../../core/properties/PropertySchema';
-import { Button, Checkbox, ColorField, HStack, Label, NumberField, TextField, Theme, type Widget } from '../../ui';
+import { Button, Checkbox, ColorField, HStack, Label, Menu, NumberField, TextField, Theme, ui, type Widget } from '../../ui';
export interface FieldHandlers {
/** Live value while dragging/typing – apply as a preview, no undo entry. */
@@ -20,6 +20,8 @@ export interface FieldRow {
setModified(modified: boolean): void;
}
+export const FIELD_LABEL_WIDTH = 64;
+
/**
* Maps one PropertySchema entry to a labelled row containing the matching widget.
* The inspector is entirely generated through this function: adding a new property
@@ -29,45 +31,12 @@ export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers:
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const label = new Label(def.label, { style: 'muted' });
- label.minWidth = 64;
+ label.minWidth = FIELD_LABEL_WIDTH;
row.add(label);
- let setValue: (v: PropertyValue) => void;
-
- switch (def.kind) {
- case 'number': {
- const field = new NumberField(Number(value), {
- min: def.min,
- max: def.max,
- step: def.step ?? 1,
- onInput: handlers.onInput,
- onChange: handlers.onChange,
- });
- row.add(field, 1);
- setValue = (v) => field.setNumber(Number(v));
- break;
- }
- case 'text': {
- const field = new TextField(String(value), { onInput: handlers.onInput, onChange: handlers.onChange });
- row.add(field, 1);
- setValue = (v) => field.setValue(String(v));
- break;
- }
- case 'color': {
- const field = new ColorField(Number(value), { onInput: handlers.onInput, onChange: handlers.onChange });
- row.add(field, 1);
- setValue = (v) => field.setValue(Number(v));
- break;
- }
- case 'boolean': {
- const field = new Checkbox(Boolean(value));
- field.onChange = handlers.onChange;
- row.add(field);
- row.add(new Label(''), 1);
- setValue = (v) => field.setChecked(Boolean(v));
- break;
- }
- }
+ const field = createField(def, value, handlers);
+ row.add(field.widget, field.grow ? 1 : 0);
+ if (!field.grow) row.add(new Label(''), 1);
let reset: Button | null = null;
if (onReset) {
@@ -78,7 +47,7 @@ export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers:
return {
row,
- setValue,
+ setValue: field.setValue,
setModified(modified) {
label.setStyle(modified ? 'bodyBold' : 'muted');
if (reset && reset.visible !== modified) {
@@ -88,3 +57,107 @@ export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers:
},
};
}
+
+export interface Field {
+ widget: Widget;
+ /** Whether the widget should take the remaining row width. */
+ grow: boolean;
+ setValue(value: PropertyValue): void;
+}
+
+/** The bare widget for a property definition (no label, no reset). */
+export function createField(def: PropertyDef, value: PropertyValue, handlers: FieldHandlers): Field {
+ switch (def.kind) {
+ case 'number': {
+ const field = new NumberField(Number(value), {
+ min: def.min,
+ max: def.max,
+ step: def.step ?? 1,
+ onInput: handlers.onInput,
+ onChange: handlers.onChange,
+ });
+ return { widget: field, grow: true, setValue: (v) => field.setNumber(Number(v)) };
+ }
+ case 'text': {
+ const field = new TextField(String(value), { onInput: handlers.onInput, onChange: handlers.onChange });
+ return { widget: field, grow: true, setValue: (v) => field.setValue(String(v)) };
+ }
+ case 'color': {
+ const field = new ColorField(Number(value), { onInput: handlers.onInput, onChange: handlers.onChange });
+ return { widget: field, grow: true, setValue: (v) => field.setValue(Number(v)) };
+ }
+ case 'boolean': {
+ const field = new Checkbox(Boolean(value));
+ field.onChange = handlers.onChange;
+ return { widget: field, grow: false, setValue: (v) => field.setChecked(Boolean(v)) };
+ }
+ case 'enum': {
+ const labelOf = (v: PropertyValue) => def.options.find((o) => o.value === String(v))?.label ?? (String(v) || '—');
+ const button = new Button(`${labelOf(value)} ▾`);
+ button.onClick = () => {
+ const g = button.getGlobalPosition();
+ const items = def.options.map((o) => ({ label: o.label, onSelect: () => handlers.onChange(o.value) }));
+ if (items.length === 0) items.push({ label: 'No options', onSelect: () => {} });
+ new Menu(items).open(g.x, g.y + button.h + 2);
+ };
+ return { widget: button, grow: true, setValue: (v) => button.setText(`${labelOf(v)} ▾`) };
+ }
+ case 'key': {
+ const button = new KeyCaptureButton(String(value), (key) => handlers.onChange(key));
+ return { widget: button, grow: true, setValue: (v) => button.setKey(String(v)) };
+ }
+ }
+}
+
+/** Shows a key name; click, then press a key to change it (Escape cancels). */
+class KeyCaptureButton extends Button {
+ private key: string;
+ private capturing = false;
+
+ constructor(key: string, private readonly onCapture: (key: string) => void) {
+ super(keyLabel(key));
+ this.key = key;
+ this.onClick = () => {
+ this.capturing = true;
+ this.setText('Press a key…');
+ ui().focus.focus(this);
+ };
+ }
+
+ setKey(key: string): void {
+ this.key = key;
+ if (!this.capturing) this.setText(keyLabel(key));
+ }
+
+ override onKeyDown(event: KeyboardEvent): boolean {
+ if (!this.capturing) return super.onKeyDown(event);
+ event.preventDefault();
+ this.capturing = false;
+ if (event.key === 'Escape') {
+ this.setText(keyLabel(this.key));
+ return true;
+ }
+ if (['Shift', 'Control', 'Alt', 'Meta'].includes(event.key)) {
+ this.capturing = true;
+ return true;
+ }
+ this.key = event.key;
+ this.setText(keyLabel(this.key));
+ this.onCapture(this.key);
+ return true;
+ }
+
+ override onBlur(): void {
+ super.onBlur();
+ if (this.capturing) {
+ this.capturing = false;
+ this.setText(keyLabel(this.key));
+ }
+ }
+}
+
+export function keyLabel(key: string): string {
+ if (key === ' ') return 'Space';
+ if (key === '') return '—';
+ return key.replace(/^Arrow/, '') + (key.startsWith('Arrow') ? ' arrow' : '');
+}
diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts
index 01887faf..0a56b880 100644
--- a/src/editor/panels/AssetPanel.ts
+++ b/src/editor/panels/AssetPanel.ts
@@ -7,7 +7,7 @@ import { findAsset, findUsages } from '../../core/model/Project';
import { Button, Dialog, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui';
import type { EditorState } from '../state';
-const TYPE_LABELS: Record = { scene: 'Scene', camera: 'Camera', sprite: 'Sprite' };
+const TYPE_LABELS: Record = { scene: 'Scene', camera: 'Camera', sprite: 'Sprite', behavior: 'Behavior' };
/** Lists all assets of the project; creates, places and deletes them. */
export class AssetPanel extends Panel {
diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts
index 872e38aa..c2ecf1d9 100644
--- a/src/editor/panels/HierarchyPanel.ts
+++ b/src/editor/panels/HierarchyPanel.ts
@@ -43,10 +43,12 @@ export class HierarchyPanel extends Panel {
(scene?.objects ?? []).map((object) => {
const asset = findAsset(this.state.project, object.assetId);
const props = asset ? resolveSceneObjectProps(asset, object) : {};
+ const owner = object.ownerId ? scene?.objects.find((o) => o.id === object.ownerId) : undefined;
+ const ownerName = owner ? owner.name || findAsset(this.state.project, owner.assetId)?.name || '?' : '';
return {
id: object.id,
label: object.name || asset?.name || '?',
- hint: asset?.type ?? '',
+ hint: owner ? `→ ${ownerName}` : asset?.type ?? '',
swatch: asset?.type === 'sprite' ? Number(props.color ?? 0) : undefined,
};
}),
diff --git a/src/editor/panels/Inspector.ts b/src/editor/panels/Inspector.ts
index b0c498b1..ee21b520 100644
--- a/src/editor/panels/Inspector.ts
+++ b/src/editor/panels/Inspector.ts
@@ -1,21 +1,31 @@
import { RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
+import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands';
import {
ClearOverrideCommand,
MoveSceneObjectCommand,
RenameSceneObjectCommand,
SetOverrideCommand,
+ SetOwnerCommand,
} from '../../core/commands/sceneObjectCommands';
+import { AddVariableCommand, nextVariableName, RemoveVariableCommand, RenameVariableCommand } from '../../core/commands/variableCommands';
+import { edgesInto, inlineInputDef, inlineInputs, nodeDef } from '../../core/graph';
+import type { Asset, BehaviorAsset } from '../../core/model/Asset';
import type { Position, PropertyValue } from '../../core/model/Properties';
import { findAsset, findScene, findSceneObject } from '../../core/model/Project';
-import { schemaFor } from '../../core/properties/PropertySchema';
+import type { SceneObject } from '../../core/model/SceneObject';
+import { type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema';
import { isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve';
-import { Button, HStack, Label, Panel, ScrollContainer, TextField, Theme, VStack } from '../../ui';
-import { createFieldRow, type FieldRow } from '../inspector/fieldFactory';
+import { Button, HStack, Label, Menu, type MenuItem, Panel, ScrollContainer, TextField, Theme, VStack } from '../../ui';
+import { createField, createFieldRow, FIELD_LABEL_WIDTH, type FieldRow } from '../inspector/fieldFactory';
import type { EditorState, Selection } from '../state';
+const VARIABLE_KIND_LABELS: Record = { number: 'Number', boolean: 'Boolean', text: 'Text' };
+const VARIABLE_KIND_MARKS: Record = { number: '#', boolean: '✓', text: 'Aa' };
+
/**
* Shows the properties of the current selection. The field list is generated from the
- * PropertySchema; scene objects additionally get Position and per-property overrides.
+ * PropertySchema; scene objects additionally get Position, Owner and per-property
+ * overrides; behaviors get their variables; graph nodes get their settings and inline inputs.
*/
export class Inspector extends Panel {
private readonly scroll = new ScrollContainer();
@@ -23,6 +33,8 @@ export class Inspector extends Panel {
private fields = new Map();
private nameField: TextField | null = null;
private builtFor: Selection = null;
+ /** Describes the structure the fields were built for; a change means a rebuild. */
+ private signature = '';
constructor(private readonly state: EditorState) {
super({ title: 'Inspector', padding: 0, gap: 0 });
@@ -42,18 +54,45 @@ export class Inspector extends Panel {
this.body = new VStack({ padding: Theme.padding, gap: Theme.gap });
this.fields = new Map();
this.nameField = null;
+ this.signature = this.currentSignature();
const s = this.state.selection;
if (!s) {
- this.body.add(new Label('Select an asset or a scene object.', { style: 'muted' }));
+ this.body.add(new Label('Select an asset, a scene object or a node.', { style: 'muted' }));
} else if (s.kind === 'asset') {
this.buildAsset(s.assetId);
- } else {
+ } else if (s.kind === 'object') {
this.buildObject(s.sceneId, s.objectId);
+ } else {
+ this.buildNode(s.assetId, s.nodeId);
}
this.scroll.setContent(this.body);
}
+ /** Structure-defining facts of the selection: schema keys, connected inputs. */
+ private currentSignature(): string {
+ const s = this.state.selection;
+ if (!s) return '';
+ if (s.kind === 'asset') {
+ const asset = findAsset(this.state.project, s.assetId);
+ return asset ? schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing';
+ }
+ if (s.kind === 'object') {
+ const object = this.currentObject(s.sceneId, s.objectId);
+ const asset = object && findAsset(this.state.project, object.assetId);
+ return asset ? `${object.ownerId}|` + schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing';
+ }
+ const asset = findAsset(this.state.project, s.assetId);
+ if (!asset || asset.type !== 'behavior') return 'missing';
+ const node = asset.graph.nodes.find((n) => n.id === s.nodeId);
+ if (!node) return 'missing';
+ const def = nodeDef(node.type);
+ const connected = def ? inlineInputs(def).filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0).map((p) => p.key) : [];
+ return `${node.type}|${connected.join(',')}|${asset.variables.map((v) => v.key).join(',')}`;
+ }
+
+ // --- assets ------------------------------------------------------------------------------
+
private buildAsset(assetId: string): void {
const asset = findAsset(this.state.project, assetId);
if (!asset) return;
@@ -66,37 +105,87 @@ export class Inspector extends Panel {
});
this.body.add(this.labelled('Name', this.nameField));
+ if (asset.type === 'behavior') {
+ this.buildVariables(asset);
+ return;
+ }
+
// Asset properties: values that differ from the schema default are marked and resettable.
const values = resolveAssetProps(asset);
- for (const def of schemaFor(asset.type)) {
- let original: PropertyValue | undefined;
- const row = createFieldRow(
- def,
- values[def.key] ?? def.default,
- {
- onInput: (value) => {
- const target = findAsset(this.state.project, assetId);
- if (!target) return;
- if (original === undefined) original = target.props[def.key];
- target.props[def.key] = value;
- this.state.preview();
- },
- onChange: (value) => {
- const target = findAsset(this.state.project, assetId);
- if (!target) return;
- if (original !== undefined) target.props[def.key] = original;
- original = undefined;
- this.state.execute(new SetAssetPropertyCommand(assetId, def.key, value));
- },
- },
- () => this.state.execute(new ResetAssetPropertyCommand(assetId, def.key)),
- );
+ for (const def of schemaForAsset(asset)) {
+ const row = createFieldRow(def, values[def.key] ?? def.default, this.assetHandlers(assetId, def), () => this.state.execute(new ResetAssetPropertyCommand(assetId, def.key)));
row.setModified(isModified(asset, def.key));
this.fields.set(def.key, row);
this.body.add(row.row);
}
}
+ private assetHandlers(assetId: string, def: PropertyDef) {
+ let original: PropertyValue | undefined;
+ return {
+ onInput: (value: PropertyValue) => {
+ const target = findAsset(this.state.project, assetId);
+ if (!target) return;
+ if (original === undefined) original = target.props[def.key];
+ target.props[def.key] = value;
+ this.state.preview();
+ },
+ onChange: (value: PropertyValue) => {
+ const target = findAsset(this.state.project, assetId);
+ if (!target) return;
+ if (original !== undefined) target.props[def.key] = original;
+ original = undefined;
+ this.state.execute(new SetAssetPropertyCommand(assetId, def.key, value));
+ },
+ };
+ }
+
+ /** Variables of a behavior: name, kind mark, default value, remove – plus an "add" menu. */
+ private buildVariables(asset: BehaviorAsset): void {
+ const header = new HStack({ gap: Theme.gap, align: 'center' });
+ header.add(new Label('VARIABLES', { style: 'title' }), 1);
+ const add = new Button('+ Variable ▾', { variant: 'ghost' });
+ add.onClick = () => {
+ const g = add.getGlobalPosition();
+ new Menu(
+ VARIABLE_KINDS.map((kind) => ({
+ label: VARIABLE_KIND_LABELS[kind],
+ onSelect: () => {
+ const current = findAsset(this.state.project, asset.id);
+ if (current?.type === 'behavior') this.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind));
+ },
+ })),
+ ).open(g.x, g.y + add.h + 2);
+ };
+ header.add(add);
+ this.body.add(header);
+
+ if (asset.variables.length === 0) this.body.add(new Label('No variables yet.', { style: 'muted' }));
+ const values = resolveAssetProps(asset);
+ for (const def of asset.variables) {
+ const row = new HStack({ gap: Theme.gap, align: 'center' });
+ row.minHeight = Theme.rowHeight;
+ const mark = new Label(VARIABLE_KIND_MARKS[def.kind as VariableKind] ?? '?', { style: 'small', align: 'center' });
+ mark.minWidth = 18;
+ row.add(mark);
+ const name = new TextField(def.key, {
+ width: 90,
+ onChange: (next) => {
+ if (!this.state.execute(new RenameVariableCommand(asset.id, def.key, next.trim()))) this.rebuild();
+ },
+ });
+ row.add(name);
+ const field = createField(def, values[def.key] ?? def.default, this.assetHandlers(asset.id, def));
+ row.add(field.widget, field.grow ? 1 : 0);
+ if (!field.grow) row.add(new Label(''), 1);
+ row.add(new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick: () => this.state.execute(new RemoveVariableCommand(asset.id, def.key)) }));
+ this.fields.set(def.key, { row, setValue: field.setValue, setModified: () => {} });
+ this.body.add(row);
+ }
+ }
+
+ // --- scene objects -------------------------------------------------------------------
+
private buildObject(sceneId: string, objectId: string): void {
const scene = findScene(this.state.project, sceneId);
const object = scene && findSceneObject(scene, objectId);
@@ -112,11 +201,13 @@ export class Inspector extends Panel {
const assetRow = new HStack({ gap: Theme.gap, align: 'center' });
const assetLabel = new Label('Asset', { style: 'muted' });
- assetLabel.minWidth = 64;
+ assetLabel.minWidth = FIELD_LABEL_WIDTH;
assetRow.add(assetLabel);
assetRow.add(new Button(asset.name, { variant: 'ghost', onClick: () => this.state.select({ kind: 'asset', assetId: asset.id }) }), 1);
this.body.add(assetRow);
+ if (asset.type === 'behavior') this.body.add(this.ownerRow(scene.objects, object, sceneId));
+
// Position – belongs to the scene object only. Z is the draw order (higher = on top).
this.body.add(new Label('POSITION', { style: 'title' }));
for (const axis of ['x', 'y', 'z'] as const) {
@@ -143,10 +234,12 @@ export class Inspector extends Panel {
this.body.add(row.row);
}
- // Asset properties with overrides.
- this.body.add(new Label('PROPERTIES', { style: 'title' }));
+ // Asset properties (or behavior variables) with overrides.
+ const schema = schemaForAsset(asset);
+ if (schema.length > 0 || asset.type === 'behavior') this.body.add(new Label(asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES', { style: 'title' }));
+ if (schema.length === 0 && asset.type === 'behavior') this.body.add(new Label('The behavior declares no variables.', { style: 'muted' }));
const values = resolveSceneObjectProps(asset, object);
- for (const def of schemaFor(asset.type)) {
+ for (const def of schema) {
let original: PropertyValue | undefined;
let hadOverride = false;
const row = createFieldRow(
@@ -182,10 +275,104 @@ export class Inspector extends Panel {
}
}
+ /** "Owner: ▾" – picks another object of the same scene, or none. */
+ private ownerRow(objects: SceneObject[], object: SceneObject, sceneId: string): HStack {
+ const row = new HStack({ gap: Theme.gap, align: 'center' });
+ row.minHeight = Theme.rowHeight;
+ const label = new Label('Owner', { style: 'muted' });
+ label.minWidth = FIELD_LABEL_WIDTH;
+ row.add(label);
+ const nameOf = (o: SceneObject) => o.name || findAsset(this.state.project, o.assetId)?.name || '?';
+ const owner = objects.find((o) => o.id === object.ownerId);
+ const button = new Button(`${owner ? nameOf(owner) : 'None'} ▾`);
+ button.onClick = () => {
+ const g = button.getGlobalPosition();
+ const items: MenuItem[] = [{ label: 'None', onSelect: () => this.state.execute(new SetOwnerCommand(sceneId, object.id, '')) }];
+ for (const candidate of objects) {
+ if (candidate.id === object.id) continue;
+ items.push({ label: candidate.id === object.ownerId ? `● ${nameOf(candidate)}` : nameOf(candidate), onSelect: () => this.state.execute(new SetOwnerCommand(sceneId, object.id, candidate.id)) });
+ }
+ new Menu(items).open(g.x, g.y + button.h + 2);
+ };
+ row.add(button, 1);
+ return row;
+ }
+
+ // --- graph nodes ---------------------------------------------------------------------
+
+ private buildNode(assetId: string, nodeId: string): void {
+ const asset = findAsset(this.state.project, assetId);
+ if (!asset || asset.type !== 'behavior') return;
+ const node = asset.graph.nodes.find((n) => n.id === nodeId);
+ const def = node && nodeDef(node.type);
+ if (!node || !def) return;
+
+ this.body.add(new Label(`${def.category.toUpperCase()} NODE`, { style: 'title' }));
+ this.body.add(new Label(def.label, { style: 'bodyBold' }));
+ this.body.add(new Label(def.description, { style: 'muted' }));
+
+ const handlers = (key: string) => {
+ let original: PropertyValue | undefined;
+ let had = false;
+ return {
+ onInput: (value: PropertyValue) => {
+ const target = this.currentNode(assetId, nodeId);
+ if (!target) return;
+ if (original === undefined) {
+ had = target.params[key] !== undefined;
+ original = target.params[key] ?? value;
+ }
+ target.params[key] = value;
+ this.state.preview();
+ },
+ onChange: (value: PropertyValue) => {
+ const target = this.currentNode(assetId, nodeId);
+ if (!target) return;
+ if (original !== undefined) {
+ if (had) target.params[key] = original;
+ else delete target.params[key];
+ }
+ original = undefined;
+ this.state.execute(new SetNodeParamCommand(assetId, nodeId, key, value));
+ },
+ };
+ };
+
+ if (def.params.length > 0) this.body.add(new Label('SETTINGS', { style: 'title' }));
+ for (const param of def.params) {
+ const effective = this.resolveParamDef(param, asset);
+ const row = createFieldRow(effective, node.params[param.key] ?? effective.default, handlers(param.key));
+ this.fields.set(`param:${param.key}`, row);
+ this.body.add(row.row);
+ }
+
+ const free = inlineInputs(def).filter((p) => edgesInto(asset.graph, node.id, p.key).length === 0);
+ if (free.length > 0) this.body.add(new Label('INPUTS (unconnected)', { style: 'title' }));
+ for (const port of free) {
+ const inline = inlineInputDef(port);
+ const row = createFieldRow(inline, node.params[port.key] ?? inline.default, handlers(port.key));
+ this.fields.set(`input:${port.key}`, row);
+ this.body.add(row.row);
+ }
+
+ this.body.add(new Label(''));
+ 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. */
+ 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 })) };
+ }
+ return param;
+ }
+
+ // --- refresh ---------------------------------------------------------------------------
+
/** Push current model values into the widgets (after undo, drag in viewport, …). */
private refreshValues(): void {
const s = this.state.selection;
- if (!sameTarget(s, this.builtFor)) {
+ if (!sameTarget(s, this.builtFor) || this.currentSignature() !== this.signature) {
this.rebuild();
return;
}
@@ -201,6 +388,17 @@ export class Inspector extends Panel {
}
return;
}
+ if (s.kind === 'node') {
+ const node = this.currentNode(s.assetId, s.nodeId);
+ const def = node && nodeDef(node.type);
+ if (!node || !def) return this.rebuild();
+ for (const [key, row] of this.fields) {
+ const [scope, name] = key.split(':') as ['param' | 'input', string];
+ const fallback = scope === 'param' ? def.params.find((p) => p.key === name)?.default : inlineInputDef(def.inputs.find((p) => p.key === name)!).default;
+ row.setValue(node.params[name] ?? fallback ?? '');
+ }
+ return;
+ }
const object = this.currentObject(s.sceneId, s.objectId);
const asset = object && findAsset(this.state.project, object.assetId);
if (!object || !asset) return this.rebuild();
@@ -209,7 +407,7 @@ export class Inspector extends Panel {
this.fields.get('y')?.setValue(object.position.y);
this.fields.get('z')?.setValue(object.position.z);
const values = resolveSceneObjectProps(asset, object);
- for (const def of schemaFor(asset.type)) {
+ for (const def of schemaForAsset(asset)) {
const row = this.fields.get(def.key);
row?.setValue(values[def.key] ?? def.default);
row?.setModified(isOverridden(object, def.key));
@@ -221,11 +419,16 @@ export class Inspector extends Panel {
return scene && findSceneObject(scene, objectId);
}
+ private currentNode(assetId: string, nodeId: string) {
+ const asset = findAsset(this.state.project, assetId);
+ return asset?.type === 'behavior' ? asset.graph.nodes.find((n) => n.id === nodeId) : undefined;
+ }
+
private labelled(text: string, field: TextField): HStack {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const label = new Label(text, { style: 'muted' });
- label.minWidth = 64;
+ label.minWidth = FIELD_LABEL_WIDTH;
row.add(label);
row.add(field, 1);
return row;
@@ -237,5 +440,6 @@ function sameTarget(a: Selection, b: Selection): boolean {
if (a.kind !== b.kind) return false;
if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;
if (a.kind === 'object' && b.kind === 'object') return a.sceneId === b.sceneId && a.objectId === b.objectId;
+ if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId;
return false;
}
diff --git a/src/editor/state.ts b/src/editor/state.ts
index c4694363..0529f30b 100644
--- a/src/editor/state.ts
+++ b/src/editor/state.ts
@@ -6,6 +6,8 @@ import { createEmptyProject, findScene, type Project } from '../core/model/Proje
export type Selection =
| { kind: 'asset'; assetId: string }
| { kind: 'object'; sceneId: string; objectId: string }
+ /** A node inside a behavior's graph. */
+ | { kind: 'node'; assetId: string; nodeId: string }
| null;
export type EditorMode = 'edit' | 'play';
@@ -129,6 +131,22 @@ export class EditorState {
const scene = findScene(this.project, s.sceneId);
if (!scene || !scene.objects.some((o) => o.id === s.objectId)) this.select(null);
}
+ if (s.kind === 'node') {
+ const asset = this.project.assets.find((a) => a.id === s.assetId);
+ // Fall back to the behavior asset so the graph stays open when its node vanished (undo).
+ if (!asset || asset.type !== 'behavior') this.select(null);
+ else if (!asset.graph.nodes.some((n) => n.id === s.nodeId)) this.select({ kind: 'asset', assetId: s.assetId });
+ }
+ }
+
+ /** The behavior asset the current selection refers to (asset, placed object or node), if any. */
+ get selectedBehaviorId(): string | null {
+ const s = this.selection;
+ if (!s) return null;
+ if (s.kind === 'node') return s.assetId;
+ const assetId = s.kind === 'asset' ? s.assetId : findScene(this.project, s.sceneId)?.objects.find((o) => o.id === s.objectId)?.assetId;
+ const asset = assetId ? this.project.assets.find((a) => a.id === assetId) : undefined;
+ return asset && asset.type === 'behavior' ? asset.id : null;
}
// --- mode / status -------------------------------------------------------------------
@@ -156,5 +174,6 @@ function sameSelection(a: Selection, b: Selection): boolean {
if (!a || !b || a.kind !== b.kind) return false;
if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;
if (a.kind === 'object' && b.kind === 'object') return a.sceneId === b.sceneId && a.objectId === b.objectId;
+ if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId;
return false;
}
diff --git a/src/runtime/Entity.ts b/src/runtime/Entity.ts
index 97f4a10d..a54406fc 100644
--- a/src/runtime/Entity.ts
+++ b/src/runtime/Entity.ts
@@ -17,5 +17,8 @@ export interface Entity {
y: number;
/** Draw order; higher is drawn on top. */
z: number;
+ /** For behaviors: the resolved variables (mutable state). For others: resolved properties. */
props: PropertyBag;
+ /** Scene object id of the owner (behaviors), empty when none. */
+ ownerObjectId: string;
}
diff --git a/src/runtime/World.ts b/src/runtime/World.ts
index 3d5db1fc..23e9386c 100644
--- a/src/runtime/World.ts
+++ b/src/runtime/World.ts
@@ -10,6 +10,10 @@ import type { System } from './systems/System';
export class World {
readonly entities: Entity[] = [];
readonly backgroundColor: number;
+ /** 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. */
+ readonly log: string[] = [];
private readonly systems: System[] = [];
private elapsed = 0;
@@ -36,6 +40,7 @@ export class World {
y: object.position.y,
z: object.position.z,
props: resolveSceneObjectProps(asset, object),
+ ownerObjectId: object.ownerId,
});
}
return world;
@@ -61,10 +66,19 @@ export class World {
return this.entities.find((e) => e.objectId === objectId);
}
+ findByName(name: string): Entity | undefined {
+ return this.entities.find((e) => e.name === name);
+ }
+
firstOfType(type: Entity['type']): Entity | undefined {
return this.entities.find((e) => e.type === type);
}
+ remove(entity: Entity): void {
+ const index = this.entities.indexOf(entity);
+ if (index >= 0) this.entities.splice(index, 1);
+ }
+
destroy(): void {
for (const system of this.systems) system.stop?.(this);
this.systems.length = 0;
diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts
new file mode 100644
index 00000000..db8f1820
--- /dev/null
+++ b/src/runtime/systems/BehaviorSystem.ts
@@ -0,0 +1,128 @@
+import { type EntityHandle, GraphInterpreter, type NodeContext, type TraceListener } from '../../core/graph';
+import { findAsset, type Project } from '../../core/model/Project';
+import type { Entity } from '../Entity';
+import type { World } from '../World';
+import type { Input } from './Input';
+import type { System } from './System';
+
+/** How long (seconds) a traversed edge/node counts as "hot" for the editor's live highlight. */
+export const TRACE_FADE = 0.35;
+
+/** Recency of exec edges and nodes, keyed by id, in world time. */
+export interface Trace {
+ edges: Map;
+ nodes: Map;
+}
+
+interface Instance {
+ entity: Entity;
+ interpreter: GraphInterpreter;
+ trace: Trace;
+}
+
+/**
+ * Runs the graph of every behavior entity. Each instance has its own interpreter (and
+ * therefore its own timers and variable state); the project is only read to find graphs.
+ */
+export class BehaviorSystem implements System {
+ private instances: Instance[] = [];
+ private world: World | null = null;
+
+ constructor(private readonly project: Project, private readonly input: Input) {}
+
+ start(world: World): void {
+ this.world = world;
+ this.instances = [];
+ for (const entity of world.entities) {
+ if (entity.type !== 'behavior') continue;
+ const asset = findAsset(this.project, entity.assetId);
+ if (!asset || asset.type !== 'behavior') continue;
+ const trace: Trace = { edges: new Map(), nodes: new Map() };
+ const interpreter = new GraphInterpreter(asset.graph);
+ interpreter.trace = this.traceListener(trace, world);
+ this.instances.push({ entity, interpreter, trace });
+ }
+ for (const instance of this.instances) this.run(instance, (i, ctx) => i.fire('OnStart', ctx), 0);
+ }
+
+ update(world: World, dt: number): void {
+ const keyOf = (node: { params: Record }) => String(node.params.key ?? '');
+ for (const instance of this.instances) {
+ if (!world.entities.includes(instance.entity)) continue; // destroyed during play
+ this.run(instance, (i, ctx) => {
+ for (const key of this.input.justPressed) i.fire('OnKeyDown', ctx, {}, (n) => keyOf(n) === key);
+ for (const key of this.input.justReleased) i.fire('OnKeyUp', ctx, {}, (n) => keyOf(n) === key);
+ for (const key of this.input.heldKeys) i.fire('OnKeyHeld', ctx, { dt }, (n) => keyOf(n) === key);
+ for (const node of i.eventNodes('OnTimer')) {
+ const state = i.nodeState.get(node.id) ?? { elapsed: 0 };
+ i.nodeState.set(node.id, state);
+ const interval = Math.max(0.01, Number(node.params.interval ?? 1));
+ state.elapsed = (state.elapsed ?? 0) + dt;
+ while (state.elapsed >= interval) {
+ state.elapsed -= interval;
+ i.fire('OnTimer', ctx, {}, (n) => n.id === node.id);
+ }
+ }
+ i.fire('OnUpdate', ctx, { dt });
+ }, dt);
+ }
+ }
+
+ stop(): void {
+ this.instances = [];
+ this.world = null;
+ }
+
+ /** Live highlight data for the graph editor: merged over all instances of the asset. */
+ traceFor(assetId: string): Trace {
+ const merged: Trace = { edges: new Map(), nodes: new Map() };
+ for (const instance of this.instances) {
+ if (instance.entity.assetId !== assetId) continue;
+ for (const [id, t] of instance.trace.edges) merged.edges.set(id, Math.max(t, merged.edges.get(id) ?? -Infinity));
+ for (const [id, t] of instance.trace.nodes) merged.nodes.set(id, Math.max(t, merged.nodes.get(id) ?? -Infinity));
+ }
+ return merged;
+ }
+
+ private run(instance: Instance, body: (interpreter: GraphInterpreter, ctx: NodeContext) => void, dt: number): void {
+ const world = this.world;
+ if (!world) return;
+ const ctx = this.context(instance.entity, world, dt);
+ try {
+ body(instance.interpreter, ctx);
+ } catch (error) {
+ world.log.push(`Error in behavior "${instance.entity.name}": ${error instanceof Error ? error.message : String(error)}`);
+ world.stopRequested = true;
+ }
+ }
+
+ private context(entity: Entity, world: World, dt: number): NodeContext {
+ return {
+ self: entity,
+ owner: entity.ownerObjectId ? world.findByObjectId(entity.ownerObjectId) ?? null : null,
+ variables: entity.props,
+ time: world.time,
+ dt,
+ findByName: (name) => world.findByName(name) ?? null,
+ isKeyDown: (key) => this.input.isDown(key),
+ log: (message) => {
+ world.log.push(message);
+ console.log(`[behavior ${entity.name}]`, message);
+ },
+ destroy: (target: EntityHandle) => {
+ const found = world.entities.find((e) => e === target || e.id === target.id);
+ if (found) world.remove(found);
+ },
+ stop: () => {
+ world.stopRequested = true;
+ },
+ };
+ }
+
+ private traceListener(trace: Trace, world: World): TraceListener {
+ return {
+ onEdge: (id) => trace.edges.set(id, world.time),
+ onNode: (id) => trace.nodes.set(id, world.time),
+ };
+ }
+}
diff --git a/src/runtime/systems/Input.ts b/src/runtime/systems/Input.ts
index f942cd45..03b52758 100644
--- a/src/runtime/systems/Input.ts
+++ b/src/runtime/systems/Input.ts
@@ -1,13 +1,24 @@
-/** Keyboard state tracker for play mode. Only records keys; systems interpret them. */
+/**
+ * Keyboard state tracker for play mode. Records which keys are held and which went down /
+ * up since the last `endFrame()`, so systems can react to edges as well as to held keys.
+ */
export class Input {
private readonly down = new Set();
+ private readonly pressed = new Set();
+ private readonly released = new Set();
private readonly onKeyDown = (e: KeyboardEvent) => {
if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return;
+ if (!this.down.has(e.key)) this.pressed.add(e.key); // ignore auto-repeat
this.down.add(e.key);
if (e.key.startsWith('Arrow') || e.key === ' ') e.preventDefault();
};
- private readonly onKeyUp = (e: KeyboardEvent) => this.down.delete(e.key);
- private readonly onBlur = () => this.down.clear();
+ private readonly onKeyUp = (e: KeyboardEvent) => {
+ if (this.down.delete(e.key)) this.released.add(e.key);
+ };
+ private readonly onBlur = () => {
+ for (const key of this.down) this.released.add(key);
+ this.down.clear();
+ };
attach(): void {
window.addEventListener('keydown', this.onKeyDown);
@@ -20,12 +31,34 @@ export class Input {
window.removeEventListener('keyup', this.onKeyUp);
window.removeEventListener('blur', this.onBlur);
this.down.clear();
+ this.pressed.clear();
+ this.released.clear();
}
isDown(key: string): boolean {
return this.down.has(key);
}
+ /** Keys that went down since the last frame. */
+ get justPressed(): ReadonlySet {
+ return this.pressed;
+ }
+
+ /** Keys that went up since the last frame. */
+ get justReleased(): ReadonlySet {
+ return this.released;
+ }
+
+ get heldKeys(): ReadonlySet {
+ return this.down;
+ }
+
+ /** Clears the per-frame edge sets; call once after all systems consumed them. */
+ endFrame(): void {
+ this.pressed.clear();
+ this.released.clear();
+ }
+
/** -1, 0 or 1 for a pair of keys. */
axis(negative: string, positive: string): number {
return (this.isDown(positive) ? 1 : 0) - (this.isDown(negative) ? 1 : 0);
diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts
index 326fa4b9..161cdc46 100644
--- a/src/ui/Theme.ts
+++ b/src/ui/Theme.ts
@@ -30,6 +30,25 @@ export const Theme = {
viewport: 0x121316,
grid: 0x1f2126,
gizmo: 0x4c8dff,
+ /** Behavior symbols and owner links. */
+ behavior: 0xb066ff,
+ /** Node graph: canvas, node chrome, per-category title colors, port colors, live highlight. */
+ graphBackground: 0x15171b,
+ node: 0x262a32,
+ nodeBorder: 0x3b404b,
+ nodeSelected: 0x4c8dff,
+ categoryEvent: 0xc0392b,
+ categoryFlow: 0x7f8c8d,
+ categoryData: 0x2e8b57,
+ categoryVariable: 0xb8860b,
+ categoryAction: 0x2f6fd6,
+ portExec: 0xffffff,
+ portNumber: 0x6fcf97,
+ portBoolean: 0xe57373,
+ portString: 0xe6a8ff,
+ portObject: 0x56ccf2,
+ portAny: 0xbdbdbd,
+ hot: 0xffc857,
},
} as const;
diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts
index 2ea126b9..ade77fea 100644
--- a/src/ui/widgets/ListView.ts
+++ b/src/ui/widgets/ListView.ts
@@ -25,7 +25,7 @@ export class ListView extends Widget {
onSelect?: (id: string | null) => void;
onActivate?: (id: string) => void;
onContextMenu?: (id: string, globalX: number, globalY: number) => void;
- rowHeight = Theme.rowHeight;
+ rowHeight: number = Theme.rowHeight;
emptyText = 'Nothing here yet';
private readonly emptyLabel = new Text({ text: '', style: textStyle('muted') });
diff --git a/src/viewport/Viewport.ts b/src/viewport/Viewport.ts
index 6a635de7..33d20f06 100644
--- a/src/viewport/Viewport.ts
+++ b/src/viewport/Viewport.ts
@@ -1,6 +1,6 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
-import type { AssetType } from '../core/model/AssetTypes';
+import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
import { type Position, type PropertyBag, sortByZ, type Vec2 } from '../core/model/Properties';
import { findAsset } from '../core/model/Project';
import { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera';
@@ -19,8 +19,13 @@ interface DrawItem {
/** Draw order (see `Position.z`). */
z: number;
props: PropertyBag;
+ /** Id of the owner item (behaviors), empty when none. */
+ ownerId: string;
}
+/** World-unit size of the behavior symbol (a diamond). */
+const BEHAVIOR_SYMBOL = 28;
+
/**
* How world coordinates map onto the viewport: the world point (`x`, `y`) sits at the
* centre of the screen rectangle `rect` (viewport-local pixels) at `scale` pixels per
@@ -57,6 +62,8 @@ export class Viewport extends Widget {
* hide them, no matter what z-order the scene ends up with.
*/
private readonly overlay = new Container();
+ /** Dashed owner links (behavior → owner), drawn in world units beneath the symbols. */
+ private readonly links = new Graphics();
private readonly contentMask = new Graphics();
private readonly gizmos = new Graphics();
private readonly nodes = new Map();
@@ -70,7 +77,9 @@ export class Viewport extends Widget {
constructor(private readonly state: EditorState) {
super();
- this.world.addChild(this.objects, this.overlay);
+ this.world.addChild(this.objects, this.links, this.overlay);
+ // Decorations must not swallow hits meant for the objects beneath them.
+ this.links.eventMode = 'none';
this.content.addChild(this.world, this.gizmos);
this.addChild(this.bg, this.grid, this.content, this.contentMask);
this.content.mask = this.contentMask;
@@ -204,9 +213,10 @@ export class Viewport extends Widget {
private collectItems(): { items: DrawItem[]; background: number } {
if (this.playWorld) {
+ // Symbols (camera, behaviors) are editor-only; the running game shows scene content.
return {
background: this.playWorld.backgroundColor,
- items: this.playWorld.entities.filter((e) => e.type !== 'camera').map((e) => ({ id: e.objectId, type: e.type, name: e.name, x: e.x, y: e.y, z: e.z, props: e.props })),
+ items: this.playWorld.entities.filter((e) => !isSymbolType(e.type)).map((e) => ({ id: e.objectId, type: e.type, name: e.name, x: e.x, y: e.y, z: e.z, props: e.props, ownerId: '' })),
};
}
const scene = this.state.activeScene;
@@ -215,7 +225,7 @@ export class Viewport extends Widget {
for (const object of scene.objects) {
const asset = findAsset(this.state.project, object.assetId);
if (!asset) continue;
- items.push({ id: object.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) });
+ items.push({ id: object.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), ownerId: object.ownerId });
}
return { items, background: Number(scene.props.backgroundColor ?? Theme.colors.viewport) };
}
@@ -223,6 +233,7 @@ export class Viewport extends Widget {
/** Size of an item's footprint in world units (sprite box or camera frame). */
private itemSize(item: DrawItem): { w: number; h: number } {
if (item.type === 'sprite') return { w: Number(item.props.width ?? 1), h: Number(item.props.height ?? 1) };
+ if (item.type === 'behavior') return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL };
return cameraFrame(item.props);
}
@@ -276,8 +287,16 @@ export class Viewport extends Widget {
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).stroke({ width: 2 / view.scale, color: Theme.colors.textMuted });
node.rect(-12, -8, 24, 16).fill(Theme.colors.textMuted);
node.moveTo(12, 0).lineTo(20, -6).lineTo(20, 6).closePath().fill(Theme.colors.textMuted);
+ } else if (item.type === 'behavior') {
+ // A diamond with a small "circuit" mark: the logic symbol.
+ const r = BEHAVIOR_SYMBOL / 2;
+ node.moveTo(0, -r).lineTo(r, 0).lineTo(0, r).lineTo(-r, 0).closePath().fill(Theme.colors.behavior);
+ node.moveTo(-6, -3).lineTo(-1, -3).lineTo(-1, 3).lineTo(6, 3).stroke({ width: 2, color: Theme.colors.accentText });
+ node.circle(-6, -3, 2).fill(Theme.colors.accentText);
+ node.circle(6, 3, 2).fill(Theme.colors.accentText);
}
}
+ this.drawOwnerLinks(items, editing, view.scale);
for (const [id, node] of this.nodes) {
if (!seen.has(id)) {
node.destroy();
@@ -307,25 +326,42 @@ export class Viewport extends Widget {
/** Items drawn as editor symbols rather than as scene content. */
private isSymbol(item: DrawItem): boolean {
- return item.type === 'camera';
+ return isSymbolType(item.type);
+ }
+
+ /** Dashed line from every behavior symbol to its owner (edit mode only). */
+ private drawOwnerLinks(items: DrawItem[], editing: boolean, scale: number): void {
+ this.links.clear();
+ if (!editing) return;
+ const byId = new Map(items.map((i) => [i.id, i]));
+ for (const item of items) {
+ if (item.type !== 'behavior' || !item.ownerId) continue;
+ const owner = byId.get(item.ownerId);
+ if (!owner) continue;
+ dashedLine(this.links, item.x, item.y, owner.x, owner.y, 8 / scale, 6 / scale);
+ this.links.stroke({ width: 1.5 / scale, color: Theme.colors.behavior, alpha: 0.8 });
+ this.links.circle(owner.x, owner.y, 3 / scale).fill(Theme.colors.behavior);
+ }
}
private syncLabel(item: DrawItem, show: boolean, scale: number): void {
let label = this.labels.get(item.id);
- if (!show || item.type !== 'camera') {
+ if (!show || !this.isSymbol(item)) {
if (label) label.visible = false;
return;
}
if (!label) {
label = new Text({ text: '', style: textStyle('small') });
+ label.eventMode = 'none';
this.labels.set(item.id, label);
this.overlay.addChild(label);
}
- const frame = cameraFrame(item.props);
+ const size = this.itemSize(item);
label.visible = true;
label.text = item.name;
label.scale.set(1 / scale);
- label.position.set(item.x - frame.w / 2, item.y - frame.h / 2 - 16 / scale);
+ if (item.type === 'camera') label.position.set(item.x - size.w / 2, item.y - size.h / 2 - 16 / scale);
+ else label.position.set(item.x + size.w / 2 + 4 / scale, item.y - 8 / scale);
}
private drawGrid(editing: boolean): void {
@@ -369,3 +405,15 @@ export class Viewport extends Widget {
this.dragging = { objectId, sceneId: scene.id, start: { ...object.position }, pointer: { x: local.x, y: local.y }, moved: false };
}
}
+
+/** Adds a dashed line path to `g` (Pixi has no dash style); the caller strokes it. */
+export function dashedLine(g: Graphics, x1: number, y1: number, x2: number, y2: number, dash: number, gap: number): void {
+ const length = Math.hypot(x2 - x1, y2 - y1);
+ if (length === 0) return;
+ const ux = (x2 - x1) / length;
+ const uy = (y2 - y1) / length;
+ for (let d = 0; d < length; d += dash + gap) {
+ const end = Math.min(length, d + dash);
+ g.moveTo(x1 + ux * d, y1 + uy * d).lineTo(x1 + ux * end, y1 + uy * end);
+ }
+}
diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts
new file mode 100644
index 00000000..83f2a199
--- /dev/null
+++ b/tests/core/graph.test.ts
@@ -0,0 +1,289 @@
+import { describe, expect, it } from 'vitest';
+import { buildAsset } from '../../src/core/commands/assetCommands';
+import { CommandStack } from '../../src/core/commands/CommandStack';
+import { AddNodeCommand, ConnectCommand, DisconnectCommand, RemoveNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands';
+import { PlaceAssetCommand, SetOwnerCommand } from '../../src/core/commands/sceneObjectCommands';
+import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
+import { type EntityHandle, GraphInterpreter, type NodeContext, allNodeDefs, portsCompatible } from '../../src/core/graph';
+import type { BehaviorAsset } from '../../src/core/model/Asset';
+import { createEmptyProject } from '../../src/core/model/Project';
+import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
+
+function entity(name: string, x = 0): EntityHandle {
+ return { id: name, name, x, y: 0, z: 0, props: {} };
+}
+
+function context(overrides: Partial = {}): NodeContext & { logs: string[]; stopped: boolean } {
+ const logs: string[] = [];
+ const ctx = {
+ self: entity('self'),
+ owner: entity('owner'),
+ variables: { speed: 100 },
+ time: 0,
+ dt: 0.5,
+ logs,
+ stopped: false,
+ findByName: () => null,
+ isKeyDown: () => false,
+ log: (m: string) => logs.push(m),
+ destroy: () => {},
+ stop: () => { ctx.stopped = true; },
+ ...overrides,
+ };
+ return ctx;
+}
+
+/** Builds "On key held → Move(owner, dx = speed × dt)" through commands, like the editor would. */
+function buildMoverProject() {
+ const project = createEmptyProject('p', 'P');
+ const behavior = buildAsset('behavior', 'Mover', 'mover') as BehaviorAsset;
+ project.assets.push(behavior);
+ const stack = new CommandStack(project);
+ stack.execute(new AddVariableCommand('mover', 'speed', 'number'));
+ const held = new AddNodeCommand('mover', 'OnKeyHeld', 0, 0, { key: 'ArrowRight' });
+ const owner = new AddNodeCommand('mover', 'Owner', 0, 100);
+ const speed = new AddNodeCommand('mover', 'GetVariable', 0, 200, { variable: 'speed' });
+ const mul = new AddNodeCommand('mover', 'Multiply', 200, 150);
+ const move = new AddNodeCommand('mover', 'Move', 400, 0);
+ for (const c of [held, owner, speed, mul, move]) stack.execute(c);
+ stack.execute(new ConnectCommand('mover', { node: held.node.id, port: 'exec' }, { node: move.node.id, port: 'exec' }));
+ stack.execute(new ConnectCommand('mover', { node: owner.node.id, port: 'object' }, { node: move.node.id, port: 'object' }));
+ stack.execute(new ConnectCommand('mover', { node: speed.node.id, port: 'value' }, { node: mul.node.id, port: 'a' }));
+ stack.execute(new ConnectCommand('mover', { node: held.node.id, port: 'dt' }, { node: mul.node.id, port: 'b' }));
+ stack.execute(new ConnectCommand('mover', { node: mul.node.id, port: 'result' }, { node: move.node.id, port: 'dx' }));
+ return { project, behavior, stack, nodes: { held, owner, speed, mul, move } };
+}
+
+describe('node registry', () => {
+ it('registers the built-in node set with well-formed ports', () => {
+ const defs = allNodeDefs();
+ expect(defs.length).toBeGreaterThan(20);
+ for (const def of defs) {
+ const keys = [...def.inputs, ...def.outputs].map((p) => p.key);
+ expect(new Set(def.inputs.map((p) => p.key)).size).toBe(def.inputs.length);
+ expect(new Set(def.outputs.map((p) => p.key)).size).toBe(def.outputs.length);
+ if (def.category === 'event') expect(def.inputs).toHaveLength(0);
+ else expect(def.run).toBeTypeOf('function');
+ expect(keys.every((k) => k.length > 0)).toBe(true);
+ }
+ });
+
+ it('type-checks port connections', () => {
+ expect(portsCompatible('exec', 'exec')).toBe(true);
+ expect(portsCompatible('exec', 'number')).toBe(false);
+ expect(portsCompatible('number', 'any')).toBe(true);
+ expect(portsCompatible('any', 'boolean')).toBe(true);
+ expect(portsCompatible('number', 'boolean')).toBe(false);
+ expect(portsCompatible('object', 'object')).toBe(true);
+ });
+});
+
+describe('GraphInterpreter', () => {
+ it('runs an event pulse and pulls data lazily (speed × dt moves the owner)', () => {
+ const { behavior } = buildMoverProject();
+ const interpreter = new GraphInterpreter(behavior.graph);
+ const ctx = context({ variables: { speed: 100 } });
+ const fired = interpreter.fire('OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowRight');
+ expect(fired).toBe(1);
+ expect(ctx.owner!.x).toBe(50);
+ expect(interpreter.fire('OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowLeft')).toBe(0);
+ expect(ctx.owner!.x).toBe(50);
+ });
+
+ it('uses inline values for unconnected inputs', () => {
+ const project = createEmptyProject('p', 'P');
+ const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset;
+ project.assets.push(behavior);
+ const stack = new CommandStack(project);
+ const start = new AddNodeCommand('b', 'OnStart', 0, 0);
+ const self = new AddNodeCommand('b', 'Self', 0, 0);
+ const move = new AddNodeCommand('b', 'Move', 0, 0, { dx: 7, dy: -3 });
+ for (const c of [start, self, move]) stack.execute(c);
+ stack.execute(new ConnectCommand('b', { node: start.node.id, port: 'exec' }, { node: move.node.id, port: 'exec' }));
+ stack.execute(new ConnectCommand('b', { node: self.node.id, port: 'object' }, { node: move.node.id, port: 'object' }));
+ const ctx = context();
+ new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
+ expect(ctx.self.x).toBe(7);
+ expect(ctx.self.y).toBe(-3);
+ });
+
+ it('follows Branch, writes variables and logs', () => {
+ const project = createEmptyProject('p', 'P');
+ const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset;
+ project.assets.push(behavior);
+ const stack = new CommandStack(project);
+ stack.execute(new AddVariableCommand('b', 'count', 'number'));
+ const start = new AddNodeCommand('b', 'OnStart', 0, 0);
+ const get = new AddNodeCommand('b', 'GetVariable', 0, 0, { variable: 'count' });
+ const cmp = new AddNodeCommand('b', 'Compare', 0, 0, { op: '<', b: 3 });
+ const branch = new AddNodeCommand('b', 'Branch', 0, 0);
+ const add = new AddNodeCommand('b', 'Add', 0, 0, { b: 1 });
+ const set = new AddNodeCommand('b', 'SetVariable', 0, 0, { variable: 'count' });
+ const logThen = new AddNodeCommand('b', 'Log', 0, 0, { message: 'then' });
+ const logElse = new AddNodeCommand('b', 'Log', 0, 0, { message: 'else' });
+ for (const c of [start, get, cmp, branch, add, set, logThen, logElse]) stack.execute(c);
+ const connect = (from: AddNodeCommand, fp: string, to: AddNodeCommand, tp: string) =>
+ stack.execute(new ConnectCommand('b', { node: from.node.id, port: fp }, { node: to.node.id, port: tp }));
+ connect(start, 'exec', branch, 'exec');
+ connect(get, 'value', cmp, 'a');
+ connect(cmp, 'result', branch, 'condition');
+ connect(branch, 'then', set, 'exec');
+ connect(get, 'value', add, 'a');
+ connect(add, 'result', set, 'value');
+ connect(set, 'exec', logThen, 'exec');
+ connect(branch, 'else', logElse, 'exec');
+
+ const interpreter = new GraphInterpreter(behavior.graph);
+ const ctx = context({ variables: { count: 0 } });
+ for (let i = 0; i < 5; i++) interpreter.fire('OnStart', ctx);
+ expect(ctx.variables.count).toBe(3);
+ expect(ctx.logs).toEqual(['then', 'then', 'then', 'else', 'else']);
+ });
+
+ it('reports traversed edges and nodes to the trace listener', () => {
+ const { behavior, nodes } = buildMoverProject();
+ const interpreter = new GraphInterpreter(behavior.graph);
+ const edges: string[] = [];
+ const visited: string[] = [];
+ interpreter.trace = { onEdge: (id) => edges.push(id), onNode: (id) => visited.push(id) };
+ interpreter.fire('OnKeyHeld', context(), { dt: 1 });
+ expect(edges).toHaveLength(1);
+ expect(visited).toContain(nodes.mul.node.id);
+ expect(visited).toContain(nodes.move.node.id);
+ });
+
+ it('stops a runaway flow instead of hanging', () => {
+ const project = createEmptyProject('p', 'P');
+ const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset;
+ project.assets.push(behavior);
+ const stack = new CommandStack(project);
+ const start = new AddNodeCommand('b', 'OnStart', 0, 0);
+ const a = new AddNodeCommand('b', 'Sequence', 0, 0);
+ const b = new AddNodeCommand('b', 'Sequence', 0, 0);
+ for (const c of [start, a, b]) stack.execute(c);
+ stack.execute(new ConnectCommand('b', { node: start.node.id, port: 'exec' }, { node: a.node.id, port: 'exec' }));
+ stack.execute(new ConnectCommand('b', { node: a.node.id, port: 'a' }, { node: b.node.id, port: 'exec' }));
+ stack.execute(new ConnectCommand('b', { node: b.node.id, port: 'a' }, { node: a.node.id, port: 'exec' }));
+ expect(() => new GraphInterpreter(behavior.graph).fire('OnStart', context())).toThrow(/step limit/);
+ });
+});
+
+describe('graph commands', () => {
+ it('rejects incompatible, self and duplicate connections', () => {
+ const { stack, nodes } = buildMoverProject();
+ expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.held.node.id, port: 'exec' }, { node: nodes.mul.node.id, port: 'a' }))).toThrow(/Cannot connect/);
+ expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.mul.node.id, port: 'result' }, { node: nodes.mul.node.id, port: 'a' }))).toThrow(/itself/);
+ expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.held.node.id, port: 'exec' }, { node: nodes.move.node.id, port: 'exec' }))).toThrow(/Already/);
+ expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.held.node.id, port: 'nope' }, { node: nodes.move.node.id, port: 'exec' }))).toThrow(/Port/);
+ });
+
+ it('replaces the edge of a data input and restores it on undo', () => {
+ const { behavior, stack, nodes } = buildMoverProject();
+ const literal = new AddNodeCommand('mover', 'Number', 0, 0, { value: 9 });
+ stack.execute(literal);
+ const before = behavior.graph.edges.length;
+ stack.execute(new ConnectCommand('mover', { node: literal.node.id, port: 'value' }, { node: nodes.move.node.id, port: 'dx' }));
+ expect(behavior.graph.edges.length).toBe(before); // one replaced, one added
+ expect(behavior.graph.edges.find((e) => e.to.node === nodes.move.node.id && e.to.port === 'dx')!.from.node).toBe(literal.node.id);
+ stack.undo();
+ expect(behavior.graph.edges.find((e) => e.to.node === nodes.move.node.id && e.to.port === 'dx')!.from.node).toBe(nodes.mul.node.id);
+ });
+
+ it('removing a node drops its edges; undo restores both', () => {
+ const { behavior, stack, nodes } = buildMoverProject();
+ const edgesBefore = behavior.graph.edges.length;
+ stack.execute(new RemoveNodeCommand('mover', nodes.mul.node.id));
+ expect(behavior.graph.nodes.some((n) => n.id === nodes.mul.node.id)).toBe(false);
+ expect(behavior.graph.edges.length).toBe(edgesBefore - 3);
+ stack.undo();
+ expect(behavior.graph.nodes.some((n) => n.id === nodes.mul.node.id)).toBe(true);
+ expect(behavior.graph.edges.length).toBe(edgesBefore);
+ stack.execute(new DisconnectCommand('mover', behavior.graph.edges[0]!.id));
+ expect(behavior.graph.edges.length).toBe(edgesBefore - 1);
+ stack.undo();
+ expect(behavior.graph.edges.length).toBe(edgesBefore);
+ });
+
+ it('coerces declared params and keeps inline input values', () => {
+ const { behavior, stack, nodes } = buildMoverProject();
+ stack.execute(new SetNodeParamCommand('mover', nodes.held.node.id, 'key', 'ArrowLeft'));
+ stack.execute(new SetNodeParamCommand('mover', nodes.move.node.id, 'dy', 4));
+ const held = behavior.graph.nodes.find((n) => n.id === nodes.held.node.id)!;
+ const move = behavior.graph.nodes.find((n) => n.id === nodes.move.node.id)!;
+ expect(held.params.key).toBe('ArrowLeft');
+ expect(move.params.dy).toBe(4);
+ stack.undo();
+ expect(move.params.dy).toBeUndefined();
+ });
+});
+
+describe('variables', () => {
+ it('adds, renames (including graph references and overrides) and removes variables', () => {
+ const { project, behavior, stack, nodes } = buildMoverProject();
+ const scene = buildAsset('scene', 'S', 's');
+ project.assets.push(scene);
+ const place = new PlaceAssetCommand('s', 'mover', { x: 0, y: 0 });
+ stack.execute(place);
+ const object = (scene as { objects: { id: string; overrides: Record }[] }).objects[0]!;
+ object.overrides.speed = 5;
+
+ stack.execute(new RenameVariableCommand('mover', 'speed', 'velocity'));
+ expect(behavior.variables[0]!.key).toBe('velocity');
+ expect(behavior.props).toEqual({ velocity: 0 });
+ expect(object.overrides).toEqual({ velocity: 5 });
+ expect(behavior.graph.nodes.find((n) => n.id === nodes.speed.node.id)!.params.variable).toBe('velocity');
+ stack.undo();
+ expect(behavior.variables[0]!.key).toBe('speed');
+ expect(object.overrides).toEqual({ speed: 5 });
+
+ expect(() => stack.execute(new AddVariableCommand('mover', 'speed', 'number'))).toThrow(/already/);
+ expect(() => stack.execute(new AddVariableCommand('mover', '1bad', 'number'))).toThrow(/names/);
+
+ stack.execute(new RemoveVariableCommand('mover', 'speed'));
+ expect(behavior.variables).toHaveLength(0);
+ expect(object.overrides).toEqual({});
+ stack.undo();
+ expect(behavior.variables).toHaveLength(1);
+ expect(object.overrides).toEqual({ speed: 5 });
+ });
+
+ it('owner references are validated and cleared when the owner disappears', () => {
+ const project = createEmptyProject('p', 'P');
+ const scene = buildAsset('scene', 'S', 's');
+ const sprite = buildAsset('sprite', 'Box', 'box');
+ const behavior = buildAsset('behavior', 'B', 'b');
+ project.assets.push(scene, sprite, behavior);
+ const stack = new CommandStack(project);
+ const placeBox = new PlaceAssetCommand('s', 'box', { x: 0, y: 0 });
+ const placeB = new PlaceAssetCommand('s', 'b', { x: 0, y: 0 });
+ stack.execute(placeBox);
+ stack.execute(placeB);
+ expect(() => stack.execute(new SetOwnerCommand('s', placeB.objectId, placeB.objectId))).toThrow(/itself/);
+ stack.execute(new SetOwnerCommand('s', placeB.objectId, placeBox.objectId));
+ const objects = () => (project.assets[0] as { objects: { id: string; ownerId: string }[] }).objects;
+ expect(objects()[1]!.ownerId).toBe(placeBox.objectId);
+ // Round trip keeps the owner; a dangling owner is dropped by the loader.
+ const loaded = deserializeProject(serializeProject(project));
+ expect((loaded.assets[0] as { objects: { ownerId: string }[] }).objects[1]!.ownerId).toBe(placeBox.objectId);
+ const raw = serializeProject(project) as { assets: { objects?: { ownerId: string }[] }[] };
+ raw.assets[0]!.objects![1]!.ownerId = 'ghost';
+ expect((deserializeProject(raw).assets[0] as { objects: { ownerId: string }[] }).objects[1]!.ownerId).toBe('');
+ });
+});
+
+describe('behavior serialization', () => {
+ it('round-trips variables and graph, dropping unknown node types and dangling edges', () => {
+ const { project, behavior } = buildMoverProject();
+ const loaded = deserializeProject(serializeProject(project)).assets.find((a) => a.id === 'mover') as BehaviorAsset;
+ expect(loaded.variables).toEqual(behavior.variables);
+ expect(loaded.graph).toEqual(behavior.graph);
+ expect(loaded.props).toEqual({ speed: 0 });
+
+ const raw = serializeProject(project) as { assets: { graph?: { nodes: unknown[]; edges: unknown[] } }[] };
+ raw.assets[0]!.graph!.nodes.push({ id: 'zzz', type: 'DoesNotExist', x: 0, y: 0, params: {} });
+ raw.assets[0]!.graph!.edges.push({ id: 'e-bad', from: { node: 'zzz', port: 'exec' }, to: { node: behavior.graph.nodes[0]!.id, port: 'exec' } });
+ const cleaned = deserializeProject(raw).assets[0] as BehaviorAsset;
+ expect(cleaned.graph.nodes.length).toBe(behavior.graph.nodes.length);
+ expect(cleaned.graph.edges.length).toBe(behavior.graph.edges.length);
+ });
+});
diff --git a/tests/core/serialize.test.ts b/tests/core/serialize.test.ts
index 724566d1..b674006b 100644
--- a/tests/core/serialize.test.ts
+++ b/tests/core/serialize.test.ts
@@ -52,6 +52,7 @@ describe('serialize', () => {
name: '',
position: { x: 5, y: 0, z: 0 },
overrides: { color: 0x00ff00, width: 1 },
+ ownerId: '',
});
expect(project.assets[1]!.name).toBe('sprite');
expect(project.assets[1]!.props).toEqual({ width: 10, height: 100, color: 0xe0553f });
diff --git a/tools/e2e.mjs b/tools/e2e.mjs
index 714f61dc..9f568f56 100644
--- a/tools/e2e.mjs
+++ b/tools/e2e.mjs
@@ -199,7 +199,8 @@ try {
await shot('07-undo-redo');
// 7. Play: the camera frame is rendered (both crates lie inside the 640×360 frame at the
- // origin), arrow keys do nothing, Esc stops without touching the model.
+ // origin; the behavior symbol is editor-only). The "Mover" behavior owns the red crate:
+ // holding ArrowRight moves it in the world (speed × dt), Esc stops without touching the model.
await clickText('Renamed crate');
const xBeforePlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`);
await clickText('▶ Play');
@@ -210,15 +211,24 @@ try {
check('play renders the camera frame with both crates', JSON.parse(info).rendering && JSON.parse(info).visibleItems === 2, info);
await shot('08-playing');
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
- await sleep(300);
+ await sleep(400);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
+ const worldAfterRight = JSON.parse(await evaluate('JSON.stringify(__gm.world())'));
+ const movedCrate = worldAfterRight.entities.find((e) => e.objectId === 'o1');
+ check('behavior graph moved its owner while ArrowRight was held', movedCrate.x > -150 + 30, `world x=${movedCrate.x}`);
+ await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
+ await sleep(250);
+ await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
+ const afterLeft = JSON.parse(await evaluate('JSON.stringify(__gm.world())')).entities.find((e) => e.objectId === 'o1');
+ check('inline dx (−3 per frame) moved the owner back left', afterLeft.x < movedCrate.x, `world x=${afterLeft.x}`);
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
await sleep(300);
s = JSON.parse(await stateJson());
check('escape stopped play', s.mode === 'edit', JSON.stringify(s));
await shot('09-stopped');
const o2AfterPlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`);
- check('play did not write back to the model', o2AfterPlay === xBeforePlay, `${o2AfterPlay} vs ${xBeforePlay}`);
+ const o1AfterPlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').position.x`);
+ check('play did not write back to the model', o2AfterPlay === xBeforePlay && o1AfterPlay === -150, `${o2AfterPlay} vs ${xBeforePlay}, o1=${o1AfterPlay}`);
// 7b. Culling: move the blue crate far outside the camera frame → only one item visible.
await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 5000; __gm.state.preview()`);
@@ -234,7 +244,7 @@ try {
await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o3' })`);
await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 });
await sleep(200);
- check('camera object deleted', (await evaluate('__gm.state.activeScene.objects.length')) === 2);
+ check('camera object deleted', (await evaluate('__gm.state.activeScene.objects.length')) === 3);
await clickText('▶ Play');
await sleep(300);
info = await evaluate('JSON.stringify(__gm.viewportInfo())');
@@ -246,7 +256,7 @@ try {
await sleep(300);
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(200);
- check('undo restored the camera object', (await evaluate('__gm.state.activeScene.objects.length')) === 3);
+ check('undo restored the camera object', (await evaluate('__gm.state.activeScene.objects.length')) === 4);
// 8. Context menu on the Crate asset → menu shows "Place in scene".
await clickText('Crate', 'right');
@@ -257,7 +267,7 @@ try {
await clickText('Place in scene');
await sleep(200);
const count = await evaluate('__gm.state.activeScene.objects.length');
- check('placing from the menu adds an object', count === 4, `objects=${count}`);
+ check('placing from the menu adds an object', count === 5, `objects=${count}`);
// 9. Create a new asset through the + menu.
await clickText('+');
@@ -330,6 +340,93 @@ try {
check('undo restores the asset value', heightUndone === 60, `height=${heightUndone}`);
check('reset button visible again after undo', Boolean(await evaluate(`__gm.locate('↺')`)));
+ // 13. Behavior graph editor. Selecting the Mover asset binds the graph; the Graph tab shows it.
+ await clickText('Mover');
+ await evaluate(`__gm.showTab('graph')`);
+ await sleep(250);
+ check('graph editor shows the selected behavior', (await evaluate(`__gm.graph.current()`)) === 'mover' && (await evaluate('__gm.tab()')) === 'graph');
+ await shot('16-graph');
+ const nodeOf = (id) => evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === ${JSON.stringify(id)}))`).then(JSON.parse);
+ const graphCounts = () => evaluate(`(() => { const g = __gm.state.project.assets.find(a => a.id === 'mover').graph; return JSON.stringify({ nodes: g.nodes.length, edges: g.edges.length }); })()`).then(JSON.parse);
+
+ // 13a. Drag a node: clicking its title selects it, dragging moves it (undoable).
+ const mulBefore = await nodeOf('n-mul');
+ const mulPos = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.nodeAt('n-mul'))`));
+ await drag(mulPos.x, mulPos.y, mulPos.x + 60, mulPos.y + 40);
+ s = JSON.parse(await stateJson());
+ const mulAfter = await nodeOf('n-mul');
+ check('dragging a node selects and moves it', s.selection?.kind === 'node' && s.selection.nodeId === 'n-mul' && mulAfter.x === mulBefore.x + 60 && mulAfter.y === mulBefore.y + 40, `${JSON.stringify(s.selection)} ${mulBefore.x},${mulBefore.y} → ${mulAfter.x},${mulAfter.y}`);
+ check('node inspector shows the node', Boolean(await evaluate(`__gm.locate('Multiply')`)) && Boolean(await evaluate(`__gm.locate('DATA NODE')`)));
+ await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
+ await sleep(150);
+ const mulUndone = await nodeOf('n-mul');
+ check('undo moves the node back', mulUndone.x === mulBefore.x && mulUndone.y === mulBefore.y, `${mulUndone.x},${mulUndone.y}`);
+
+ // 13b. Right-click the empty canvas → palette → search "log" → Enter adds a Log node.
+ const gcenter = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.center())`));
+ const before13 = await graphCounts();
+ await click(gcenter.x + 250, gcenter.y + 250, 'right');
+ await sleep(250);
+ check('node palette opens', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
+ await typeText('log');
+ await sleep(100);
+ await keyPress('Enter', 'Enter', { windowsVirtualKeyCode: 13 });
+ await sleep(250);
+ const after13 = await graphCounts();
+ s = JSON.parse(await stateJson());
+ const newNode = s.selection?.kind === 'node' ? await nodeOf(s.selection.nodeId) : null;
+ check('palette adds a Log node and selects it', after13.nodes === before13.nodes + 1 && newNode?.type === 'Log', JSON.stringify({ after13, newNode }));
+ await shot('17-node-added');
+
+ // 13c. Connect: drag from the upper Move node's exec output onto the Log node's exec input.
+ const fromPort = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.portAt('n-move', 'exec', true))`));
+ const toPort = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'exec', false))`));
+ await drag(fromPort.x, fromPort.y, toPort.x, toPort.y, 16);
+ const connected = await graphCounts();
+ check('dragging port to port connects the nodes', connected.edges === before13.edges + 1, JSON.stringify(connected));
+ // An exec output must not connect to a data input: dragging onto "message" does nothing.
+ const dataPort = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'message', false))`));
+ await drag(fromPort.x, fromPort.y, dataPort.x, dataPort.y, 16);
+ check('incompatible ports are rejected', (await graphCounts()).edges === before13.edges + 1 && s.mode === 'edit');
+ await shot('18-connected');
+
+ // 13d. Delete removes the selected node together with its edge; undo brings both back.
+ await evaluate(`__gm.state.select({ kind: 'node', assetId: 'mover', nodeId: ${JSON.stringify(newNode.id)} })`);
+ await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 });
+ await sleep(150);
+ const deleted = await graphCounts();
+ check('Delete removes the node and its edge', deleted.nodes === before13.nodes && deleted.edges === before13.edges, JSON.stringify(deleted));
+ await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
+ await sleep(150);
+ const restored = await graphCounts();
+ check('undo restores node and edge', restored.nodes === before13.nodes + 1 && restored.edges === before13.edges + 1, JSON.stringify(restored));
+
+ // 13e. Live highlight: play with the graph open, hold ArrowRight → the exec edge is hot.
+ await clickText('▶ Play');
+ await sleep(200);
+ await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
+ await sleep(300);
+ await shot('19-graph-live');
+ await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
+ await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
+ await sleep(200);
+
+ // 13f. Variables: "+ Variable ▾" → Number adds "var1" with default 0; undo removes it.
+ await evaluate(`__gm.state.select({ kind: 'asset', assetId: 'mover' })`);
+ await sleep(150);
+ await clickText('+ Variable ▾');
+ await sleep(150);
+ await clickText('Number');
+ await sleep(200);
+ const vars = JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === 'mover').variables.map(v => v.key))`));
+ check('adding a variable declares it on the behavior', vars.join(',') === 'speed,var1', vars.join(','));
+ await shot('20-variable-added');
+ await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
+ await sleep(150);
+ const varsUndone = JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === 'mover').variables.map(v => v.key))`));
+ check('undo removes the variable', varsUndone.join(',') === 'speed', varsUndone.join(','));
+ await evaluate(`__gm.showTab('scene')`);
+
check('no exceptions during run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | '));
ws.close();
} catch (error) {
diff --git a/tools/seed-demo.mjs b/tools/seed-demo.mjs
index 20846e7c..b5a32688 100644
--- a/tools/seed-demo.mjs
+++ b/tools/seed-demo.mjs
@@ -10,6 +10,31 @@ if (!projectId) {
process.exit(2);
}
+/**
+ * "Mover" behavior: ArrowRight moves the owner by speed × dt per frame (data flow through
+ * a variable and a Multiply node), ArrowLeft moves it by an inline −3 per frame.
+ */
+const moverGraph = {
+ nodes: [
+ { id: 'n-right', type: 'OnKeyHeld', x: 0, y: 0, params: { key: 'ArrowRight' } },
+ { id: 'n-owner', type: 'Owner', x: 0, y: 140, params: {} },
+ { id: 'n-speed', type: 'GetVariable', x: 0, y: 220, params: { variable: 'speed' } },
+ { id: 'n-mul', type: 'Multiply', x: 220, y: 180, params: {} },
+ { id: 'n-move', type: 'Move', x: 440, y: 0, params: { dy: 0 } },
+ { id: 'n-left', type: 'OnKeyHeld', x: 0, y: 340, params: { key: 'ArrowLeft' } },
+ { id: 'n-move2', type: 'Move', x: 440, y: 340, params: { dx: -3, dy: 0 } },
+ ],
+ edges: [
+ { id: 'e1', from: { node: 'n-right', port: 'exec' }, to: { node: 'n-move', port: 'exec' } },
+ { id: 'e2', from: { node: 'n-owner', port: 'object' }, to: { node: 'n-move', port: 'object' } },
+ { id: 'e3', from: { node: 'n-speed', port: 'value' }, to: { node: 'n-mul', port: 'a' } },
+ { id: 'e4', from: { node: 'n-right', port: 'dt' }, to: { node: 'n-mul', port: 'b' } },
+ { id: 'e5', from: { node: 'n-mul', port: 'result' }, to: { node: 'n-move', port: 'dx' } },
+ { id: 'e6', from: { node: 'n-left', port: 'exec' }, to: { node: 'n-move2', port: 'exec' } },
+ { id: 'e7', from: { node: 'n-owner', port: 'object' }, to: { node: 'n-move2', port: 'object' } },
+ ],
+};
+
const project = {
formatVersion: 1,
id: projectId,
@@ -22,13 +47,22 @@ const project = {
name: 'Level 1',
props: { backgroundColor: 0x22252c },
objects: [
- { id: 'o1', assetId: 'crate', name: '', position: { x: -150, y: 0, z: 0 }, overrides: {} },
- { id: 'o2', assetId: 'crate', name: 'Blue crate', position: { x: 120, y: -80, z: 0 }, overrides: { width: 140, color: 0x4488ff } },
- { id: 'o3', assetId: 'cam', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {} },
+ { id: 'o1', assetId: 'crate', name: '', position: { x: -150, y: 0, z: 0 }, overrides: {}, ownerId: '' },
+ { id: 'o2', assetId: 'crate', name: 'Blue crate', position: { x: 120, y: -80, z: 0 }, overrides: { width: 140, color: 0x4488ff }, ownerId: '' },
+ { id: 'o3', assetId: 'cam', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, ownerId: '' },
+ { id: 'o4', assetId: 'mover', name: '', position: { x: -150, y: 110, z: 0 }, overrides: {}, ownerId: 'o1' },
],
},
{ id: 'crate', type: 'sprite', name: 'Crate', props: { width: 100, height: 60, color: 0xe0553f } },
{ id: 'cam', type: 'camera', name: 'Main camera', props: { width: 640, height: 360, zoom: 1 } },
+ {
+ id: 'mover',
+ type: 'behavior',
+ name: 'Mover',
+ props: { speed: 200 },
+ variables: [{ key: 'speed', label: 'speed', kind: 'number', default: 0 }],
+ graph: moverGraph,
+ },
],
};