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

+26 -5
View File
@@ -97,10 +97,11 @@ shown on the node and editable in the inspector.
viewport). The `Owner` node returns it; `Self` returns the behavior itself; `Parent` walks up
from any object.
- **Node set** (`src/core/graph/nodes/`, one file per node): events `On start`, `On update`, `On key down/up/held`,
`On timer`, `On collision enter/stay/exit`, `On click` (the mouse pressed on the owner – anywhere when it has none) · flow `Branch`, `Sequence` · data `Self`, `Owner`, `Parent`, `Object by name`, `Name of`,
`On timer`, `On collision enter/stay/exit`, `On click` (the mouse pressed on the owner – anywhere when it has none) · flow `Branch`, `Sequence`, `Repeat` · data `Self`, `Owner`, `Parent`, `Object by name`, `Name of`,
`Is asset` (is this object an instance of asset X – how you tell what you collided with), `Get/Set
variable`, `Get/Set property`, `Get position` (local and world), `Is enabled`, literals, `Add/Subtract/Multiply/Divide`,
`Min/Max/Abs/Sqrt/Clamp`, `Floor/Ceil/Round`, `Modulo`, `Random`, `Time`, `To text`, `Join text`, `Equals`,
`Min/Max/Abs/Sqrt/Clamp`, `Floor/Ceil/Round`, `Modulo`, `Random`, `Time`, `To text`, `To number`, `Join text`,
`Substring`, `Find text`, `Equals`,
`Length`, `Distance`, `Normalize`, `Axis input` (four keys → a
normalized direction, so diagonals are not faster), `Compare`, `And/Or/Not`, `Key is down`,
`Mouse position`, `Mouse button`, `Is hovered`, `Is touching` ·
@@ -109,6 +110,13 @@ shown on the node and editable in the inspector.
(ports, settings, `run`), listed in `nodes/index.ts`; palette, ports, validation and
inspector fields are generated from it. A port may declare a `default` for its inline value
(e.g. `Move.scale = 1`), shown on the node and in the inspector.
- **Loops**: `Repeat` runs its `body` output a number of times, counting `index` up from 0, and
continues at `done`. Data nodes are cached for the length of one event pulse, but a loop round
drops that cache, so an expression built on `index` really changes with it. That is what makes
a graph able to work through a list: the Tetris example draws its 200 board cells that way,
`Object by name` taking the name the round put together (`"c" + index`). Two guards, not one:
a pulse may take 100 000 steps (a loop legitimately runs long), but exec edges may only nest
1000 deep – that is what catches a flow feeding back into itself before the call stack does.
- **Cross-object state**: a behavior's variables *are* the properties of its object, so another
graph reads and writes them with `Object by name` → `Get property` / `Set property`. That is
how the example game keeps one coin counter for the whole run.
@@ -460,7 +468,7 @@ src/editor/graph/ GraphEditor (header + picker), GraphCanvas, NodeView, Nod
src/editor/container/ ContainerEditor (header + picker), ContainerCanvas (a SceneViewport on a template)
src/editor/texture/ TextureEditor (header + picker), TextureCanvas (picture + frames), SliceDialog, sheetView (the grid maths)
api/ PHP backend data/projects/ persisted projects
tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/), smoke-jumprun, lib/
tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/), smoke-jumprun, smoke-tetris, lib/
```
The toolbar's right edge shows the frame rate (frames counted over a 0.5 s window, updated
@@ -490,6 +498,7 @@ npm run build # writes dist/ and regenerates index.html
export GM_REMOTE=user@host # where the browser runs; see below
node tools/seed-e2e.mjs && node tools/e2e.mjs # 368 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs # 17 checks: the Tetris example, played end to end
```
**A run needs a working GPU.** An idle editor page keeps rendering a picture that never changes, and
@@ -532,8 +541,9 @@ imports, base-to-string. Where a rule fires on something deliberate, the disable
why (the `Log` node's `console.log`, a guard against untyped test hooks).
Each script writes to its own project (`tools/lib/projects.mjs`): the **fixture** project is
rewritten by every e2e run and the **jump-run** project by its seeder. The **playground**
project is the author's own and is maintained in the editor – every tool refuses to touch it.
rewritten by every e2e run, the **jump-run** and **tetris** projects by their seeders. The
**playground** project is the author's own and is maintained in the editor – every tool refuses
to touch it.
The e2e run is a list of steps in `tools/e2e/` (hierarchy, inspector, play, assets, drawing,
graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect,
@@ -551,6 +561,17 @@ and picking one up destroys the container, not just the sprite the collider repo
(start, jump, collect, win, restart, lose) – it is the regression test for everything screens,
text, mouse and platformer physics need.
**The second example** (`tools/seed-tetris.mjs`, project *Tetris*) is the same idea with data
instead of physics. The board is a **text of 200 characters**, one per cell – `.` for empty,
otherwise the digit of the shape that came to rest there. A cell is `Substring(grid, row · 10 +
column, 1)`, and clearing a full line is one expression: an empty row joined in front of
everything above and below it. What the player sees is a pool of objects the graph addresses by
name – `c0…c199` for the well, `p0…p3` for the piece that is falling – so nothing is spawned or
destroyed while playing. Every frame tries exactly **one** move (turning beats moving beats
falling), which is why the graph needs only a single "does it fit?" test instead of one per key.
`tools/smoke-tetris.mjs` plays it in a headless browser (fall, move, turn, drop, land, clear a
line, fill the well) – it is the regression test for loops, text nodes and names built at runtime.
The workspace folder itself is what nginx serves, so `npm run build` is the deployment.
## API
+40 -13
View File
@@ -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;
}
+12
View File
@@ -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. */
+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,
+137
View File
@@ -28,6 +28,7 @@ import { deserializeProject, serializeProject } from '../../src/core/store/seria
import {
type ProjectFixture,
fakeContext,
fakeEntity,
graphEditor,
makeBehavior,
makeProject,
@@ -241,10 +242,146 @@ describe('GraphInterpreter', () => {
[a, 'a', b, 'exec'],
[b, 'a', a, 'exec'],
]);
expect(() => new GraphInterpreter(behavior.graph).fire('OnStart', fakeContext())).toThrow(
/nested too deeply/,
);
});
it('stops a loop that would run forever', () => {
const { behavior, edit } = emptyBehavior(['count']);
const start = edit.addNode('OnStart');
const repeat = edit.addNode('Repeat', { times: 10_000_000 });
const log = edit.addNode('Log', { message: 'x' });
edit.wire([
[start, 'exec', repeat, 'exec'],
[repeat, 'body', log, 'exec'],
]);
expect(() => new GraphInterpreter(behavior.graph).fire('OnStart', fakeContext())).toThrow(/step limit/);
});
});
describe('Object by name', () => {
/** Both graphs below name "Target"; only the way the name reaches the node differs. */
function lookup(nodeParams: Record<string, string>, wireName: boolean) {
const { behavior, edit } = emptyBehavior();
const start = edit.addNode('OnStart');
const byName = edit.addNode('ObjectByName', nodeParams);
const log = edit.addNode('Log');
edit.wire([
[start, 'exec', log, 'exec'],
[byName, 'object', log, 'message'],
]);
if (wireName) {
const join = edit.addNode('Join', { a: 'Tar', b: 'get' });
edit.wire([[join, 'text', byName, 'name']]);
}
const ctx = fakeContext({ findByName: (name) => (name === 'Target' ? fakeEntity('Target') : null) });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
return ctx.logs;
}
it('still reads a name stored as a node setting, the way every saved project holds it', () => {
expect(lookup({ name: 'Target' }, false)).toEqual(['Target']);
});
it('takes a name put together at runtime', () => {
expect(lookup({}, true)).toEqual(['Target']);
});
});
/**
* A loop is the one place where the per-pulse data cache would lie: every round has to read its
* inputs anew, or the body would keep computing with the values of the first round.
*/
describe('Repeat', () => {
/** "count = count + index" in the body of a Repeat, so the sum proves each round saw its own index. */
function summingLoop(times: number) {
const fixture = emptyBehavior(['count']);
const { edit } = fixture;
const start = edit.addNode('OnStart');
const repeat = edit.addNode('Repeat', { times });
const get = edit.addNode('GetVariable', { variable: 'count' });
const add = edit.addNode('Add');
const set = edit.addNode('SetVariable', { variable: 'count' });
edit.wire([
[start, 'exec', repeat, 'exec'],
[repeat, 'body', set, 'exec'],
[get, 'value', add, 'a'],
[repeat, 'index', add, 'b'],
[add, 'result', set, 'value'],
]);
return { ...fixture, nodes: { repeat, set } };
}
it('runs the body once per round, each round seeing its own index', () => {
const { behavior } = summingLoop(5);
const ctx = fakeContext({ variables: { count: 0 } });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
// 0+1+2+3+4. A stale data cache would add the first round's index five times (0) instead.
expect(ctx.variables.count).toBe(10);
});
it('runs nothing at all for a count of zero', () => {
const { behavior } = summingLoop(0);
const ctx = fakeContext({ variables: { count: 7 } });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
expect(ctx.variables.count).toBe(7);
});
it('continues along done when the rounds are over', () => {
const { behavior, edit, nodes } = summingLoop(3);
const done = edit.addNode('Log', { message: 'done' });
edit.wire([[nodes.repeat, 'done', done, 'exec']]);
const ctx = fakeContext({ variables: { count: 0 } });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
expect({ count: ctx.variables.count, logs: ctx.logs }).toEqual({ count: 3, logs: ['done'] });
});
it('nests: the inner loop starts over for every round of the outer one', () => {
const { behavior, edit } = emptyBehavior(['count']);
const start = edit.addNode('OnStart');
const outer = edit.addNode('Repeat', { times: 3 });
const inner = edit.addNode('Repeat', { times: 4 });
const get = edit.addNode('GetVariable', { variable: 'count' });
const add = edit.addNode('Add', { b: 1 });
const set = edit.addNode('SetVariable', { variable: 'count' });
edit.wire([
[start, 'exec', outer, 'exec'],
[outer, 'body', inner, 'exec'],
[inner, 'body', set, 'exec'],
[get, 'value', add, 'a'],
[add, 'result', set, 'value'],
]);
const ctx = fakeContext({ variables: { count: 0 } });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
expect(ctx.variables.count).toBe(12);
});
it('leaves the world alone after the loop: what follows reads current values, not the last round', () => {
const { behavior, edit } = emptyBehavior(['count']);
const start = edit.addNode('OnStart');
const repeat = edit.addNode('Repeat', { times: 3 });
const get = edit.addNode('GetVariable', { variable: 'count' });
const add = edit.addNode('Add', { b: 1 });
const set = edit.addNode('SetVariable', { variable: 'count' });
const after = edit.addNode('SetVariable', { variable: 'count' });
const afterAdd = edit.addNode('Multiply', { b: 10 });
edit.wire([
[start, 'exec', repeat, 'exec'],
[repeat, 'body', set, 'exec'],
[get, 'value', add, 'a'],
[add, 'result', set, 'value'],
[repeat, 'done', after, 'exec'],
[get, 'value', afterAdd, 'a'],
[afterAdd, 'result', after, 'value'],
]);
const ctx = fakeContext({ variables: { count: 0 } });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
// 3 rounds, then ×10 on the *current* count – a cache left over from the last round would give 20.
expect(ctx.variables.count).toBe(30);
});
});
describe('graph commands', () => {
it('rejects incompatible, self and duplicate connections', () => {
const {
+51 -2
View File
@@ -18,6 +18,8 @@ interface Case {
outputs?: Record<string, Value>;
/** Expected exec continuation; `null` asserts the flow ends here. */
next?: string | string[] | null;
/** Expected loop request; `null` asserts the node does not loop. */
repeat?: RunResult['repeat'] | null;
/** Assertions on what the node did to the context or to the handles it was given. */
after?(ctx: FakeContext): void;
}
@@ -43,6 +45,19 @@ const CASES: Record<string, Case[]> = {
{ name: '"false" as text is false', inputs: { condition: 'false' }, next: 'else' },
],
Sequence: [{ name: 'runs all three outputs in order', next: ['a', 'b', 'c'] }],
Repeat: [
{
name: 'asks for as many body rounds as it was given, then continues at done',
inputs: { times: 3 },
repeat: { port: 'body', index: 'index', times: 3 },
next: 'done',
},
{
name: 'a fractional count is cut and a negative one runs nothing',
inputs: { times: -2.7 },
repeat: { port: 'body', index: 'index', times: 0 },
},
],
// --- variables -------------------------------------------------------------------------------
@@ -84,11 +99,11 @@ const CASES: Record<string, Case[]> = {
ObjectByName: [
{
name: 'asks the context for the name',
params: { name: 'Player' },
inputs: { name: 'Player' },
context: { findByName: (name) => (name === 'Player' ? fakeEntity('Player') : null) },
outputs: { object: fakeEntity('Player') },
},
{ name: 'an unknown name is null', params: { name: 'Ghost' }, outputs: { object: null } },
{ name: 'an unknown name is null', inputs: { name: 'Ghost' }, outputs: { object: null } },
],
NameOf: [
{ name: 'reads the name', inputs: { object: fakeEntity('Coin') }, outputs: { name: 'Coin' } },
@@ -229,6 +244,39 @@ const CASES: Record<string, Case[]> = {
{ name: 'text passes through unformatted', inputs: { value: 'x', decimals: 2 }, outputs: { text: 'x' } },
{ name: 'decimals are clamped to 0…10', inputs: { value: 1, decimals: -5 }, outputs: { text: '1' } },
],
ToNumber: [
{ name: 'reads a text as a number', inputs: { value: '42' }, outputs: { result: 42 } },
{ name: 'a word is 0', inputs: { value: 'twelve' }, outputs: { result: 0 } },
],
Substring: [
{
name: 'takes count characters from start',
inputs: { text: 'abcdef', start: 2, count: 3 },
outputs: { text: 'cde' },
},
{
name: 'a negative count reaches to the end',
inputs: { text: 'abcdef', start: 4, count: -1 },
outputs: { text: 'ef' },
},
{
name: 'asking past the end gives what is there',
inputs: { text: 'ab', start: 1, count: 10 },
outputs: { text: 'b' },
},
],
FindText: [
{
name: 'finds where the search first appears',
inputs: { text: '..#.#', search: '#' },
outputs: { index: 2 },
},
{
name: 'text that does not appear is -1',
inputs: { text: '....', search: '#' },
outputs: { index: -1 },
},
],
Join: [
{ name: 'sticks values together as text', inputs: { a: 'Coins: ', b: 3 }, outputs: { text: 'Coins: 3' } },
{
@@ -541,6 +589,7 @@ for (const [type, cases] of Object.entries(CASES)) {
const { result, ctx } = run(type, c);
if (c.outputs) expect(result.outputs ?? {}).toEqual(c.outputs);
if (c.next !== undefined) expect(result.next ?? null).toEqual(c.next);
if (c.repeat !== undefined) expect(result.repeat ?? null).toEqual(c.repeat);
c.after?.(ctx);
});
}
+95
View File
@@ -0,0 +1,95 @@
## Nächstes spiel: Tetris
Ganz simpel halten, also wirklich nur das nötigste:
- die möglichen shapes die es halt so gibt
- ein zufallspawn eines dieser shapes das langsam nach unten geht
- links/rechts pfeilstasten um zu bewegen, pfeil rauf um im uhrzeigsinn zu drehen, pfeil runter gedrückt halten um nach unten zu beschleunigen
- eine line gefüllte line solved
- obere grenze = game over, ansonsten ist es einfach ein endlosspiel
- counter ui der einfach nur die gesolved lines zählt
was wir nicht brauchen:
- "levels", sprich dass shapes schneller werden beim runterfallen
- titlescreen oder gameover screen, wenn gameover einfach spiel stoppen
- animationen, spezielle texturen, oder sonst irgendwelchen schnick schnack
- e2e erweiterung zu dem projekt => dieses projekt braucht keine speziellen e2e tests die man immer wieder durchlaufen muss
habe ich irgendwas vergessen? wenn ja, rückfragen, ansonsten einfach project seed anlegen und projekt generieren
falls nodes fehlen, oder praktische nodes sinnvoll wären die noch fehlen, dürfen diese natürlich erstellt werden
---
**Umgesetzt (2026-09-09).** Projekt *Tetris* (`2072b56583af`), Seed `tools/seed-tetris.mjs`,
gespielt von `tools/smoke-tetris.mjs`. Alles aus deiner Liste ist drin, nichts davon, was du
ausgeschlossen hast: keine Level, kein Titel- oder Game-Over-Screen (Game Over stoppt das Spiel
per `Stop game`), keine Animationen, keine Texturen, und **keine Erweiterung der E2E-Suite** —
der Smoke-Lauf ist wie beim Jump-and-Run ein eigenes Skript, das nur laeuft, wenn dieses Projekt
oder die Engine darunter sich aendert.
**Das Brett ist ein Text.** 200 Zeichen, eins pro Zelle: `.` fuer leer, sonst die Ziffer der Form,
die dort liegengeblieben ist. Damit sind die beiden Operationen, auf denen Tetris steht, je ein
einziger Ausdruck statt einer Schleife: eine Zelle ist `Substring(grid, zeile·10 + spalte, 1)`,
und eine volle Reihe zu loesen ist eine leere Reihe, vor alles darueber und darunter gejoint.
Gezeichnet wird aus einem **Vorrat benannter Objekte** — `c0…c199` fuer das Brett, `p0…p3` fuer
den fallenden Stein. Waehrend des Spielens wird nichts gespawnt und nichts zerstoert.
**Vier neue Nodes, alle allgemein und nicht Tetris-eigen** (danach gefragt, du hast (a) gewaehlt):
| | |
|---|---|
| `Repeat` (flow) | `times` → `body` / `index` / `done`. Der Interpreter kann seitdem Schleifen: er verwirft den Datencache **pro Runde**, sonst rechnete jede Runde mit den Werten der ersten. |
| `Substring` | `count` Zeichen ab `start`; negatives `count` reicht bis zum Ende |
| `Find text` | wo ein Text zuerst vorkommt, `−1` wenn nicht — damit ist "Reihe voll" = *kein Punkt darin* |
| `To number` | die fehlende Gegenrichtung zu `To text` |
Dazu eine Aenderung an einem vorhandenen Node: **`Object by name` nimmt den Namen jetzt als
Eingang** statt nur als Einstellung. Ohne das waeren 200 Zellen nicht adressierbar — die Zeichen-
schleife baut `"c" + index`. Alte Projekte sind unberuehrt: ein gespeicherter Name liegt in
`params.name`, und genau von dort liest ein unverbundener Eingang (eigener Test dafuer).
**Der Kunstgriff, der den Graphen klein haelt:** jeder Frame versucht **genau einen** Zug —
Drehen schlaegt Seitwaerts schlaegt Fallen. Die Tasten bewegen nichts selbst, sie hinterlassen
einen Wunsch (`wantDx`, `wantRot`), den der naechste Frame einloest. Dadurch gibt es die
Pruefung "passen die vier Zellen dort hin?" **einmal** im ganzen Graphen statt einmal pro Taste.
Ein um einen Frame verzoegerter Seitwaertsschritt ist bei 60 fps unsichtbar; drei Kopien
derselben 30 Nodes waeren es nicht.
**Wie das Landen ins Brett kommt:** der gelandete Stein wird nicht noch einmal aus der Formtabelle
ausgerechnet, sondern **von seinen vier Zellobjekten abgelesen** (`Get position` → Spalte/Zeile).
Sie stehen genau dort, wo der Stein ist — das Zeichnen ist die einzige Stelle, die sie anfasst.
**Belegt** — `tools/smoke-tetris.mjs`, remote im echten Browser: **ALL PASSED, 17 Checks.**
```
PASS the piece falls on its own – row 1 → 4
PASS the left arrow moves the piece one column – 4,5,4,5 → 3,4,3,4
PASS the up arrow turns the piece – rot 0 → 1
PASS the board draws exactly the cells the board text names – {"lit":8,"inText":8}
PASS a full line disappears and is counted – lines 1, bottom row "....44...."
PASS a piece that cannot fall at the very top stops the game – stopped
```
Der Brett-Check ist der wichtigste: er haelt den **Text** gegen die **Bildschirmzellen** — ohne ihn
koennte die Zeichenschleife irgendetwas anzeigen und alles andere trotzdem gruen sein. Fuer die
volle Reihe wird die unterste Reihe vorher gefuellt, damit jeder Stein sie vollmacht, egal welcher
kommt. Der Game-Over-Check wartet auf die Bedingung statt auf die Uhr — mit fester Wartezeit war
er in einem von zwei Laeufen rot, und ein Check, der wuerfelt, ist keiner.
**Was ich selbst entschieden habe:**
- **Kein Wall-Kick.** Eine Drehung, die nicht passt, findet nicht statt — sie wird nicht an der
Wand vorbeigeschoben. Das ist die simple Regel, und "ganz simpel" war die Vorgabe.
- **Farbe pro Form**, wie im Original. Kostet drei Nodes: die Ziffer im Bretttext sucht ihre
Farbe aus einem zweiten Text (`colors`), acht Ziffern je Farbe.
- **Drehachse** ist die Box der Form (I in 4×4, O in 2×2, Rest 3×3), nicht der Schwerpunkt. Ohne
Wall-Kick ist das das Verhalten, das am wenigsten ueberrascht.
- **Game Over = ein Stein kann ganz oben (`py ≤ 0`) nicht einmal fallen.** Kein zweiter Test beim
Erscheinen, damit die Pruefung an der einen Stelle bleibt.
- **Tempo:** eine Reihe pro halber Sekunde, gehaltene Pfeil-runter-Taste zehnmal so schnell. Beides
steht als Variable `stepTime` im Inspector.
**Was du wissen solltest:** der Board-Container traegt **205 Objekte** (Wanne + 200 Zellen + 4
Steinzellen). Im Editor sieht man davon nichts — die Szene zeigt acht Objekte, der Rest liegt
im Container. Wer ihn aber oeffnet, bekommt eine lange Liste. Das ist der Preis dafuer, dass
nichts gespawnt wird.
+2
View File
@@ -32,7 +32,9 @@ const KEYS = {
Escape: { key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 },
Space: { key: ' ', code: 'Space', windowsVirtualKeyCode: 32 },
ArrowLeft: { key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 },
ArrowUp: { key: 'ArrowUp', code: 'ArrowUp', windowsVirtualKeyCode: 38 },
ArrowRight: { key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 },
ArrowDown: { key: 'ArrowDown', code: 'ArrowDown', windowsVirtualKeyCode: 40 },
Delete: { key: 'Delete', code: 'Delete', windowsVirtualKeyCode: 46 },
a: { key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65 },
g: { key: 'g', code: 'KeyG', windowsVirtualKeyCode: 71 },
+2
View File
@@ -5,6 +5,7 @@
* playground the user's own project. No tool writes it – it is maintained in the editor.
* fixture rewritten by every e2e run (tools/seed-e2e.mjs + tools/e2e.mjs).
* jumpRun the example game (tools/seed-jumprun.mjs, played by tools/smoke-jumprun.mjs).
* tetris the second example game (tools/seed-tetris.mjs, played by tools/smoke-tetris.mjs).
*
* All ids exist on the server (created via POST ?r=projects); PUT does not create projects.
*/
@@ -12,6 +13,7 @@ export const PROJECTS = {
playground: '649188c15414',
fixture: '3caa9b6a0c92',
jumpRun: '3d271f3bb58b',
tetris: '2072b56583af',
};
export const DEFAULT_BASE_URL = 'https://nockal.com/game-maker/';
+807
View File
@@ -0,0 +1,807 @@
#!/usr/bin/env node
/**
* Writes the second example game: Tetris, again built only from assets and one node graph.
*
* The board is a text: 200 characters, one per cell, '.' for empty and the digit of the piece
* that came to rest there. That makes the two operations the game is built on single expressions
* instead of loops – a cell is `Substring(grid, row·10 + column, 1)`, and clearing a full line is
* an empty row joined in front of everything above and below it.
*
* What the player sees is a pool of objects the graph addresses by name: `c0…c199` for the board
* and `p0…p3` for the piece that is falling. Nothing is spawned or destroyed while playing.
*
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-tetris.mjs [projectId] [baseUrl]
*/
import { edge, node, sceneObject, variable } from './lib/builders.mjs';
import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs';
const { projectId, baseUrl } = cliArgs('seed-tetris.mjs', PROJECTS.tetris);
// --- board geometry --------------------------------------------------------------------------
const COLS = 10;
const ROWS = 20;
const CELL = 16;
/** Cell 0 sits at the top left of the board; these turn a column/row into the object's position. */
const CELL_X = (col) => col * CELL - (COLS * CELL) / 2 + CELL / 2;
const CELL_Y = (row) => row * CELL - (ROWS * CELL) / 2 + CELL / 2;
const EMPTY_CELL = '.';
const EMPTY_ROW = EMPTY_CELL.repeat(COLS);
const EMPTY_GRID = EMPTY_CELL.repeat(COLS * ROWS);
// --- colours ---------------------------------------------------------------------------------
const BACKGROUND = 0x151821;
const WELL = 0x0d1017;
const WHITE = 0xf2f5f7;
const HINT = 0x9aa3b2;
/** One per shape, in the order of `SHAPES` below. */
const SHAPE_COLORS = [0x2ec9d6, 0xf5c542, 0xa35fd6, 0x63c74d, 0xe0553f, 0x4a7de0, 0xe08a3f];
// --- the seven shapes ------------------------------------------------------------------------
/**
* Each shape as its four cells in a box of `size` × `size`, the box being what turning rotates
* inside: (x, y) → (size − 1 − y, x) is a quarter turn clockwise. I needs a 4-box, O a 2-box.
*/
const SHAPES = [
{
name: 'I',
size: 4,
cells: [
[0, 1],
[1, 1],
[2, 1],
[3, 1],
],
},
{
name: 'O',
size: 2,
cells: [
[0, 0],
[1, 0],
[0, 1],
[1, 1],
],
},
{
name: 'T',
size: 3,
cells: [
[1, 0],
[0, 1],
[1, 1],
[2, 1],
],
},
{
name: 'S',
size: 3,
cells: [
[1, 0],
[2, 0],
[0, 1],
[1, 1],
],
},
{
name: 'Z',
size: 3,
cells: [
[0, 0],
[1, 0],
[1, 1],
[2, 1],
],
},
{
name: 'J',
size: 3,
cells: [
[0, 0],
[0, 1],
[1, 1],
[2, 1],
],
},
{
name: 'L',
size: 3,
cells: [
[2, 0],
[0, 1],
[1, 1],
[2, 1],
],
},
];
const turned = (cells, size) => cells.map(([x, y]) => [size - 1 - y, x]);
/**
* All 28 rotations in one text, eight digits each (x and y of the four cells), so the graph reads
* a rotation as `Substring(shapes, (shape·4 + rotation)·8, 8)`.
*/
function shapeTable() {
let table = '';
for (const shape of SHAPES) {
let cells = shape.cells;
for (let rotation = 0; rotation < 4; rotation++) {
table += cells.map(([x, y]) => `${x}${y}`).join('');
cells = turned(cells, shape.size);
}
}
return table;
}
/** Colours as eight digits each, so a cell's digit picks its colour out of one text. */
const colorTable = () => SHAPE_COLORS.map((c) => String(c).padStart(8, '0')).join('');
/** The column a shape starts in, so every piece appears centred over the well. */
const spawnColumns = () => SHAPES.map((s) => Math.floor((COLS - s.size) / 2)).join('');
// --- the board container ---------------------------------------------------------------------
/** The well, the 200 board cells (switched off = empty) and the four cells of the falling piece. */
const boardTemplate = [
sceneObject('well', 'well-sprite', { name: 'Well' }),
...Array.from({ length: COLS * ROWS }, (_, i) =>
sceneObject(`cell${i}`, 'cell-sprite', {
name: `c${i}`,
x: CELL_X(i % COLS),
y: CELL_Y(Math.floor(i / COLS)),
z: 1,
overrides: { enabled: false },
}),
),
...Array.from({ length: 4 }, (_, i) =>
sceneObject(`piece${i}`, 'cell-sprite', { name: `p${i}`, z: 2, overrides: { enabled: false } }),
),
];
// --- graph helpers -----------------------------------------------------------------------------
// Nodes are laid out in bands, one per strand of the graph, so the editor shows the game as a
// stack of readable blocks rather than one cloud.
const nodes = [];
const edges = [];
let band = 0;
/** Starts the next band. */
const strand = () => band++;
/** Places a node; `column`/`row` are grid steps inside the current band. */
function at(id, type, column, row, params = {}) {
nodes.push(node(id, type, column * 220, band * 1400 + row * 90, params));
return id;
}
function link(from, fromPort, to, toPort) {
edges.push(edge(`e${edges.length + 1}`, from, fromPort, to, toPort));
}
/** Chains exec outputs: `flow(a, b, c)` runs a, then b, then c. */
function flow(...ids) {
for (let i = 0; i < ids.length - 1; i++) link(ids[i], 'exec', ids[i + 1], 'exec');
}
// Reading a variable is so common that it gets a shorthand.
const get = (id, key, column, row) => at(id, 'GetVariable', column, row, { variable: key });
const set = (id, key, column, row, value) =>
at(id, 'SetVariable', column, row, value === undefined ? { variable: key } : { variable: key, value });
/**
* The four cells of one rotation, as the graph computes them: reads `shapes` at
* (shape·4 + rotation)·8 and takes the two digits belonging to the round the loop is in.
* Returns the ids carrying the cell's column and row, offset by the piece's position.
*/
function cellOfRound(prefix, { loop, shapeVar, rotVar, xVar, yVar }, column, row) {
const shape = get(`${prefix}-shape`, shapeVar, column, row);
const rot = get(`${prefix}-rot`, rotVar, column, row + 1);
const shapes = get(`${prefix}-shapes`, 'shapes', column, row + 2);
const four = at(`${prefix}-x4`, 'Multiply', column + 1, row, { b: 4 });
const plus = at(`${prefix}-plus`, 'Add', column + 2, row);
const eight = at(`${prefix}-x8`, 'Multiply', column + 3, row, { b: 8 });
const entry = at(`${prefix}-entry`, 'Substring', column + 4, row, { count: 8 });
link(shape, 'value', four, 'a');
link(four, 'result', plus, 'a');
link(rot, 'value', plus, 'b');
link(plus, 'result', eight, 'a');
link(shapes, 'value', entry, 'text');
link(eight, 'result', entry, 'start');
const twice = at(`${prefix}-k2`, 'Multiply', column + 4, row + 3, { b: 2 });
link(loop, 'index', twice, 'a');
const digitX = at(`${prefix}-dx`, 'Substring', column + 5, row + 2, { count: 1 });
const digitY = at(`${prefix}-dy`, 'Substring', column + 5, row + 5, { count: 1 });
const next = at(`${prefix}-k2b`, 'Add', column + 4, row + 5, { b: 1 });
link(twice, 'result', next, 'a');
link(entry, 'text', digitX, 'text');
link(twice, 'result', digitX, 'start');
link(entry, 'text', digitY, 'text');
link(next, 'result', digitY, 'start');
const numX = at(`${prefix}-nx`, 'ToNumber', column + 6, row + 2);
const numY = at(`${prefix}-ny`, 'ToNumber', column + 6, row + 5);
link(digitX, 'text', numX, 'value');
link(digitY, 'text', numY, 'value');
const baseX = get(`${prefix}-basex`, xVar, column + 6, row);
const baseY = get(`${prefix}-basey`, yVar, column + 6, row + 7);
const cellX = at(`${prefix}-cx`, 'Add', column + 7, row + 1);
const cellY = at(`${prefix}-cy`, 'Add', column + 7, row + 6);
link(baseX, 'value', cellX, 'a');
link(numX, 'result', cellX, 'b');
link(baseY, 'value', cellY, 'a');
link(numY, 'result', cellY, 'b');
return { cellX, cellY };
}
/** `ObjectByName` with a name built at runtime: prefix + the round the loop is in. */
function objectOfRound(prefix, loop, namePrefix, column, row) {
const text = at(`${prefix}-text`, 'ToText', column, row, { decimals: 0 });
const name = at(`${prefix}-name`, 'Join', column + 1, row, { a: namePrefix });
const object = at(`${prefix}-obj`, 'ObjectByName', column + 2, row);
link(loop, 'index', text, 'value');
link(text, 'text', name, 'b');
link(name, 'text', object, 'name');
return object;
}
// --- the graph ---------------------------------------------------------------------------------
// 1) On start: an empty board, no piece, nothing pressed. The world is rebuilt for every run, so
// this only matters when the behavior was left running in the editor.
strand();
at('s-start', 'OnStart', 0, 0);
set('s-grid', 'grid', 1, 0, EMPTY_GRID);
set('s-lines', 'lines', 2, 0, 0);
set('s-piece', 'hasPiece', 3, 0, false);
set('s-fall', 'fall', 4, 0, 0);
set('s-dx', 'wantDx', 5, 0, 0);
set('s-turn', 'wantRot', 6, 0, 0);
at('s-label', 'ObjectByName', 6, 2, { name: 'LinesLabel' });
at('s-text', 'SetText', 7, 0, { text: 'Lines 0' });
flow('s-start', 's-grid', 's-lines', 's-piece', 's-fall', 's-dx', 's-turn', 's-text');
link('s-label', 'object', 's-text', 'object');
// 2) The keys do not move anything themselves – they leave a wish that the next frame grants. That
// way every attempt to move goes through the one test below, whatever asked for it.
strand();
at('k-left', 'OnKeyDown', 0, 0, { key: 'ArrowLeft' });
set('k-setleft', 'wantDx', 1, 0, -1);
at('k-right', 'OnKeyDown', 0, 3, { key: 'ArrowRight' });
set('k-setright', 'wantDx', 1, 3, 1);
at('k-up', 'OnKeyDown', 0, 6, { key: 'ArrowUp' });
set('k-setup', 'wantRot', 1, 6, 1);
flow('k-left', 'k-setleft');
flow('k-right', 'k-setright');
flow('k-up', 'k-setup');
// 3) Every frame does one thing: either a new piece appears, or the piece tries one move. Which
// move is decided first (turning beats moving beats falling) and then tested in one place.
strand();
at('u-update', 'OnUpdate', 0, 0);
get('u-has', 'hasPiece', 0, 2);
at('u-br', 'Branch', 1, 0);
flow('u-update', 'u-br');
link('u-has', 'value', 'u-br', 'condition');
// 3a) No piece: draw one of the seven at random, at the column that centres it.
at('n-random', 'Random', 2, 4, { min: 0, max: 7 });
at('n-floor', 'Floor', 3, 4);
set('n-shape', 'shape', 4, 4);
set('n-rot', 'rot', 5, 4, 0);
get('n-shape2', 'shape', 5, 7);
get('n-columns', 'spawnX', 5, 9);
at('n-column', 'Substring', 6, 8, { count: 1 });
at('n-number', 'ToNumber', 7, 8);
set('n-px', 'px', 6, 4);
set('n-py', 'py', 7, 4, 0);
set('n-fall', 'fall', 8, 4, 0);
set('n-has', 'hasPiece', 9, 4, true);
link('u-br', 'else', 'n-shape', 'exec');
link('n-random', 'result', 'n-floor', 'value');
link('n-floor', 'result', 'n-shape', 'value');
link('n-shape2', 'value', 'n-column', 'start');
link('n-columns', 'value', 'n-column', 'text');
link('n-column', 'text', 'n-number', 'value');
link('n-number', 'result', 'n-px', 'value');
flow('n-shape', 'n-rot', 'n-px', 'n-py', 'n-fall', 'n-has');
// 3b) A piece is falling: the candidate starts out as where it already is.
strand();
get('c-px', 'px', 0, 2);
get('c-py', 'py', 1, 2);
get('c-rot', 'rot', 2, 2);
set('c-nx', 'nx', 0, 0);
set('c-ny', 'ny', 1, 0);
set('c-nrot', 'nrot', 2, 0);
set('c-kind', 'kind', 3, 0, '');
link('u-br', 'then', 'c-nx', 'exec');
link('c-px', 'value', 'c-nx', 'value');
link('c-py', 'value', 'c-ny', 'value');
link('c-rot', 'value', 'c-nrot', 'value');
flow('c-nx', 'c-ny', 'c-nrot', 'c-kind');
// Turning wins: a quarter turn clockwise is one step around the four rotations of the shape.
get('c-wantrot', 'wantRot', 4, 3);
at('c-turning', 'Compare', 5, 3, { op: '>', b: 0 });
at('c-brturn', 'Branch', 5, 0);
get('c-rot2', 'rot', 6, 3);
at('c-plus1', 'Add', 7, 3, { b: 1 });
at('c-wrap', 'Modulo', 8, 3, { b: 4 });
set('c-setrot', 'nrot', 6, 0);
set('c-kindturn', 'kind', 7, 0, 'turn');
set('c-usedrot', 'wantRot', 8, 0, 0);
flow('c-kind', 'c-brturn');
link('c-wantrot', 'value', 'c-turning', 'a');
link('c-turning', 'result', 'c-brturn', 'condition');
link('c-brturn', 'then', 'c-setrot', 'exec');
link('c-rot2', 'value', 'c-plus1', 'a');
link('c-plus1', 'result', 'c-wrap', 'a');
link('c-wrap', 'result', 'c-setrot', 'value');
flow('c-setrot', 'c-kindturn', 'c-usedrot');
// Then sideways, one column per key press.
get('c-wantdx', 'wantDx', 4, 8);
at('c-moving', 'Equals', 5, 8, { b: 0 });
at('c-notmoving', 'Not', 6, 8);
at('c-brmove', 'Branch', 6, 6);
get('c-px2', 'px', 7, 9);
at('c-shift', 'Add', 8, 9);
set('c-setnx', 'nx', 7, 6);
set('c-kindmove', 'kind', 8, 6, 'move');
set('c-useddx', 'wantDx', 9, 6, 0);
link('c-brturn', 'else', 'c-brmove', 'exec');
link('c-wantdx', 'value', 'c-moving', 'a');
link('c-moving', 'result', 'c-notmoving', 'value');
link('c-notmoving', 'result', 'c-brmove', 'condition');
link('c-brmove', 'then', 'c-setnx', 'exec');
link('c-px2', 'value', 'c-shift', 'a');
link('c-wantdx', 'value', 'c-shift', 'b');
link('c-shift', 'result', 'c-setnx', 'value');
flow('c-setnx', 'c-kindmove', 'c-useddx');
// And otherwise the clock: holding the down key lets it run ten times as fast.
strand();
get('f-fall', 'fall', 0, 3);
at('f-down', 'KeyIsDown', 0, 6, { key: 'ArrowDown' });
at('f-boost', 'Multiply', 1, 6, { b: 9 });
at('f-rate', 'Add', 2, 6, { a: 1 });
at('f-step', 'Multiply', 3, 5);
at('f-add', 'Add', 4, 3);
set('f-setfall', 'fall', 1, 0);
link('c-brmove', 'else', 'f-setfall', 'exec');
link('f-down', 'down', 'f-boost', 'a');
link('f-boost', 'result', 'f-rate', 'b');
link('u-update', 'dt', 'f-step', 'a');
link('f-rate', 'result', 'f-step', 'b');
link('f-fall', 'value', 'f-add', 'a');
link('f-step', 'result', 'f-add', 'b');
link('f-add', 'result', 'f-setfall', 'value');
get('f-fall2', 'fall', 2, 3);
get('f-time', 'stepTime', 2, 5);
at('f-due', 'Compare', 3, 3, { op: '>=' });
at('f-brdue', 'Branch', 4, 0);
at('f-minus', 'Subtract', 5, 3);
set('f-rest', 'fall', 5, 0);
get('f-py', 'py', 6, 3);
at('f-down1', 'Add', 7, 3, { b: 1 });
set('f-setny', 'ny', 6, 0);
set('f-kind', 'kind', 7, 0, 'fall');
flow('f-setfall', 'f-brdue');
link('f-fall2', 'value', 'f-due', 'a');
link('f-time', 'value', 'f-due', 'b');
link('f-due', 'result', 'f-brdue', 'condition');
link('f-brdue', 'then', 'f-rest', 'exec');
link('f-fall2', 'value', 'f-minus', 'a');
link('f-time', 'value', 'f-minus', 'b');
link('f-minus', 'result', 'f-rest', 'value');
link('f-py', 'value', 'f-down1', 'a');
link('f-down1', 'result', 'f-setny', 'value');
flow('f-rest', 'f-setny', 'f-kind');
// 4) The one test: do the four cells of the candidate fit? Every attempt above ends here.
strand();
set('t-ok', 'ok', 0, 0, true);
at('t-loop', 'Repeat', 1, 0, { times: 4 });
flow('c-usedrot', 't-ok');
flow('c-useddx', 't-ok');
flow('f-kind', 't-ok');
flow('t-ok', 't-loop');
const candidate = cellOfRound(
't',
{ loop: 't-loop', shapeVar: 'shape', rotVar: 'nrot', xVar: 'nx', yVar: 'ny' },
2,
4,
);
at('t-left', 'Compare', 10, 0, { op: '<', b: 0 });
at('t-right', 'Compare', 10, 2, { op: '>', b: COLS - 1 });
at('t-side', 'Or', 11, 1);
at('t-bottom', 'Compare', 10, 4, { op: '>', b: ROWS - 1 });
at('t-outside', 'Or', 12, 2);
link(candidate.cellX, 'result', 't-left', 'a');
link(candidate.cellX, 'result', 't-right', 'a');
link('t-left', 'result', 't-side', 'a');
link('t-right', 'result', 't-side', 'b');
link(candidate.cellY, 'result', 't-bottom', 'a');
link('t-side', 'result', 't-outside', 'a');
link('t-bottom', 'result', 't-outside', 'b');
// A cell outside the board reads as empty here, but the check above has already caught it.
at('t-row', 'Multiply', 10, 7, { b: COLS });
at('t-index', 'Add', 11, 7);
get('t-grid', 'grid', 11, 9);
at('t-cell', 'Substring', 12, 8, { count: 1 });
at('t-empty', 'Equals', 13, 8, { b: EMPTY_CELL });
at('t-taken', 'Not', 14, 8);
at('t-blocked', 'Or', 15, 4);
at('t-br', 'Branch', 16, 0);
set('t-no', 'ok', 17, 0, false);
link(candidate.cellY, 'result', 't-row', 'a');
link('t-row', 'result', 't-index', 'a');
link(candidate.cellX, 'result', 't-index', 'b');
link('t-grid', 'value', 't-cell', 'text');
link('t-index', 'result', 't-cell', 'start');
link('t-cell', 'text', 't-empty', 'a');
link('t-empty', 'result', 't-taken', 'value');
link('t-outside', 'result', 't-blocked', 'a');
link('t-taken', 'result', 't-blocked', 'b');
link('t-loop', 'body', 't-br', 'exec');
link('t-blocked', 'result', 't-br', 'condition');
link('t-br', 'then', 't-no', 'exec');
// 5) It fits: the candidate becomes the piece. It does not: only a fall means anything – the
// piece has landed (or, still at the very top, the well is full).
strand();
get('m-ok', 'ok', 0, 2);
at('m-br', 'Branch', 1, 0);
get('m-nx', 'nx', 2, 3);
get('m-ny', 'ny', 3, 3);
get('m-nrot', 'nrot', 4, 3);
set('m-px', 'px', 2, 0);
set('m-py', 'py', 3, 0);
set('m-rot', 'rot', 4, 0);
link('t-loop', 'done', 'm-br', 'exec');
link('m-ok', 'value', 'm-br', 'condition');
link('m-br', 'then', 'm-px', 'exec');
link('m-nx', 'value', 'm-px', 'value');
link('m-ny', 'value', 'm-py', 'value');
link('m-nrot', 'value', 'm-rot', 'value');
flow('m-px', 'm-py', 'm-rot');
get('m-kind', 'kind', 2, 7);
at('m-landed', 'Equals', 3, 7, { b: 'fall' });
at('m-brland', 'Branch', 3, 5);
get('m-py2', 'py', 4, 8);
at('m-attop', 'Compare', 5, 8, { op: '<=', b: 0 });
at('m-brover', 'Branch', 5, 5);
at('m-stop', 'StopGame', 6, 5);
link('m-br', 'else', 'm-brland', 'exec');
link('m-kind', 'value', 'm-landed', 'a');
link('m-landed', 'result', 'm-brland', 'condition');
link('m-brland', 'then', 'm-brover', 'exec');
link('m-py2', 'value', 'm-attop', 'a');
link('m-attop', 'result', 'm-brover', 'condition');
link('m-brover', 'then', 'm-stop', 'exec');
// 6) Drawing the piece: the four cell objects are moved to where the piece is. This is the only
// place that touches them, so where they stand *is* where the piece is – which is what the
// landing below reads back instead of working the shape out a second time.
strand();
at('d-loop', 'Repeat', 0, 0, { times: 4 });
flow('m-rot', 'd-loop');
flow('n-has', 'd-loop');
const drawn = cellOfRound(
'd',
{ loop: 'd-loop', shapeVar: 'shape', rotVar: 'rot', xVar: 'px', yVar: 'py' },
1,
4,
);
const pieceObject = objectOfRound('d-p', 'd-loop', 'p', 9, 0);
at('d-x', 'Multiply', 9, 4, { b: CELL });
at('d-atx', 'Add', 10, 4, { b: CELL_X(0) });
at('d-y', 'Multiply', 9, 6, { b: CELL });
at('d-aty', 'Add', 10, 6, { b: CELL_Y(0) });
link(drawn.cellX, 'result', 'd-x', 'a');
link('d-x', 'result', 'd-atx', 'a');
link(drawn.cellY, 'result', 'd-y', 'a');
link('d-y', 'result', 'd-aty', 'a');
get('d-shapec', 'shape', 12, 8);
get('d-colors', 'colors', 12, 10);
at('d-offset', 'Multiply', 13, 8, { b: 8 });
at('d-hex', 'Substring', 14, 9, { count: 8 });
at('d-color', 'ToNumber', 15, 9);
at('d-show', 'SetEnabled', 12, 0, { enabled: true });
at('d-place', 'SetPosition', 13, 0);
at('d-paint', 'SetProperty', 14, 0, { property: 'color' });
link('d-loop', 'body', 'd-show', 'exec');
link(pieceObject, 'object', 'd-show', 'object');
link(pieceObject, 'object', 'd-place', 'object');
link(pieceObject, 'object', 'd-paint', 'object');
link('d-atx', 'result', 'd-place', 'x');
link('d-aty', 'result', 'd-place', 'y');
link('d-shapec', 'value', 'd-offset', 'a');
link('d-colors', 'value', 'd-hex', 'text');
link('d-offset', 'result', 'd-hex', 'start');
link('d-hex', 'text', 'd-color', 'value');
link('d-color', 'result', 'd-paint', 'value');
flow('d-show', 'd-place', 'd-paint');
// 7) Landing: each of the four cell objects writes its digit into the board text, read back from
// where it stands.
strand();
at('l-loop', 'Repeat', 0, 0, { times: 4 });
link('m-brover', 'else', 'l-loop', 'exec');
const landed = objectOfRound('l-p', 'l-loop', 'p', 1, 4);
at('l-pos', 'GetPosition', 4, 4);
at('l-offx', 'Add', 5, 3, { b: -CELL_X(0) });
at('l-offy', 'Add', 5, 6, { b: -CELL_Y(0) });
at('l-col', 'Divide', 6, 3, { b: CELL });
at('l-rowof', 'Divide', 6, 6, { b: CELL });
at('l-colr', 'Round', 7, 3);
at('l-rowr', 'Round', 7, 6);
link(landed, 'object', 'l-pos', 'object');
link('l-pos', 'x', 'l-offx', 'a');
link('l-pos', 'y', 'l-offy', 'a');
link('l-offx', 'result', 'l-col', 'a');
link('l-offy', 'result', 'l-rowof', 'a');
link('l-col', 'result', 'l-colr', 'value');
link('l-rowof', 'result', 'l-rowr', 'value');
at('l-row10', 'Multiply', 8, 6, { b: COLS });
at('l-index', 'Add', 9, 4);
at('l-next', 'Add', 10, 8, { b: 1 });
get('l-grid', 'grid', 9, 10);
get('l-shape', 'shape', 9, 12);
at('l-digit', 'ToText', 10, 12, { decimals: 0 });
at('l-before', 'Substring', 10, 6);
at('l-after', 'Substring', 11, 8, { count: -1 });
at('l-joined', 'Join', 11, 10);
at('l-whole', 'Join', 12, 10);
set('l-setgrid', 'grid', 13, 0);
link('l-rowr', 'result', 'l-row10', 'a');
link('l-row10', 'result', 'l-index', 'a');
link('l-colr', 'result', 'l-index', 'b');
link('l-index', 'result', 'l-next', 'a');
link('l-grid', 'value', 'l-before', 'text');
link('l-index', 'result', 'l-before', 'count');
link('l-grid', 'value', 'l-after', 'text');
link('l-next', 'result', 'l-after', 'start');
link('l-shape', 'value', 'l-digit', 'value');
link('l-before', 'text', 'l-joined', 'a');
link('l-digit', 'text', 'l-joined', 'b');
link('l-joined', 'text', 'l-whole', 'a');
link('l-after', 'text', 'l-whole', 'b');
link('l-whole', 'text', 'l-setgrid', 'value');
link('l-loop', 'body', 'l-setgrid', 'exec');
// 8) Full lines: walk the board from the bottom up. A cleared row does not move the walk on,
// because everything above has dropped into it – so the same row is looked at again.
strand();
set('r-row', 'row', 0, 0, ROWS - 1);
at('r-loop', 'Repeat', 1, 0, { times: ROWS + 4 });
link('l-loop', 'done', 'r-row', 'exec');
flow('r-row', 'r-loop');
get('r-rowvar', 'row', 2, 3);
at('r-left', 'Compare', 3, 3, { op: '>=', b: 0 });
at('r-brleft', 'Branch', 3, 0);
at('r-start', 'Multiply', 4, 3, { b: COLS });
get('r-grid', 'grid', 4, 5);
at('r-text', 'Substring', 5, 4, { count: COLS });
at('r-gap', 'FindText', 6, 4, { search: EMPTY_CELL });
at('r-full', 'Compare', 7, 4, { op: '<', b: 0 });
at('r-brfull', 'Branch', 5, 0);
link('r-loop', 'body', 'r-brleft', 'exec');
link('r-rowvar', 'value', 'r-left', 'a');
link('r-left', 'result', 'r-brleft', 'condition');
link('r-rowvar', 'value', 'r-start', 'a');
link('r-grid', 'value', 'r-text', 'text');
link('r-start', 'result', 'r-text', 'start');
link('r-text', 'text', 'r-gap', 'text');
link('r-gap', 'index', 'r-full', 'a');
link('r-brleft', 'then', 'r-brfull', 'exec');
link('r-full', 'result', 'r-brfull', 'condition');
at('r-above', 'Substring', 6, 7);
at('r-below', 'Substring', 6, 9, { count: -1 });
at('r-past', 'Add', 5, 9, { b: COLS });
at('r-top', 'Join', 7, 7, { a: EMPTY_ROW });
at('r-rest', 'Join', 8, 8);
set('r-setgrid', 'grid', 7, 0);
get('r-lines', 'lines', 8, 3);
at('r-onemore', 'Add', 9, 3, { b: 1 });
set('r-setlines', 'lines', 9, 0);
link('r-brfull', 'then', 'r-setgrid', 'exec');
link('r-grid', 'value', 'r-above', 'text');
link('r-start', 'result', 'r-above', 'count');
link('r-start', 'result', 'r-past', 'a');
link('r-grid', 'value', 'r-below', 'text');
link('r-past', 'result', 'r-below', 'start');
link('r-above', 'text', 'r-top', 'b');
link('r-top', 'text', 'r-rest', 'a');
link('r-below', 'text', 'r-rest', 'b');
link('r-rest', 'text', 'r-setgrid', 'value');
link('r-lines', 'value', 'r-onemore', 'a');
link('r-onemore', 'result', 'r-setlines', 'value');
flow('r-setgrid', 'r-setlines');
at('r-back', 'Subtract', 6, 12, { b: 1 });
set('r-setrow', 'row', 7, 11);
link('r-brfull', 'else', 'r-setrow', 'exec');
link('r-rowvar', 'value', 'r-back', 'a');
link('r-back', 'result', 'r-setrow', 'value');
// 9) The board as it now stands, cell by cell: switched off where the text says empty, and in the
// colour of the shape that came to rest there otherwise.
strand();
at('b-count', 'SetText', 0, 0);
at('b-label', 'ObjectByName', 0, 3, { name: 'LinesLabel' });
get('b-lines', 'lines', 0, 5);
at('b-number', 'ToText', 1, 5, { decimals: 0 });
at('b-caption', 'Join', 2, 5, { a: 'Lines ' });
at('b-loop', 'Repeat', 1, 0, { times: COLS * ROWS });
link('r-loop', 'done', 'b-count', 'exec');
link('b-label', 'object', 'b-count', 'object');
link('b-lines', 'value', 'b-number', 'value');
link('b-number', 'text', 'b-caption', 'b');
link('b-caption', 'text', 'b-count', 'text');
flow('b-count', 'b-loop');
get('b-grid', 'grid', 2, 4);
at('b-cell', 'Substring', 3, 3, { count: 1 });
at('b-empty', 'Equals', 4, 3, { b: EMPTY_CELL });
at('b-filled', 'Not', 5, 3);
const boardObject = objectOfRound('b-c', 'b-loop', 'c', 2, 0);
at('b-show', 'SetEnabled', 6, 0);
at('b-br', 'Branch', 7, 0);
at('b-paint', 'SetProperty', 8, 0, { property: 'color' });
link('b-loop', 'index', 'b-cell', 'start');
link('b-grid', 'value', 'b-cell', 'text');
link('b-cell', 'text', 'b-empty', 'a');
link('b-empty', 'result', 'b-filled', 'value');
link('b-loop', 'body', 'b-show', 'exec');
link(boardObject, 'object', 'b-show', 'object');
link('b-filled', 'result', 'b-show', 'enabled');
link('b-filled', 'result', 'b-br', 'condition');
link(boardObject, 'object', 'b-paint', 'object');
flow('b-show', 'b-br');
link('b-br', 'then', 'b-paint', 'exec');
at('b-digit', 'ToNumber', 8, 4);
get('b-colors', 'colors', 8, 8);
at('b-offset', 'Multiply', 9, 4, { b: 8 });
at('b-hex', 'Substring', 9, 6, { count: 8 });
at('b-color', 'ToNumber', 10, 6);
link('b-cell', 'text', 'b-digit', 'value');
link('b-digit', 'result', 'b-offset', 'a');
link('b-colors', 'value', 'b-hex', 'text');
link('b-offset', 'result', 'b-hex', 'start');
link('b-hex', 'text', 'b-color', 'value');
link('b-color', 'result', 'b-paint', 'value');
// The board is drawn and the piece is part of it now: the next frame draws a new one.
set('b-nopiece', 'hasPiece', 12, 0, false);
link('b-loop', 'done', 'b-nopiece', 'exec');
/** A mistyped id would silently leave an input unconnected, so the seed refuses to write one. */
function checkGraph() {
const ids = new Set();
for (const n of nodes) {
if (ids.has(n.id)) throw new Error(`duplicate node id "${n.id}"`);
ids.add(n.id);
}
for (const e of edges) {
for (const end of [e.from.node, e.to.node]) {
if (!ids.has(end)) throw new Error(`edge ${e.id} points at unknown node "${end}"`);
}
}
return { nodes, edges };
}
const tetrisGraph = checkGraph();
// --- project -----------------------------------------------------------------------------------
const label = (id, name, x, y, text, size, color, align = 'center') =>
sceneObject(id, 'label', { name, x, y, overrides: { text, fontSize: size, color, align } });
const project = {
formatVersion: 2,
id: projectId,
name: 'Tetris',
activeSceneId: 'scene1',
assets: [
{
id: 'scene1',
type: 'scene',
name: 'Tetris',
props: { backgroundColor: BACKGROUND },
objects: [
sceneObject('oboard', 'board', { name: 'Board', x: 150 }),
label('otitle', 'Title', -110, -110, 'TETRIS', 44, WHITE),
label('olines', 'LinesLabel', -110, -40, 'Lines 0', 26, WHITE),
label('ohint1', 'Hint', -110, 60, 'Left / Right · move', 16, HINT),
label('ohint2', 'Hint 2', -110, 84, 'Up · turn', 16, HINT),
label('ohint3', 'Hint 3', -110, 108, 'Down · drop faster', 16, HINT),
sceneObject('ocam', 'cam', { name: 'Main Camera' }),
sceneObject('ogame', 'tetris', { name: 'Tetris', x: -260, y: 150 }),
],
},
{ id: 'cam', type: 'camera', name: 'Main Camera', props: { width: 640, height: 360, zoom: 1 } },
{
id: 'well-sprite',
type: 'sprite',
name: 'Well',
props: { width: COLS * CELL, height: ROWS * CELL, color: WELL },
},
{ id: 'cell-sprite', type: 'sprite', name: 'Cell', props: { width: CELL - 1, height: CELL - 1 } },
{
id: 'label',
type: 'text',
name: 'Label',
props: { text: 'Text', fontSize: 26, color: WHITE, align: 'center' },
},
{ id: 'board', type: 'container', name: 'Board', props: {}, objects: boardTemplate },
{
id: 'tetris',
type: 'behavior',
name: 'Tetris',
props: {
grid: EMPTY_GRID,
shapes: shapeTable(),
colors: colorTable(),
spawnX: spawnColumns(),
shape: 0,
rot: 0,
px: 3,
py: 0,
nx: 0,
ny: 0,
nrot: 0,
kind: '',
ok: true,
hasPiece: false,
fall: 0,
stepTime: 0.5,
lines: 0,
wantDx: 0,
wantRot: 0,
row: 0,
},
variables: [
variable('grid', 'text', EMPTY_GRID),
variable('shapes', 'text', shapeTable()),
variable('colors', 'text', colorTable()),
variable('spawnX', 'text', spawnColumns()),
variable('shape', 'number', 0),
variable('rot', 'number', 0),
variable('px', 'number', 3),
variable('py', 'number', 0),
variable('nx', 'number', 0),
variable('ny', 'number', 0),
variable('nrot', 'number', 0),
variable('kind', 'text', ''),
variable('ok', 'boolean', true),
variable('hasPiece', 'boolean', false),
variable('fall', 'number', 0),
variable('stepTime', 'number', 0.5),
variable('lines', 'number', 0),
variable('wantDx', 'number', 0),
variable('wantRot', 'number', 0),
variable('row', 'number', 0),
],
graph: tetrisGraph,
},
],
};
await writeProject(projectId, baseUrl, project);
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env node
/**
* Plays the Tetris example (tools/seed-tetris.mjs) in a headless browser: a piece appears and
* falls, the arrow keys move and turn it, holding down drops it, a landing writes itself into the
* board, a full line disappears and counts, and a full well ends the run.
*
* It is not part of the e2e suite – like smoke-jumprun.mjs it proves one seeded project, and is
* run when that project or the engine underneath it changes.
*
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
* Screenshots land in /tmp/gm-tetris-*.png.
*/
import { instanceChild } from './lib/builders.mjs';
import { runChecks } from './lib/harness.mjs';
import { PROJECTS, cliArgs } from './lib/projects.mjs';
const { projectId, baseUrl } = cliArgs('smoke-tetris.mjs', PROJECTS.tetris);
const COLS = 10;
const ROWS = 20;
const CELL = 16;
/** The board container, the five texts, the camera and the behavior. */
const STORED_COUNT = 8;
/** Those eight, the board instance keeping its place, plus the well, 200 cells and 4 piece cells. */
const OBJECT_COUNT = STORED_COUNT + 1 + COLS * ROWS + 4;
const GRAPH = { nodes: 219, edges: 248 };
const PIECE = (i) => instanceChild('oboard', `piece${i}`);
const EMPTY_ROW = '.'.repeat(COLS);
const FULL_ROW = '0'.repeat(COLS);
await runChecks({ projectId, baseUrl, port: 9337, shotPrefix: 'gm-tetris' }, async (t) => {
const {
check,
sleep,
evaluate,
json,
shot,
press,
holdKey,
entity,
world,
mutate,
play,
navigate,
exceptions,
} = t;
/** The variables of the Tetris behavior, live from the running world. */
const vars = async () => (await entity('ogame')).props;
const filledCells = (grid) => grid.split('').filter((c) => c !== '.').length;
const pieceCells = async () => {
const w = await world();
return [0, 1, 2, 3].map((i) => w.entities.find((e) => e.objectId === PIECE(i)));
};
/** Where the four cells of the falling piece stand, as board columns and rows. */
const piecePosition = async () => {
const cells = await pieceCells();
return cells.map((c) => ({
column: Math.round((c.x + (COLS * CELL) / 2 - CELL / 2) / CELL),
row: Math.round((c.y + (ROWS * CELL) / 2 - CELL / 2) / CELL),
}));
};
const litCells = async () =>
(await world()).entities.filter((e) => /^c\d+$/.test(e.name) && e.props.enabled !== false).length;
/** Waits for a condition instead of for the clock: a fixed sleep makes the check a coin toss. */
const waitFor = async (condition, ms = 6000) => {
for (let waited = 0; waited < ms; waited += 200) {
if (await condition()) return true;
await sleep(200);
}
return false;
};
/** Writes a behavior variable of the play copy and pushes it into the running world. */
const setVariable = (key, value) =>
mutate(
`__gm.state.activeScene.objects.find((x) => x.id === 'ogame').overrides[${JSON.stringify(key)}] = ${JSON.stringify(value)}`,
);
await navigate();
// 1. The document survived the loader: 219 nodes and 248 edges is the whole game.
const graph = await json(
`(() => { const a = __gm.state.project.assets.find((x) => x.id === 'tetris'); return { nodes: a.graph.nodes.length, edges: a.graph.edges.length }; })()`,
);
check(
'the whole graph loads – no unknown node type, no dropped edge',
graph.nodes === GRAPH.nodes && graph.edges === GRAPH.edges,
JSON.stringify(graph),
);
const stored = await evaluate('__gm.state.activeScene.objects.length');
const listed = await evaluate('__gm.sceneObjects().length');
check(
'the scene stores eight objects and resolves the board out of one of them',
stored === STORED_COUNT && listed === OBJECT_COUNT,
JSON.stringify({ stored, listed }),
);
await shot('01-editor');
// 2. Play: a piece appears, all four of its cells switched on and inside the well.
await play();
await sleep(400);
const spawned = await vars();
const cells = await pieceCells();
check(
'playing puts a piece into the well',
spawned.hasPiece === true && cells.every((c) => c && c.props.enabled === true),
JSON.stringify({ hasPiece: spawned.hasPiece, shape: spawned.shape, px: spawned.px }),
);
const start = await piecePosition();
check(
'its four cells stand inside the board',
start.every((c) => c.column >= 0 && c.column < COLS && c.row >= 0 && c.row < ROWS),
JSON.stringify(start),
);
await shot('02-playing');
// 3. It falls by itself.
await sleep(1300);
const fallen = await piecePosition();
check('the piece falls on its own', fallen[0].row > start[0].row, `row ${start[0].row} → ${fallen[0].row}`);
// 4. Left and right move it by exactly one column per press, up turns it.
const beforeMove = await piecePosition();
await press('ArrowLeft');
await sleep(150);
const moved = await piecePosition();
check(
'the left arrow moves the piece one column',
moved.every((c, i) => c.column === beforeMove[i].column - 1),
`${beforeMove.map((c) => c.column).join(',')} → ${moved.map((c) => c.column).join(',')}`,
);
await press('ArrowRight');
await sleep(150);
const back = await piecePosition();
check(
'the right arrow moves it back',
back.every((c, i) => c.column === beforeMove[i].column),
back.map((c) => c.column).join(','),
);
const beforeTurn = (await vars()).rot;
await press('ArrowUp');
await sleep(150);
const afterTurn = (await vars()).rot;
check(
'the up arrow turns the piece',
afterTurn === (beforeTurn + 1) % 4,
`rot ${beforeTurn} → ${afterTurn}`,
);
await shot('03-moved');
// 5. Holding down drops it, and where it lands is written into the board text.
await setVariable('grid', '.'.repeat(COLS * ROWS));
await holdKey('ArrowDown', 1600);
await sleep(300);
const landedVars = await vars();
check(
'holding the down arrow drops the piece, and the landing is written into the board',
filledCells(landedVars.grid) >= 4,
`${filledCells(landedVars.grid)} cells taken`,
);
check(
'the board draws exactly the cells the board text names',
(await litCells()) === filledCells((await vars()).grid),
JSON.stringify({ lit: await litCells(), inText: filledCells((await vars()).grid) }),
);
await shot('04-landed');
// 6. A full line disappears and counts. The bottom row is filled in beforehand, so whatever the
// piece is and wherever it comes down, landing on top of it completes that line.
await setVariable('lines', 0);
await setVariable('grid', EMPTY_ROW.repeat(ROWS - 1) + FULL_ROW);
await holdKey('ArrowDown', 2200);
await sleep(400);
const cleared = await vars();
check(
'a full line disappears and is counted',
cleared.lines === 1,
`lines ${cleared.lines}, bottom row "${cleared.grid.slice((ROWS - 1) * COLS)}"`,
);
check(
'the counter on screen says so too',
Boolean(await evaluate(`Boolean(__gm.locate('Lines 1'))`)),
'Lines 1',
);
const bottom = cleared.grid.slice((ROWS - 1) * COLS);
check(
'the row that was full is gone, and what stood on it dropped into its place',
bottom.includes('.') && bottom !== EMPTY_ROW,
`bottom row "${bottom}", ${filledCells(cleared.grid)} cells taken in all`,
);
await shot('05-line-cleared');
// 7. A well filled to the top ends the run: the new piece cannot come down even once.
await setVariable('grid', EMPTY_ROW + FULL_ROW.repeat(ROWS - 1));
await setVariable('hasPiece', false);
await waitFor(async () => (await evaluate('__gm.state.mode')) === 'edit');
const mode = await evaluate('__gm.state.mode');
const over = mode === 'play' ? await vars() : null;
check(
'a piece that cannot fall at the very top stops the game',
mode === 'edit',
over
? JSON.stringify({
mode,
py: over.py,
hasPiece: over.hasPiece,
kind: over.kind,
ok: over.ok,
lines: over.lines,
top: over.grid.slice(0, 30),
})
: 'stopped',
);
await shot('06-game-over');
check('no exceptions during the run', exceptions().length === 0, exceptions().slice(0, 3).join(' | '));
});