game-maker / suite (push) Successful in 10m58s
Four issues that all reach for the same line: how the current state is read and written. Splitting them into four commits would mean four states of the tree that do not compile, so they arrive together. #55 start state — the machine asset carries an optional `start` holding a State node's id. Right-click a state to set or remove it, a ring in the node's title says which one it is. A dead id (the state was deleted) reads as "no start", so nothing has to clean up after a deletion. #53 entered/exited — `On state entered` and `On state exited`, one exec output per state. Every change now goes through a single `setState` that reports it; BehaviorSystem delivers it after the exec flow that caused it, `exited` of the old before `entered` of the new, round after round until the queue is empty, with a cap that stops a ping-pong graph with an endless-loop error. A local machine tells the instance that switched, a global one tells every instance holding a matching event node. The start state fires `entered` too: per instance for a local machine, once per run for a global one. #57 reading — `Current state` with `name` for showing and `id` for comparing, `Is in state` with one boolean per state. Neither has a `run`, so `read` learned `def` and `lookup`, and the catalog test knows this third kind of node. #54 seeing it — during a run the state machine tab marks every state some instance is holding faintly, and the one held by the selected scene object's instance strongly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
192 lines
7.5 KiB
TypeScript
192 lines
7.5 KiB
TypeScript
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||
import { isKindWith, plainKindOf } from '../properties/kinds';
|
||
import { flatDef, type PropertyDef } from '../properties/PropertySchema';
|
||
import type { DeclaredPort, Graph, GraphNode, PortType } from './Graph';
|
||
import type { GraphLookup } from './lookup';
|
||
import type { NodeContext } from './NodeContext';
|
||
import { asList, type Value } from './values';
|
||
|
||
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;
|
||
/** The property this port carries; its typed-in value is edited as that property (color, enum, …). */
|
||
field?: PropertyDef;
|
||
/** `false`: nothing to type in even for a number – unconnected, the node's own rule applies. */
|
||
inline?: boolean;
|
||
/** Not a port of the definition: a key only an edge still names, drawn so that edge can be seen. */
|
||
ghost?: true;
|
||
}
|
||
|
||
/**
|
||
* `state`, `shader` and `particles` are the categories no behavior graph offers: their nodes are the
|
||
* states of a state machine, the GLSL of a shader asset and the motion of one particle.
|
||
*/
|
||
export type NodeCategory =
|
||
'event' | 'flow' | 'data' | 'variable' | 'action' | 'state' | 'shader' | 'particles';
|
||
|
||
/**
|
||
* How a loop node asks for one of its exec outputs to be run repeatedly: `port` carries the body,
|
||
* `index` is the number output seeded with the round number.
|
||
*/
|
||
export interface RepeatResult {
|
||
port: string;
|
||
index: string;
|
||
times: number;
|
||
}
|
||
|
||
/** How a node asks for another behavior's graph to be run in place, `inputs` seeding its start node. */
|
||
export interface CallResult {
|
||
behaviorId: string;
|
||
inputs: Record<string, Value>;
|
||
}
|
||
|
||
export interface RunResult {
|
||
/** 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[];
|
||
/** Runs `port` that many times before continuing along `next`. */
|
||
repeat?: RepeatResult;
|
||
/** Runs another behavior before continuing along `next`; its results join `outputs`. */
|
||
call?: CallResult;
|
||
}
|
||
|
||
/** What a node may compute its ports from: itself, the graph it sits in, and the project around it. */
|
||
export interface PortSource {
|
||
node: GraphNode;
|
||
graph: Graph;
|
||
lookup: GraphLookup;
|
||
}
|
||
|
||
/** Ports a single node has on top of the fixed ones of its type, and a label naming what it does. */
|
||
export interface DerivedShape {
|
||
label?: string;
|
||
inputs?: PortDef[];
|
||
outputs?: PortDef[];
|
||
}
|
||
|
||
/** Palette, port rendering, connection validation and inspector fields are all derived from this. */
|
||
export interface NodeDef {
|
||
type: string;
|
||
label: string;
|
||
category: NodeCategory;
|
||
description: string;
|
||
inputs: PortDef[];
|
||
outputs: PortDef[];
|
||
/** Node settings that are not ports (key name, operator, variable). */
|
||
params: PropertyDef[];
|
||
/**
|
||
* Settings that say nothing at this value set, so the node body leaves them off; the inspector
|
||
* still offers them. Without it a switched-off repeat is two rows of "0" on every key node.
|
||
*/
|
||
hiddenParams?(params: PropertyBag): string[];
|
||
/**
|
||
* Which side the ports this node declares for itself (`GraphNode.ports`) appear on. Setting it
|
||
* is what gives a node the port editor in the inspector. `both`: every port is an output, and an
|
||
* input as well where it is marked `write`.
|
||
*/
|
||
declaresPorts?: 'inputs' | 'outputs' | 'both';
|
||
/** How a declared port becomes a port; without it, named after its key and typed by its kind. */
|
||
declaredPort?(port: DeclaredPort, node: GraphNode): PortDef;
|
||
/**
|
||
* Edges between two such nodes are transitions: drawn as a straight arrow instead of the usual
|
||
* curve, and hung on ports that are never shown. A transition graph is read as directed steps
|
||
* between whole states, not as signal flowing left to right into a particular socket.
|
||
*/
|
||
transitionEdges?: true;
|
||
/**
|
||
* Ports this node computes beyond the fixed and the declared ones – how many outputs a Sequence
|
||
* offers, which values the behavior a call node names takes. `resolveDef` applies it.
|
||
*/
|
||
derivePorts?(source: PortSource): DerivedShape;
|
||
/**
|
||
* Outputs readable at any moment, even while the node's exec input has not fired in this pulse.
|
||
* Never cached, so a read after a write sees the new value. `input` pulls one input on demand:
|
||
* pulling all of them would loop through an edge that feeds the node's own output back in.
|
||
* `def` and `lookup` are as in `run`: the definition resolved for this very node, and the project
|
||
* around it – what a reader with one output per state needs to answer for all of them.
|
||
*/
|
||
read?(
|
||
ctx: NodeContext,
|
||
params: PropertyBag,
|
||
input: (key: string) => Value,
|
||
def: NodeDef,
|
||
lookup: GraphLookup,
|
||
): Record<string, Value>;
|
||
/**
|
||
* Event nodes have no `run` – the interpreter seeds their outputs. `def` is resolved for the node at
|
||
* hand, the only way a node with derived ports knows how many it has. `entry` is the exec input
|
||
* the pulse arrived through – what a node with one exec input per meaning reads. `lookup` is the
|
||
* project around the graph, for a node whose step is written in another asset.
|
||
*/
|
||
run?(
|
||
ctx: NodeContext,
|
||
inputs: Record<string, Value>,
|
||
params: PropertyBag,
|
||
def: NodeDef,
|
||
entry: string,
|
||
lookup: GraphLookup,
|
||
): 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 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 while unconnected. An object is a live handle and a
|
||
* struct is assembled from its parts, so neither has a value that could be typed on one line; a
|
||
* shader colour has one only where the node gives it a colour field.
|
||
*/
|
||
export function inlineInputs(def: NodeDef): PortDef[] {
|
||
return def.inputs.filter(isInlinePort);
|
||
}
|
||
|
||
function isInlinePort(port: PortDef): boolean {
|
||
if (port.inline === false) return false;
|
||
if (port.type === 'vec3') return port.field !== undefined;
|
||
return port.type !== 'exec' && port.type !== 'object' && port.type !== 'struct';
|
||
}
|
||
|
||
/** Untyped, an input writes the zero of its kind; as its default the node body shows it, not just the name. */
|
||
export function withInlineDefault(port: PortDef): PortDef {
|
||
return isInlinePort(port) ? { ...port, default: inlineInputDef(port).default } : port;
|
||
}
|
||
|
||
export function inlineInputDef(port: PortDef): PropertyDef {
|
||
if (port.field) return { ...port.field, key: port.key, label: port.label };
|
||
const kind = port.type === 'any' ? 'text' : plainKindOf(port.type);
|
||
if (kind === 'list') return { key: port.key, label: port.label, kind, default: asList(port.default) };
|
||
const flat = kind !== undefined && isKindWith('field', kind) ? kind : 'number';
|
||
const def = flatDef(port.key, flat, { label: port.label, value: port.default });
|
||
return def.kind === 'number' ? { ...def, step: 1 } : def;
|
||
}
|
||
|
||
/** 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;
|
||
}
|