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; /** Position local to `parent` (world position for top-level entities). */ x: number; y: number; z: number; props: PropertyBag; /** The entity this one is parented to; children move with their parent. */ parent: EntityHandle | null; } /** World position of a handle: its local position plus those of all ancestors. */ export function worldPositionOf(handle: EntityHandle): { x: number; y: number; z: number } { const result = { x: handle.x, y: handle.y, z: handle.z }; const seen = new Set([handle]); for (let p = handle.parent; p && !seen.has(p); p = p.parent) { result.x += p.x; result.y += p.y; result.z += p.z; seen.add(p); } return result; } /** * 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 behavior's parent object (the one the graph acts on), 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; /** * Whether any collider of `a` overlaps any collider of `b` right now. A collider entity * stands for itself; any other entity stands for its direct collider children. */ isTouching(a: EntityHandle, b: EntityHandle): boolean; /** * Moves `target` by (dx, dy) in local units, stopping its solid colliders at other solid * colliders axis by axis (x first, then y) so it slides along walls. Reports which axes hit. */ moveAndSlide(target: EntityHandle, dx: number, dy: number): { blockedX: boolean; blockedY: boolean }; log(message: string): void; destroy(target: EntityHandle): void; stop(): void; } export interface PortDef { key: string; label: string; type: PortType; /** Inline value used while the input is unconnected and the node has no own value (default 0/''/false). */ default?: PropertyValue; } 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: asBoolean(port.default ?? false) }; case 'string': case 'any': return { key: port.key, label: port.label, kind: 'text', default: asString(port.default ?? '') }; default: return { key: port.key, label: port.label, kind: 'number', default: asNumber(port.default ?? 0), step: 1 }; } } /** The value an unconnected input carries: the node's own inline value, else the port default. */ export function inlineValue(node: { params: PropertyBag }, port: PortDef): Value { return node.params[port.key] ?? port.default ?? null; } // --- 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; }