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:
rootandClaude Opus 5 committed 2026-09-09 19:10:41 +02:00
1 parent 9c4148dc1b
commit 4f78c7518c
16 files changed
+1479 -25

No files matched your search

+16
View File
@@ -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)) },
}),
};
+8 -5
View File
@@ -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)) } }),
};
+21
View File
@@ -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) } };
},
};
+14
View File
@@ -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) } }),
};
+19
View File
@@ -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',
}),
};
+8
View File
@@ -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,