From 922774778bd3bc915c40e9ed124426eb4b3d5d26 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 7 Sep 2026 12:44:43 +0200 Subject: [PATCH] Behaviors with Blueprint-style node graph, graph editor tab and BehaviorSystem Co-Authored-By: Claude Fable 5.1 --- README.md | 46 ++- src/core/commands/assetCommands.ts | 26 +- src/core/commands/graphCommands.ts | 173 ++++++++++ src/core/commands/sceneObjectCommands.ts | 44 ++- src/core/commands/variableCommands.ts | 132 ++++++++ src/core/graph/Graph.ts | 70 ++++ src/core/graph/GraphInterpreter.ts | 112 +++++++ src/core/graph/NodeRegistry.ts | 154 +++++++++ src/core/graph/index.ts | 6 + src/core/graph/nodes.ts | 256 +++++++++++++++ src/core/model/Asset.ts | 19 +- src/core/model/AssetTypes.ts | 12 +- src/core/model/SceneObject.ts | 14 +- src/core/properties/PropertySchema.ts | 42 ++- src/core/properties/resolve.ts | 8 +- src/core/store/serialize.ts | 97 +++++- src/editor/App.ts | 103 +++++- src/editor/graph/GraphEditor.ts | 396 +++++++++++++++++++++++ src/editor/graph/NodePalette.ts | 55 ++++ src/editor/graph/NodeView.ts | 191 +++++++++++ src/editor/inspector/fieldFactory.ts | 151 ++++++--- src/editor/panels/AssetPanel.ts | 2 +- src/editor/panels/HierarchyPanel.ts | 4 +- src/editor/panels/Inspector.ts | 276 +++++++++++++--- src/editor/state.ts | 19 ++ src/runtime/Entity.ts | 3 + src/runtime/World.ts | 14 + src/runtime/systems/BehaviorSystem.ts | 128 ++++++++ src/runtime/systems/Input.ts | 39 ++- src/ui/Theme.ts | 19 ++ src/ui/widgets/ListView.ts | 2 +- src/viewport/Viewport.ts | 64 +++- tests/core/graph.test.ts | 289 +++++++++++++++++ tests/core/serialize.test.ts | 1 + tools/e2e.mjs | 109 ++++++- tools/seed-demo.mjs | 40 ++- 36 files changed, 2969 insertions(+), 147 deletions(-) create mode 100644 src/core/commands/graphCommands.ts create mode 100644 src/core/commands/variableCommands.ts create mode 100644 src/core/graph/Graph.ts create mode 100644 src/core/graph/GraphInterpreter.ts create mode 100644 src/core/graph/NodeRegistry.ts create mode 100644 src/core/graph/index.ts create mode 100644 src/core/graph/nodes.ts create mode 100644 src/editor/graph/GraphEditor.ts create mode 100644 src/editor/graph/NodePalette.ts create mode 100644 src/editor/graph/NodeView.ts create mode 100644 src/runtime/systems/BehaviorSystem.ts create mode 100644 tests/core/graph.test.ts 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, + }, ], };