Behaviors with Blueprint-style node graph, graph editor tab and BehaviorSystem
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
d4b1ffa051
commit
922774778b
36 files changed
+2969
-147
No files matched your search
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph';
|
||||
import { hasExecInput, isEventNode, type NodeContext, requireNodeDef, type Value } from './NodeRegistry';
|
||||
|
||||
/**
|
||||
* Guards against runaway flows (a Sequence looping back into itself, …). The interpreter
|
||||
* recurses along exec edges, so the limit also keeps the call stack shallow.
|
||||
*/
|
||||
const MAX_STEPS_PER_FIRE = 1000;
|
||||
|
||||
export interface TraceListener {
|
||||
/** An exec edge was followed. */
|
||||
onEdge(edgeId: string): void;
|
||||
/** A node ran (exec) or was evaluated (data). */
|
||||
onNode(nodeId: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one behavior graph for one instance. Blueprint semantics: an event node starts a
|
||||
* *pulse*; the interpreter follows exec edges, and whenever a node needs a data input it
|
||||
* pulls the value from the connected output – evaluating pure data nodes lazily and
|
||||
* reading exec nodes' outputs from the pulse cache. Per-node state (timers) lives here,
|
||||
* so every instance has its own.
|
||||
*/
|
||||
export class GraphInterpreter {
|
||||
/** Per-node persistent state across pulses (timer accumulators etc.). */
|
||||
readonly nodeState = new Map<string, Record<string, number>>();
|
||||
private outputCache = new Map<string, Record<string, Value>>();
|
||||
private steps = 0;
|
||||
trace: TraceListener | null = null;
|
||||
|
||||
constructor(readonly graph: Graph) {}
|
||||
|
||||
/** Nodes of one event type, in graph order. */
|
||||
eventNodes(type: string): GraphNode[] {
|
||||
return this.graph.nodes.filter((n) => n.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires every event node of `type` whose params satisfy `filter`, seeding its outputs with
|
||||
* `outputs` (e.g. `{ dt }`). Returns how many event nodes fired.
|
||||
*/
|
||||
fire(type: string, ctx: NodeContext, outputs: Record<string, Value> = {}, filter?: (node: GraphNode) => boolean): number {
|
||||
let fired = 0;
|
||||
for (const node of this.eventNodes(type)) {
|
||||
if (filter && !filter(node)) continue;
|
||||
this.outputCache = new Map();
|
||||
this.steps = 0;
|
||||
this.outputCache.set(node.id, { ...outputs });
|
||||
this.trace?.onNode(node.id);
|
||||
this.follow(node, 'exec', ctx);
|
||||
fired++;
|
||||
}
|
||||
return fired;
|
||||
}
|
||||
|
||||
/** Follows the exec edges leaving `port` of `node`, running each target in order. */
|
||||
private follow(node: GraphNode, port: string, ctx: NodeContext): void {
|
||||
for (const edge of edgesOutOf(this.graph, node.id, port)) {
|
||||
const target = this.node(edge.to.node);
|
||||
if (!target) continue;
|
||||
this.trace?.onEdge(edge.id);
|
||||
this.execute(target, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private execute(node: GraphNode, ctx: NodeContext): void {
|
||||
if (++this.steps > MAX_STEPS_PER_FIRE) throw new Error('Behavior graph exceeded the step limit (infinite loop?)');
|
||||
const def = requireNodeDef(node.type);
|
||||
if (!def.run) return;
|
||||
const inputs = this.collectInputs(node, ctx);
|
||||
const result = def.run(ctx, inputs, node.params);
|
||||
this.outputCache.set(node.id, result.outputs ?? {});
|
||||
this.trace?.onNode(node.id);
|
||||
const next = result.next === undefined ? [] : Array.isArray(result.next) ? result.next : [result.next];
|
||||
for (const port of next) this.follow(node, port, ctx);
|
||||
}
|
||||
|
||||
/** Resolves every data input: connected → pulled from the source, else the inline param. */
|
||||
private collectInputs(node: GraphNode, ctx: NodeContext): Record<string, Value> {
|
||||
const def = requireNodeDef(node.type);
|
||||
const inputs: Record<string, Value> = {};
|
||||
for (const port of def.inputs) {
|
||||
if (port.type === 'exec') continue;
|
||||
const edge = edgesInto(this.graph, node.id, port.key)[0];
|
||||
if (edge) inputs[port.key] = this.readOutput(edge.from.node, edge.from.port, ctx);
|
||||
else inputs[port.key] = node.params[port.key] ?? null;
|
||||
}
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/** Value of one output port: from the pulse cache, or by evaluating a pure data node. */
|
||||
private readOutput(nodeId: string, port: string, ctx: NodeContext): Value {
|
||||
const cached = this.outputCache.get(nodeId);
|
||||
if (cached) return cached[port] ?? null;
|
||||
const source = this.node(nodeId);
|
||||
if (!source) return null;
|
||||
const def = requireNodeDef(source.type);
|
||||
// Exec nodes that have not run in this pulse have no value yet.
|
||||
if (isEventNode(def) || hasExecInput(def) || !def.run) return null;
|
||||
if (++this.steps > MAX_STEPS_PER_FIRE) throw new Error('Behavior graph exceeded the step limit (data cycle?)');
|
||||
// Mark before evaluating so a data cycle reads null instead of recursing forever.
|
||||
this.outputCache.set(nodeId, {});
|
||||
const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params).outputs ?? {};
|
||||
this.outputCache.set(nodeId, outputs);
|
||||
this.trace?.onNode(nodeId);
|
||||
return outputs[port] ?? null;
|
||||
}
|
||||
|
||||
private node(id: string): GraphNode | undefined {
|
||||
return this.graph.nodes.find((n) => n.id === id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import type { PropertyDef } from '../properties/PropertySchema';
|
||||
import type { PortType } from './Graph';
|
||||
|
||||
/** A value travelling along a data edge. Objects are live entity handles (play mode only). */
|
||||
export type Value = PropertyValue | EntityHandle | null;
|
||||
|
||||
/**
|
||||
* What the graph sees of a running entity. The runtime's Entity satisfies this shape; unit
|
||||
* tests use plain objects. Mutating `x`/`y`/`props` is how actions take effect.
|
||||
*/
|
||||
export interface EntityHandle {
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
props: PropertyBag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Services a graph may use while running. Implemented by the runtime (BehaviorSystem) and
|
||||
* by test doubles. Everything the graph does to the world goes through here, so the core
|
||||
* stays free of runtime imports.
|
||||
*/
|
||||
export interface NodeContext {
|
||||
/** The behavior entity itself. */
|
||||
self: EntityHandle;
|
||||
/** The scene object the behavior was attached to, if any. */
|
||||
owner: EntityHandle | null;
|
||||
/** Mutable variable state of this behavior instance. */
|
||||
variables: PropertyBag;
|
||||
/** Seconds since the world started and since the last tick. */
|
||||
time: number;
|
||||
dt: number;
|
||||
findByName(name: string): EntityHandle | null;
|
||||
isKeyDown(key: string): boolean;
|
||||
log(message: string): void;
|
||||
destroy(target: EntityHandle): void;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export interface PortDef {
|
||||
key: string;
|
||||
label: string;
|
||||
type: PortType;
|
||||
}
|
||||
|
||||
export type NodeCategory = 'event' | 'flow' | 'data' | 'variable' | 'action';
|
||||
|
||||
export interface RunResult {
|
||||
/** Values for the node's data outputs (cached for the rest of the pulse). */
|
||||
outputs?: Record<string, Value>;
|
||||
/** Exec output(s) to continue along; omitted = the node ends the flow. */
|
||||
next?: string | string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes one node type: its ports, its inline settings and how it runs. The editor
|
||||
* palette, port rendering, connection validation and the inspector fields are all derived
|
||||
* from this – adding a node type means adding one definition, no UI code.
|
||||
*/
|
||||
export interface NodeDef {
|
||||
type: string;
|
||||
label: string;
|
||||
category: NodeCategory;
|
||||
/** Shown in the palette. */
|
||||
description: string;
|
||||
inputs: PortDef[];
|
||||
outputs: PortDef[];
|
||||
/** Node settings that are not ports (key name, operator, variable). */
|
||||
params: PropertyDef[];
|
||||
/**
|
||||
* Executes the node. Pure data nodes (no exec pins) are evaluated on demand whenever an
|
||||
* input downstream needs their value; exec nodes run when the flow reaches them.
|
||||
* Event nodes have no `run`; the interpreter seeds their outputs when it fires them.
|
||||
*/
|
||||
run?(ctx: NodeContext, inputs: Record<string, Value>, params: PropertyBag): RunResult;
|
||||
}
|
||||
|
||||
const defs = new Map<string, NodeDef>();
|
||||
|
||||
export function registerNode(def: NodeDef): NodeDef {
|
||||
if (defs.has(def.type)) throw new Error(`Node type "${def.type}" registered twice`);
|
||||
defs.set(def.type, def);
|
||||
return def;
|
||||
}
|
||||
|
||||
export function nodeDef(type: string): NodeDef | undefined {
|
||||
return defs.get(type);
|
||||
}
|
||||
|
||||
export function requireNodeDef(type: string): NodeDef {
|
||||
const def = defs.get(type);
|
||||
if (!def) throw new Error(`Unknown node type "${type}"`);
|
||||
return def;
|
||||
}
|
||||
|
||||
export function allNodeDefs(): NodeDef[] {
|
||||
return [...defs.values()];
|
||||
}
|
||||
|
||||
export function isEventNode(def: NodeDef): boolean {
|
||||
return def.category === 'event';
|
||||
}
|
||||
|
||||
export function hasExecInput(def: NodeDef): boolean {
|
||||
return def.inputs.some((p) => p.type === 'exec');
|
||||
}
|
||||
|
||||
/** Data inputs that may carry an inline value when unconnected (numbers, strings, booleans, any). */
|
||||
export function inlineInputs(def: NodeDef): PortDef[] {
|
||||
return def.inputs.filter((p) => p.type !== 'exec' && p.type !== 'object');
|
||||
}
|
||||
|
||||
/** A PropertyDef for editing an inline input value of the given port. */
|
||||
export function inlineInputDef(port: PortDef): PropertyDef {
|
||||
switch (port.type) {
|
||||
case 'boolean':
|
||||
return { key: port.key, label: port.label, kind: 'boolean', default: false };
|
||||
case 'string':
|
||||
case 'any':
|
||||
return { key: port.key, label: port.label, kind: 'text', default: '' };
|
||||
default:
|
||||
return { key: port.key, label: port.label, kind: 'number', default: 0, step: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// --- value helpers for node implementations ---------------------------------------------
|
||||
|
||||
export function asNumber(value: Value | undefined): number {
|
||||
if (typeof value === 'number') return value;
|
||||
if (typeof value === 'boolean') return value ? 1 : 0;
|
||||
if (typeof value === 'string') {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function asBoolean(value: Value | undefined): boolean {
|
||||
if (typeof value === 'string') return value !== '' && value !== 'false' && value !== '0';
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
export function asString(value: Value | undefined): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'object') return value.name;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function asEntity(value: Value | undefined): EntityHandle | null {
|
||||
return value && typeof value === 'object' ? value : null;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,256 @@
|
||||
import type { PropertyDef } from '../properties/PropertySchema';
|
||||
import { asBoolean, asEntity, asNumber, asString, type PortDef, registerNode } from './NodeRegistry';
|
||||
|
||||
/**
|
||||
* The built-in node set. Each entry is data + a `run` function; everything else (palette,
|
||||
* ports, inspector fields, validation) is derived. Importing this module registers them.
|
||||
*/
|
||||
|
||||
const exec = (key = 'exec', label = ''): PortDef => ({ key, label, type: 'exec' });
|
||||
const num = (key: string, label = key): PortDef => ({ key, label, type: 'number' });
|
||||
const bool = (key: string, label = key): PortDef => ({ key, label, type: 'boolean' });
|
||||
const obj = (key: string, label = key): PortDef => ({ key, label, type: 'object' });
|
||||
const any = (key: string, label = key): PortDef => ({ key, label, type: 'any' });
|
||||
|
||||
const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' };
|
||||
/** Placeholder: the graph inspector replaces the options with the asset's variables. */
|
||||
const variableParam: PropertyDef = { key: 'variable', label: 'Variable', kind: 'enum', default: '', options: [] };
|
||||
|
||||
// --- events -------------------------------------------------------------------------------
|
||||
|
||||
registerNode({ type: 'OnStart', label: 'On start', category: 'event', description: 'Runs once when the game starts.', inputs: [], outputs: [exec()], params: [] });
|
||||
registerNode({ type: 'OnUpdate', label: 'On update', category: 'event', description: 'Runs every frame.', inputs: [], outputs: [exec(), num('dt', 'dt')], params: [] });
|
||||
registerNode({ type: 'OnKeyDown', label: 'On key down', category: 'event', description: 'Runs once when the key is pressed.', inputs: [], outputs: [exec()], params: [keyParam] });
|
||||
registerNode({ type: 'OnKeyUp', label: 'On key up', category: 'event', description: 'Runs once when the key is released.', inputs: [], outputs: [exec()], params: [keyParam] });
|
||||
registerNode({ type: 'OnKeyHeld', label: 'On key held', category: 'event', description: 'Runs every frame while the key is held.', inputs: [], outputs: [exec(), num('dt', 'dt')], params: [keyParam] });
|
||||
registerNode({
|
||||
type: 'OnTimer',
|
||||
label: 'On timer',
|
||||
category: 'event',
|
||||
description: 'Runs every N seconds.',
|
||||
inputs: [],
|
||||
outputs: [exec()],
|
||||
params: [{ key: 'interval', label: 'Interval (s)', kind: 'number', default: 1, min: 0.01, step: 0.1 }],
|
||||
});
|
||||
|
||||
// --- flow ---------------------------------------------------------------------------------
|
||||
|
||||
registerNode({
|
||||
type: 'Branch',
|
||||
label: 'Branch',
|
||||
category: 'flow',
|
||||
description: 'Continues along "then" or "else" depending on the condition.',
|
||||
inputs: [exec(), bool('condition')],
|
||||
outputs: [exec('then', 'then'), exec('else', 'else')],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => ({ next: asBoolean(inputs.condition) ? 'then' : 'else' }),
|
||||
});
|
||||
registerNode({
|
||||
type: 'Sequence',
|
||||
label: 'Sequence',
|
||||
category: 'flow',
|
||||
description: 'Runs its outputs one after another.',
|
||||
inputs: [exec()],
|
||||
outputs: [exec('a', '1'), exec('b', '2'), exec('c', '3')],
|
||||
params: [],
|
||||
run: () => ({ next: ['a', 'b', 'c'] }),
|
||||
});
|
||||
|
||||
// --- data: references --------------------------------------------------------------------
|
||||
|
||||
registerNode({ type: 'Self', label: 'Self', category: 'data', description: 'This behavior object.', inputs: [], outputs: [obj('object')], params: [], run: (ctx) => ({ outputs: { object: ctx.self } }) });
|
||||
registerNode({ type: 'Owner', label: 'Owner', category: 'data', description: 'The object this behavior is attached to.', inputs: [], outputs: [obj('object')], params: [], run: (ctx) => ({ outputs: { object: ctx.owner } }) });
|
||||
registerNode({
|
||||
type: 'ObjectByName',
|
||||
label: 'Object by name',
|
||||
category: 'data',
|
||||
description: 'Finds a scene object by its name.',
|
||||
inputs: [],
|
||||
outputs: [obj('object')],
|
||||
params: [{ key: 'name', label: 'Name', kind: 'text', default: '' }],
|
||||
run: (ctx, _inputs, params) => ({ outputs: { object: ctx.findByName(String(params.name ?? '')) } }),
|
||||
});
|
||||
|
||||
// --- variables ----------------------------------------------------------------------------
|
||||
|
||||
registerNode({
|
||||
type: 'GetVariable',
|
||||
label: 'Get variable',
|
||||
category: 'variable',
|
||||
description: 'Reads one of this behavior\'s variables.',
|
||||
inputs: [],
|
||||
outputs: [any('value')],
|
||||
params: [variableParam],
|
||||
run: (ctx, _inputs, params) => ({ outputs: { value: ctx.variables[String(params.variable ?? '')] ?? null } }),
|
||||
});
|
||||
registerNode({
|
||||
type: 'SetVariable',
|
||||
label: 'Set variable',
|
||||
category: 'variable',
|
||||
description: 'Writes one of this behavior\'s variables.',
|
||||
inputs: [exec(), any('value')],
|
||||
outputs: [exec()],
|
||||
params: [variableParam],
|
||||
run: (ctx, inputs, params) => {
|
||||
const key = String(params.variable ?? '');
|
||||
const current = ctx.variables[key];
|
||||
const value = inputs.value;
|
||||
if (key) {
|
||||
if (typeof current === 'number') ctx.variables[key] = asNumber(value);
|
||||
else if (typeof current === 'boolean') ctx.variables[key] = asBoolean(value);
|
||||
else ctx.variables[key] = asString(value);
|
||||
}
|
||||
return { next: 'exec' };
|
||||
},
|
||||
});
|
||||
|
||||
// --- data: properties and positions --------------------------------------------------------
|
||||
|
||||
registerNode({
|
||||
type: 'GetProperty',
|
||||
label: 'Get property',
|
||||
category: 'data',
|
||||
description: 'Reads a property (width, color, …) of an object.',
|
||||
inputs: [obj('object')],
|
||||
outputs: [any('value')],
|
||||
params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }],
|
||||
run: (_ctx, inputs, params) => ({ outputs: { value: asEntity(inputs.object)?.props[String(params.property ?? '')] ?? null } }),
|
||||
});
|
||||
registerNode({
|
||||
type: 'SetProperty',
|
||||
label: 'Set property',
|
||||
category: 'action',
|
||||
description: 'Writes a property (width, color, …) of an object.',
|
||||
inputs: [exec(), obj('object'), any('value')],
|
||||
outputs: [exec()],
|
||||
params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }],
|
||||
run: (_ctx, inputs, params) => {
|
||||
const target = asEntity(inputs.object);
|
||||
const key = String(params.property ?? '');
|
||||
if (target && key) {
|
||||
const current = target.props[key];
|
||||
const value = inputs.value;
|
||||
if (typeof current === 'number') target.props[key] = asNumber(value);
|
||||
else if (typeof current === 'boolean') target.props[key] = asBoolean(value);
|
||||
else target.props[key] = asString(value);
|
||||
}
|
||||
return { next: 'exec' };
|
||||
},
|
||||
});
|
||||
registerNode({
|
||||
type: 'GetPosition',
|
||||
label: 'Get position',
|
||||
category: 'data',
|
||||
description: 'The x/y position of an object.',
|
||||
inputs: [obj('object')],
|
||||
outputs: [num('x'), num('y')],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => {
|
||||
const target = asEntity(inputs.object);
|
||||
return { outputs: { x: target?.x ?? 0, y: target?.y ?? 0 } };
|
||||
},
|
||||
});
|
||||
|
||||
// --- data: literals and math ---------------------------------------------------------------
|
||||
|
||||
registerNode({ type: 'Number', label: 'Number', category: 'data', description: 'A constant number.', inputs: [], outputs: [num('value')], params: [{ key: 'value', label: 'Value', kind: 'number', default: 0, step: 1 }], run: (_c, _i, p) => ({ outputs: { value: asNumber(p.value) } }) });
|
||||
registerNode({ type: 'Boolean', label: 'Boolean', category: 'data', description: 'A constant true/false.', inputs: [], outputs: [bool('value')], params: [{ key: 'value', label: 'Value', kind: 'boolean', default: false }], run: (_c, _i, p) => ({ outputs: { value: asBoolean(p.value) } }) });
|
||||
registerNode({ type: 'String', label: 'String', category: 'data', description: 'A constant text.', inputs: [], outputs: [{ key: 'value', label: 'value', type: 'string' }], params: [{ key: 'value', label: 'Value', kind: 'text', default: '' }], run: (_c, _i, p) => ({ outputs: { value: asString(p.value) } }) });
|
||||
|
||||
const MATH: [string, string, string, (a: number, b: number) => number][] = [
|
||||
['Add', 'Add', 'a + b', (a, b) => a + b],
|
||||
['Subtract', 'Subtract', 'a − b', (a, b) => a - b],
|
||||
['Multiply', 'Multiply', 'a × b', (a, b) => a * b],
|
||||
['Divide', 'Divide', 'a ÷ b (0 when b is 0)', (a, b) => (b === 0 ? 0 : a / b)],
|
||||
];
|
||||
for (const [type, label, description, fn] of MATH) {
|
||||
registerNode({ type, label, category: 'data', description, inputs: [num('a'), num('b')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: fn(asNumber(i.a), asNumber(i.b)) } }) });
|
||||
}
|
||||
|
||||
const COMPARE: Record<string, (a: number, b: number) => boolean> = {
|
||||
'==': (a, b) => a === b,
|
||||
'!=': (a, b) => a !== b,
|
||||
'<': (a, b) => a < b,
|
||||
'<=': (a, b) => a <= b,
|
||||
'>': (a, b) => a > b,
|
||||
'>=': (a, b) => a >= b,
|
||||
};
|
||||
registerNode({
|
||||
type: 'Compare',
|
||||
label: 'Compare',
|
||||
category: 'data',
|
||||
description: 'Compares two numbers.',
|
||||
inputs: [num('a'), num('b')],
|
||||
outputs: [bool('result')],
|
||||
params: [{ key: 'op', label: 'Operator', kind: 'enum', default: '<', options: Object.keys(COMPARE).map((op) => ({ value: op, label: op })) }],
|
||||
run: (_c, i, p) => ({ outputs: { result: (COMPARE[String(p.op)] ?? COMPARE['=='])!(asNumber(i.a), asNumber(i.b)) } }),
|
||||
});
|
||||
registerNode({ type: 'And', label: 'And', category: 'data', description: 'Both true.', inputs: [bool('a'), bool('b')], outputs: [bool('result')], params: [], run: (_c, i) => ({ outputs: { result: asBoolean(i.a) && asBoolean(i.b) } }) });
|
||||
registerNode({ type: 'Or', label: 'Or', category: 'data', description: 'Either true.', inputs: [bool('a'), bool('b')], outputs: [bool('result')], params: [], run: (_c, i) => ({ outputs: { result: asBoolean(i.a) || asBoolean(i.b) } }) });
|
||||
registerNode({ type: 'Not', label: 'Not', category: 'data', description: 'Inverts a boolean.', inputs: [bool('value')], outputs: [bool('result')], params: [], run: (_c, i) => ({ outputs: { result: !asBoolean(i.value) } }) });
|
||||
registerNode({ type: 'KeyIsDown', label: 'Key is down', category: 'data', description: 'Whether a key is currently held.', inputs: [], outputs: [bool('down')], params: [keyParam], run: (ctx, _i, p) => ({ outputs: { down: ctx.isKeyDown(String(p.key ?? '')) } }) });
|
||||
|
||||
// --- actions --------------------------------------------------------------------------------
|
||||
|
||||
registerNode({
|
||||
type: 'Move',
|
||||
label: 'Move',
|
||||
category: 'action',
|
||||
description: 'Moves an object by dx/dy.',
|
||||
inputs: [exec(), obj('object'), num('dx'), num('dy')],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (_c, i) => {
|
||||
const target = asEntity(i.object);
|
||||
if (target) {
|
||||
target.x += asNumber(i.dx);
|
||||
target.y += asNumber(i.dy);
|
||||
}
|
||||
return { next: 'exec' };
|
||||
},
|
||||
});
|
||||
registerNode({
|
||||
type: 'SetPosition',
|
||||
label: 'Set position',
|
||||
category: 'action',
|
||||
description: 'Places an object at x/y.',
|
||||
inputs: [exec(), obj('object'), num('x'), num('y')],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (_c, i) => {
|
||||
const target = asEntity(i.object);
|
||||
if (target) {
|
||||
target.x = asNumber(i.x);
|
||||
target.y = asNumber(i.y);
|
||||
}
|
||||
return { next: 'exec' };
|
||||
},
|
||||
});
|
||||
registerNode({
|
||||
type: 'Destroy',
|
||||
label: 'Destroy',
|
||||
category: 'action',
|
||||
description: 'Removes an object from the running game.',
|
||||
inputs: [exec(), obj('object')],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, i) => {
|
||||
const target = asEntity(i.object);
|
||||
if (target) ctx.destroy(target);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
});
|
||||
registerNode({
|
||||
type: 'Log',
|
||||
label: 'Log',
|
||||
category: 'action',
|
||||
description: 'Prints a message to the console.',
|
||||
inputs: [exec(), any('message')],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, i) => {
|
||||
ctx.log(asString(i.message));
|
||||
return { next: 'exec' };
|
||||
},
|
||||
});
|
||||
registerNode({ type: 'StopGame', label: 'Stop game', category: 'action', description: 'Ends play mode.', inputs: [exec()], outputs: [], params: [], run: (ctx) => { ctx.stop(); return {}; } });
|
||||
Reference in new issue
Block a user