Give the graph a loop, and build Tetris with it
A Tetris example project needs to draw 200 board cells and scan rows for a full line. Neither is expressible without iteration, so the node system gets a Repeat node plus the missing text side: Substring, Find text and To number. The interpreter drops its data cache once per round -- otherwise every round would recompute with the first round's values. Object by name takes its name as an input now, not just as a setting; addressing "c" + index is impossible otherwise. An unconnected input reads the stored setting, so saved projects are untouched. The board itself is a 200-character text, "." for empty. That makes both operations of Tetris a single expression instead of a loop, and each frame attempts exactly one move -- rotate beats sideways beats fall -- so the fit check exists once in the whole graph. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9c4148dc1b
commit
4f78c7518c
16 files changed
+1479
-25
No files matched your search
@@ -1,13 +1,17 @@
|
||||
import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph';
|
||||
import type { NodeContext } from './NodeContext';
|
||||
import { hasExecInput, inlineValue, isEventNode, requireNodeDef } from './NodeRegistry';
|
||||
import { hasExecInput, inlineValue, isEventNode, type RepeatResult, requireNodeDef } from './NodeRegistry';
|
||||
import type { Value } from './values';
|
||||
|
||||
/** How much work one pulse may do. A Repeat over a 10×20 board is 200 rounds, so this is generous. */
|
||||
const MAX_STEPS_PER_FIRE = 100_000;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* How far exec edges may nest. The interpreter recurses along them, so this is what keeps a flow
|
||||
* feeding back into itself off the call stack – a job the step budget can no longer do now that a
|
||||
* legitimate pulse may run tens of thousands of steps.
|
||||
*/
|
||||
const MAX_STEPS_PER_FIRE = 1000;
|
||||
const MAX_DEPTH = 1000;
|
||||
|
||||
export interface TraceListener {
|
||||
onEdge(edgeId: string): void;
|
||||
@@ -23,6 +27,8 @@ export class GraphInterpreter {
|
||||
/** Per-node state that persists across pulses (timer accumulators etc.). */
|
||||
readonly nodeState = new Map<string, Record<string, number>>();
|
||||
private outputCache = new Map<string, Record<string, Value>>();
|
||||
/** Kept apart from `outputCache` because a loop round has to forget exactly these again. */
|
||||
private dataCache = new Map<string, Record<string, Value>>();
|
||||
private steps = 0;
|
||||
trace: TraceListener | null = null;
|
||||
|
||||
@@ -43,35 +49,56 @@ export class GraphInterpreter {
|
||||
for (const node of this.eventNodes(type)) {
|
||||
if (filter && !filter(node)) continue;
|
||||
this.outputCache = new Map();
|
||||
this.dataCache = new Map();
|
||||
this.steps = 0;
|
||||
this.outputCache.set(node.id, { ...outputs });
|
||||
this.trace?.onNode(node.id);
|
||||
this.follow(node, 'exec', ctx);
|
||||
this.follow(node, 'exec', ctx, 0);
|
||||
fired++;
|
||||
}
|
||||
return fired;
|
||||
}
|
||||
|
||||
private follow(node: GraphNode, port: string, ctx: NodeContext): void {
|
||||
private follow(node: GraphNode, port: string, ctx: NodeContext, depth: number): 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);
|
||||
this.execute(target, ctx, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private execute(node: GraphNode, ctx: NodeContext): void {
|
||||
private execute(node: GraphNode, ctx: NodeContext, depth: number): void {
|
||||
if (++this.steps > MAX_STEPS_PER_FIRE)
|
||||
throw new Error('Behavior graph exceeded the step limit (infinite loop?)');
|
||||
throw new Error('Behavior graph exceeded the step limit (endless loop?)');
|
||||
if (depth > MAX_DEPTH)
|
||||
throw new Error('Behavior graph nested too deeply (a flow feeding back on itself?)');
|
||||
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);
|
||||
if (result.repeat) this.loop(node, result.repeat, ctx, depth);
|
||||
const next = result.next === undefined ? [] : Array.isArray(result.next) ? result.next : [result.next];
|
||||
for (const port of next) this.follow(node, port, ctx);
|
||||
for (const port of next) this.follow(node, port, ctx, depth);
|
||||
}
|
||||
|
||||
/**
|
||||
* One round per count, the loop node's `index` output carrying the round number. Data nodes are
|
||||
* cached for the whole pulse, so the round has to drop that cache first – otherwise every round
|
||||
* would read the values the first one computed.
|
||||
*/
|
||||
private loop(node: GraphNode, repeat: RepeatResult, ctx: NodeContext, depth: number): void {
|
||||
const outputs = this.outputCache.get(node.id) ?? {};
|
||||
for (let round = 0; round < repeat.times; round++) {
|
||||
if (++this.steps > MAX_STEPS_PER_FIRE)
|
||||
throw new Error('Behavior graph exceeded the step limit (endless loop?)');
|
||||
this.dataCache.clear();
|
||||
this.outputCache.set(node.id, { ...outputs, [repeat.index]: round });
|
||||
this.follow(node, repeat.port, ctx, depth);
|
||||
}
|
||||
this.dataCache.clear();
|
||||
}
|
||||
|
||||
/** Resolves every data input: connected → pulled from the source, else the inline param. */
|
||||
@@ -89,7 +116,7 @@ export class GraphInterpreter {
|
||||
|
||||
/** 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);
|
||||
const cached = this.outputCache.get(nodeId) ?? this.dataCache.get(nodeId);
|
||||
if (cached) return cached[port] ?? null;
|
||||
const source = this.node(nodeId);
|
||||
if (!source) return null;
|
||||
@@ -99,9 +126,9 @@ export class GraphInterpreter {
|
||||
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, {});
|
||||
this.dataCache.set(nodeId, {});
|
||||
const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params).outputs ?? {};
|
||||
this.outputCache.set(nodeId, outputs);
|
||||
this.dataCache.set(nodeId, outputs);
|
||||
this.trace?.onNode(nodeId);
|
||||
return outputs[port] ?? null;
|
||||
}
|
||||
|
||||
@@ -14,11 +14,23 @@ export interface PortDef {
|
||||
|
||||
export type NodeCategory = 'event' | 'flow' | 'data' | 'variable' | 'action';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Palette, port rendering, connection validation and inspector fields are all derived from this. */
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asString } from '../../values';
|
||||
import { num, str } from '../shared';
|
||||
|
||||
export const FindText: NodeDef = {
|
||||
type: 'FindText',
|
||||
label: 'Find text',
|
||||
category: 'data',
|
||||
description: 'Where "search" first appears in "text", counting from 0, and −1 when it does not appear.',
|
||||
inputs: [str('text'), str('search')],
|
||||
outputs: [num('index')],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => ({
|
||||
outputs: { index: asString(inputs.text).indexOf(asString(inputs.search)) },
|
||||
}),
|
||||
};
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { obj } from '../shared';
|
||||
import { asString } from '../../values';
|
||||
import { obj, str } from '../shared';
|
||||
|
||||
export const ObjectByName: NodeDef = {
|
||||
type: 'ObjectByName',
|
||||
label: 'Object by name',
|
||||
category: 'data',
|
||||
description: 'Finds a scene object by its name.',
|
||||
inputs: [],
|
||||
description:
|
||||
'Finds a scene object by its name. The name is an input, so it can be put together at runtime ' +
|
||||
'("cell" + index).',
|
||||
inputs: [str('name')],
|
||||
outputs: [obj('object')],
|
||||
params: [{ key: 'name', label: 'Name', kind: 'text', default: '' }],
|
||||
run: (ctx, _inputs, params) => ({ outputs: { object: ctx.findByName(String(params.name ?? '')) } }),
|
||||
params: [],
|
||||
run: (ctx, inputs) => ({ outputs: { object: ctx.findByName(asString(inputs.name)) } }),
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asNumber, asString } from '../../values';
|
||||
import { num, str, withDefault } from '../shared';
|
||||
|
||||
export const Substring: NodeDef = {
|
||||
type: 'Substring',
|
||||
label: 'Substring',
|
||||
category: 'data',
|
||||
description:
|
||||
'A part of a text: "count" characters starting at "start" (the first is 0). ' +
|
||||
'A negative count reaches to the end.',
|
||||
inputs: [str('text'), num('start'), withDefault(num('count'), -1)],
|
||||
outputs: [str('text')],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => {
|
||||
const text = asString(inputs.text);
|
||||
const start = Math.max(0, Math.floor(asNumber(inputs.start)));
|
||||
const count = Math.floor(asNumber(inputs.count));
|
||||
return { outputs: { text: count < 0 ? text.slice(start) : text.slice(start, start + count) } };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asNumber } from '../../values';
|
||||
import { any, num } from '../shared';
|
||||
|
||||
export const ToNumber: NodeDef = {
|
||||
type: 'ToNumber',
|
||||
label: 'To number',
|
||||
category: 'data',
|
||||
description: 'Reads a text as a number. Anything that is not one – an empty text, a word – reads as 0.',
|
||||
inputs: [any('value')],
|
||||
outputs: [num('result')],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => ({ outputs: { result: asNumber(inputs.value) } }),
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asNumber } from '../../values';
|
||||
import { exec, num, withDefault } from '../shared';
|
||||
|
||||
export const Repeat: NodeDef = {
|
||||
type: 'Repeat',
|
||||
label: 'Repeat',
|
||||
category: 'flow',
|
||||
description:
|
||||
'Runs "body" a number of times, counting "index" up from 0, and continues at "done" afterwards. ' +
|
||||
'Every round reads its data inputs anew, so an expression built on "index" changes with it.',
|
||||
inputs: [exec(), withDefault(num('times'), 1)],
|
||||
outputs: [exec('body', 'body'), num('index'), exec('done', 'done')],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => ({
|
||||
repeat: { port: 'body', index: 'index', times: Math.max(0, Math.floor(asNumber(inputs.times))) },
|
||||
next: 'done',
|
||||
}),
|
||||
};
|
||||
@@ -20,6 +20,7 @@ import { Compare } from './data/Compare';
|
||||
import { Distance } from './data/Distance';
|
||||
import { Divide } from './data/Divide';
|
||||
import { Equals } from './data/Equals';
|
||||
import { FindText } from './data/FindText';
|
||||
import { Floor } from './data/Floor';
|
||||
import { GetPosition } from './data/GetPosition';
|
||||
import { GetProperty } from './data/GetProperty';
|
||||
@@ -49,8 +50,10 @@ import { Round } from './data/Round';
|
||||
import { Self } from './data/Self';
|
||||
import { Sqrt } from './data/Sqrt';
|
||||
import { StringLiteral } from './data/String';
|
||||
import { Substring } from './data/Substring';
|
||||
import { Subtract } from './data/Subtract';
|
||||
import { Time } from './data/Time';
|
||||
import { ToNumber } from './data/ToNumber';
|
||||
import { ToText } from './data/ToText';
|
||||
import { OnClick } from './events/OnClick';
|
||||
import { OnCollisionEnter } from './events/OnCollisionEnter';
|
||||
@@ -63,6 +66,7 @@ import { OnStart } from './events/OnStart';
|
||||
import { OnTimer } from './events/OnTimer';
|
||||
import { OnUpdate } from './events/OnUpdate';
|
||||
import { Branch } from './flow/Branch';
|
||||
import { Repeat } from './flow/Repeat';
|
||||
import { Sequence } from './flow/Sequence';
|
||||
import { GetVariable } from './variable/GetVariable';
|
||||
import { SetVariable } from './variable/SetVariable';
|
||||
@@ -86,6 +90,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
// flow
|
||||
Branch,
|
||||
Sequence,
|
||||
Repeat,
|
||||
// variables
|
||||
GetVariable,
|
||||
SetVariable,
|
||||
@@ -120,7 +125,10 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
Time,
|
||||
// data: text and comparison
|
||||
ToText,
|
||||
ToNumber,
|
||||
Join,
|
||||
Substring,
|
||||
FindText,
|
||||
Equals,
|
||||
Compare,
|
||||
And,
|
||||
|
||||
Reference in new issue
Block a user