From 558365d33f91700584b7ebcf8ba4cd173dd4dd8a Mon Sep 17 00:00:00 2001 From: root Date: Wed, 9 Sep 2026 21:27:01 +0200 Subject: [PATCH] Rebuild Tetris as ten behaviors that pass values around The board is a list of 200 numbers now instead of 200 characters, so a cell is Item at and a landing is Set item instead of seven nodes of string surgery. The bigger change is that the one 219-node graph is gone. Each step of a frame is a behavior of its own - Spawn piece, Next move, Fits, Draw piece, Lock piece, Clear lines, Show lines, Draw board, plus Piece cell for where one of the four cells of a piece sits. None of them holds state: what they need arrives through their inputs, every answer leaves through their outputs. That is what lets Fits answer for turning, moving and falling alike, and Piece cell serve the fit test, the drawing and the landing instead of being the same twenty nodes three times. Tetris itself is down to 84 nodes and only says in which order the steps run and which variable each answer lands in. The smoke test holds it to that: it fails if a loop or a list access reappears in the master graph. Co-Authored-By: Claude Opus 5 --- README.md | 37 +- todo-notes/done/tetris-und-data-logik.md | 94 ++ todo-notes/tetris-und-data-logik.md | 7 - tools/seed-tetris.mjs | 1415 +++++++++++++--------- tools/smoke-tetris.mjs | 97 +- 5 files changed, 1022 insertions(+), 628 deletions(-) create mode 100644 todo-notes/done/tetris-und-data-logik.md delete mode 100644 todo-notes/tetris-und-data-logik.md diff --git a/README.md b/README.md index ae97195e..65ea7035 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,13 @@ shown on the node and editable in the inspector. - **Variables** are the behavior's own property schema (`BehaviorAsset.variables`): they appear in the inspector, can be overridden per placed instance, and each running instance - gets its own mutable copy. `Get variable` / `Set variable` read and write them. + gets its own mutable copy. `Get variable` / `Set variable` read and write them. A variable is a + **number, a yes/no, a text or a list**; a list holds numbers, texts, yes/nos or colours (its + item kind decides how the inspector reads the one comma-separated line it is typed as) and is + compared by its items, never by identity – otherwise every reload would look like an override. + There is no list *of objects*: a list is stored in the project file and an object is a live + handle that no reload survives. A set of objects is a list of their names, walked with `Repeat` + and `Object by name`. - **Owner** = parent: a placed behavior is usually parented to the object it controls (inspector → Parent, or drag it onto that object in the hierarchy; dashed line in the viewport). The `Owner` node returns it; `Self` returns the behavior itself; `Parent` walks up @@ -102,6 +108,9 @@ shown on the node and editable in the inspector. 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`, `To number`, `Join text`, `Substring`, `Find text`, `Equals`, + lists `List` (a constant), `Fill` (one value repeated – how an empty board is made), `Count`, + `Item at`, `Set item` (a *new* list, the one that came in is left alone), `Sub list`, + `Join lists`, `Index of`, `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` · @@ -580,15 +589,25 @@ and picking one up destroys the container, not just the sprite the collider repo 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 +instead of physics. The board is a **list of 200 numbers**, one per cell – `-1` for empty, +otherwise the shape that came to rest there. A cell is `Item at(grid, row · 10 + column)`, a +landing is `Set item`, 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 graphs address 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. +falling), which is why the game needs only a single "does it fit?" test instead of one per key. + +It is **ten behaviors**, not one graph. `Tetris` owns the state and does none of the work: each +step of a frame is a behavior of its own, called as a node – `Spawn piece`, `Next move`, `Fits`, +`Draw piece`, `Lock piece`, `Clear lines`, `Show lines`, `Draw board`, with `Piece cell` (where +one of the four cells of a piece sits) used by three of them. None of them holds state: what they +need arrives through their inputs and every answer leaves through their outputs, which is what +lets `Fits` answer for turning, moving and falling alike, and `Piece cell` serve the fit test, +the drawing and the landing instead of being the same twenty nodes three times. What is left in +the master graph is the order the steps run in and which variable each answer lands in – it holds +no loop and no list access of its own, which `tools/smoke-tetris.mjs` checks. That script plays +the game in a headless browser (fall, move, turn, drop, land, clear a line, fill the well) – it +is the regression test for loops, lists and behaviors used as nodes. The workspace folder itself is what nginx serves, so `npm run build` is the deployment. diff --git a/todo-notes/done/tetris-und-data-logik.md b/todo-notes/done/tetris-und-data-logik.md new file mode 100644 index 00000000..b0e43f74 --- /dev/null +++ b/todo-notes/done/tetris-und-data-logik.md @@ -0,0 +1,94 @@ +## Tetris Game verbessern + +das ist erst relevant wenn behavior als node funktionieren + +hier ein paar fragen vorweg: +- habe ich gesehen dass es die variablen `grid`, `shapes`, `colors` und `spawnX` gibt. diese sind sehr kryptisch. welche nodes fehlen, bzw. wie kann man das schöner gestalten. +- man könnte jetzt das ganze auch in mehrere behavior strukturell aufsplitten und diese in das `Tetris`-Behavior als Node einbetten + +--- + +## Beantwortet und umgesetzt + +Beide Punkte hatten dieselbe Ursache, und die lag nicht im Tetris-Projekt: **der Engine fehlte +ein Datentyp.** Eine Variable konnte nur Zahl, Text oder Ja/Nein sein (`PropertyValue`), also +musste jede Menge von Werten in einen String kodiert werden. Genau daher kamen deine vier +kryptischen Variablen — nicht aus einer schlechten Wahl beim Bauen. + +### 1. Die kryptischen Variablen: es fehlte die Liste + +`list` ist jetzt eine Variablen- und eine Port-Art. Sie hält Zahlen, Texte, Ja/Neins oder +**Farben** und wird im Inspector als eine kommagetrennte Zeile getippt. + +| Variable | vorher | jetzt | +|---|---|---| +| `grid` | 200-Zeichen-Text, `.` = leer | Liste aus 200 Zahlen, `-1` = leer | +| `shapes` | 224 Ziffern am Stück | Liste aus 224 Zahlen | +| `colors` | `"03066326048763714..."` – Farben als 8-stellig gepaddete Dezimalzahlen | Farbliste: `#2ec9d6, #f5c542, …` | +| `spawnX` | `"3433333"` | `3, 4, 3, 3, 3, 3, 3` | + +Dazu acht neue Nodes, alle allgemein statt Tetris-eigen: **`List`** (Konstante), **`Fill`** +(ein Wert wiederholt – so entsteht ein leeres Brett), **`Count`**, **`Item at`**, **`Set item`**, +**`Sub list`**, **`Join lists`**, **`Index of`**. + +Was das im Graphen ausmacht: eine Zelle lesen war `Substring(grid, i, 1)` + `Equals '.'`, jetzt +`Item at(grid, i) >= 0`. Eine Landung schreiben war **sieben** Nodes (zweimal `Substring`, +zweimal `Join`, `To text`, `Add`), jetzt **einer**: `Set item`. + +**Bewusst nicht gebaut:** eine Liste *von Objekten*. Eine Liste wird in die Projektdatei +geschrieben, ein Objekt ist ein Handle, das keinen Reload überlebt — eine Objektliste wäre die +erste Wertsorte, die beim nächsten Laden still verschwindet. Objekte gehen weiter über Namen +(`Repeat` + `Item at` + `Object by name`), und eine spätere Item-Art `reference` (stabile Id) +wäre reines Hinzufügen, kein Bruch. + +### 2. Aufgesplittet: aus einem Graphen sind zehn geworden + +Erst waren es fünf; auf deinen Einwurf hin habe ich auch die einzelnen **Schritte** eines Frames +zu Behaviors gemacht, die das Master-Behavior nur noch setzt. + +| Behavior | Aufgabe | Nodes | +|---|---|---| +| `Spawn piece` | welcher Stein als nächstes kommt und in welcher Spalte | 5 | +| `Next move` | welchen **einen** Zug dieser Frame versucht | 44 | +| `Fits` | passen die vier Zellen dorthin? | 18 | +| `Draw piece` | den fallenden Stein an seinen Platz stellen | 14 | +| `Lock piece` | die gelandeten vier Zellen ins Brett schreiben | 20 | +| `Clear lines` | volle Reihen entfernen und zählen | 29 | +| `Show lines` | den Zähler auf den Schirm schreiben | 5 | +| `Draw board` | das Brett zeichnen | 11 | +| `Piece cell` | wo eine der vier Zellen eines Steins sitzt | 12 | +| **`Tetris`** | **nur noch: in welcher Reihenfolge, und wohin die Antwort** | **84** (vorher 219) | + +`Piece cell` war der erste Gewinn: dieselben zwanzig Nodes standen **zweimal** im Graphen. Jetzt +einmal — und drei der anderen rufen es auf, Sub-Behaviors im Sub-Behavior. + +**Keins der neun hält Zustand**, genau dein „kann man über input/output lösen". `Next move` ist +das Beispiel dafür: es *bewegt* nichts, es schlägt einen Zug vor und gibt ihn zurück +(`nx, ny, nrot, kind, fall, wantDx, wantRot`). Ein eingelöster Tastenwunsch kommt als `0` +zurück — so wird ein Tastendruck genau einmal ausgegeben, ohne dass das Sub-Behavior die +Variablen des Aufrufers kennt. + +**Woran man misst, dass der Master wirklich nur noch dirigiert:** in seinen 84 Nodes steckt +**keine Schleife und kein Listenzugriff mehr** — kein `Repeat`, `Item at`, `Set item`, +`Get position`, `Set position`, `Set text`. Das prüft der Smoke-Lauf, damit die Arbeit nicht +unbemerkt zurückwandert: + +``` +PASS the master calls every step as a node, and does none of their work itself + calls: spawn-piece, next-move, fits, draw-piece, lock-piece, clear-lines, show-lines, draw-board + work: [] +``` + +`Draw piece`, `Draw board` und `Show lines` haben **keinen End-Node**: sie beantworten nichts, +sie zeichnen nur. + +### Ein Fehlschlag unterwegs + +Der Game-Over-Check fiel um — und die Messung nannte den Grund selbst: `lines: 20`. Meine +Vorlage für „Brunnen voll" bestand aus 19 **vollen** Reihen, die `Clear lines` wegräumte, bevor +der neue Stein überhaupt anstand. Der Check maß also das Aufräumen statt das Spielende. Jetzt +lässt jede Reihe ihre letzte Spalte frei: blockiert, aber nirgends vollständig. + +Und beim Gegenprüfen fiel auf, dass mein erster Persistenz-Test nichts bewies — er lief über den +schema-geführten Weg, während der Filter, den ich repariert hatte, für Container-Instanzen gilt. +Beide Wege sind jetzt einzeln abgedeckt. diff --git a/todo-notes/tetris-und-data-logik.md b/todo-notes/tetris-und-data-logik.md deleted file mode 100644 index d61a1ca4..00000000 --- a/todo-notes/tetris-und-data-logik.md +++ /dev/null @@ -1,7 +0,0 @@ -## Tetris Game verbessern - -das ist erst relevant wenn behavior als node funktionieren - -hier ein paar fragen vorweg: -- habe ich gesehen dass es die variablen `grid`, `shapes`, `colors` und `spawnX` gibt. diese sind sehr kryptisch. welche nodes fehlen, bzw. wie kann man das schöner gestalten. -- man könnte jetzt das ganze auch in mehrere behavior strukturell aufsplitten und diese in das `Tetris`-Behavior als Node einbetten diff --git a/tools/seed-tetris.mjs b/tools/seed-tetris.mjs index 5781a80f..2793de0b 100644 --- a/tools/seed-tetris.mjs +++ b/tools/seed-tetris.mjs @@ -1,18 +1,25 @@ #!/usr/bin/env node /** - * Writes the second example game: Tetris, again built only from assets and one node graph. + * Writes the second example game: Tetris, built only from assets and node graphs. * - * 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. + * The board is a list of 200 numbers, one per cell: -1 for empty, otherwise the shape that came to + * rest there. Reading a cell is `Item at(grid, row·10 + column)` and clearing a full line is an + * empty row joined in front of everything above and below it – with no text encoding in between, + * so the numbers in the inspector are the numbers the game means. * - * What the player sees is a pool of objects the graph addresses by name: `c0…c199` for the board + * The game is ten behaviors rather than one graph. `Tetris` owns the state and does none of the + * work: every step is a behavior of its own, called as a node – `Spawn piece`, `Next move`, + * `Fits`, `Draw piece`, `Lock piece`, `Clear lines`, `Show lines`, `Draw board`, with `Piece cell` + * used by three of them. None of them holds state between calls: everything they need arrives + * through their inputs and every answer leaves through their outputs, which is what lets `Fits` + * serve turning, moving and falling alike. + * + * What the player sees is a pool of objects the graphs address 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 { edge, node, port, sceneObject, variable } from './lib/builders.mjs'; import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs'; const { projectId, baseUrl } = cliArgs('seed-tetris.mjs', PROJECTS.tetris); @@ -26,9 +33,9 @@ const CELL = 16; 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); +/** No shape has this number, so it cannot be mistaken for one that came to rest. */ +const EMPTY = -1; +const EMPTY_GRID = Array.from({ length: COLS * ROWS }, () => EMPTY); // --- colours --------------------------------------------------------------------------------- @@ -37,7 +44,7 @@ const WELL = 0x0d1017; const WHITE = 0xf2f5f7; const HINT = 0x9aa3b2; -/** One per shape, in the order of `SHAPES` below. */ +/** One per shape, in the order of `SHAPES` below – and the `colors` variable as it stands. */ const SHAPE_COLORS = [0x2ec9d6, 0xf5c542, 0xa35fd6, 0x63c74d, 0xe0553f, 0x4a7de0, 0xe08a3f]; // --- the seven shapes ------------------------------------------------------------------------ @@ -122,26 +129,23 @@ const SHAPES = [ 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)`. + * All 28 rotations in one list, eight numbers each (x and y of the four cells), so one rotation + * starts at (shape·4 + rotation)·8 and its n-th cell two numbers further along. */ function shapeTable() { - let table = ''; + const 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(''); + for (const [x, y] of cells) table.push(x, y); 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(''); +const spawnColumns = () => SHAPES.map((s) => Math.floor((COLS - s.size) / 2)); // --- the board container --------------------------------------------------------------------- @@ -164,556 +168,799 @@ const boardTemplate = [ // --- 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. + * One graph under construction. Nodes are laid out in bands, one per strand, so the editor shows a + * behavior as a stack of readable blocks rather than one cloud. */ -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'); +function graphBuilder() { + const nodes = []; + const edges = []; + let band = 0; - 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 at = (id, type, column, row, params = {}, ports) => { + nodes.push(node(id, type, column * 220, band * 1400 + row * 90, params, ports)); + return id; + }; + const link = (from, fromPort, to, toPort) => { + edges.push(edge(`e${edges.length + 1}`, from, fromPort, to, toPort)); + }; - 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 }; + return { + at, + link, + /** Starts the next band. */ + strand: () => band++, + /** Chains exec outputs: `flow(a, b, c)` runs a, then b, then c. */ + flow: (...ids) => { + for (let i = 0; i < ids.length - 1; i++) link(ids[i], 'exec', ids[i + 1], 'exec'); + }, + get: (id, key, column, row) => at(id, 'GetVariable', column, row, { variable: key }), + set: (id, key, column, row, value) => + at(id, 'SetVariable', column, row, value === undefined ? { variable: key } : { variable: key, value }), + /** A mistyped id would silently leave an input unconnected, so the seed refuses to write one. */ + done() { + 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 }; + }, + }; } /** `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'); +function objectOfRound(g, prefix, loop, namePrefix, column, row) { + const text = g.at(`${prefix}-text`, 'ToText', column, row, { decimals: 0 }); + const name = g.at(`${prefix}-name`, 'Join', column + 1, row, { a: namePrefix }); + const object = g.at(`${prefix}-obj`, 'ObjectByName', column + 2, row); + g.link(loop, 'index', text, 'value'); + g.link(text, 'text', name, 'b'); + g.link(name, 'text', object, 'name'); return object; } -// --- the graph --------------------------------------------------------------------------------- +// --- behavior: Piece cell ------------------------------------------------------------------------ -// 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'); +/** + * Where one of the four cells of a piece sits: reads the rotation out of `shapes` and adds the + * piece's own position. Used by the fit test and by the drawing, which is the whole point of it + * being a behavior – the two used to be the same twenty nodes twice. + */ +function pieceCellGraph() { + const g = graphBuilder(); + g.at('start', 'BehaviorStart', 0, 0, {}, [ + port('shapes', 'list'), + port('shape', 'number'), + port('rot', 'number'), + port('corner', 'number'), + port('baseX', 'number'), + port('baseY', 'number'), + ]); -// 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'); + g.at('x4', 'Multiply', 1, 2, { b: 4 }); + g.at('plus', 'Add', 2, 2); + g.at('x8', 'Multiply', 3, 2, { b: 8 }); + g.at('c2', 'Multiply', 1, 5, { b: 2 }); + g.at('ix', 'Add', 4, 2); + g.at('iy', 'Add', 4, 5, { b: 1 }); + g.at('ax', 'ItemAt', 5, 1); + g.at('ay', 'ItemAt', 5, 5); + g.at('sx', 'Add', 6, 1); + g.at('sy', 'Add', 6, 5); + g.at('end', 'BehaviorEnd', 7, 0, {}, [port('x', 'number'), port('y', 'number')]); -// 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 }; + g.link('start', 'shape', 'x4', 'a'); + g.link('x4', 'result', 'plus', 'a'); + g.link('start', 'rot', 'plus', 'b'); + g.link('plus', 'result', 'x8', 'a'); + g.link('start', 'corner', 'c2', 'a'); + g.link('x8', 'result', 'ix', 'a'); + g.link('c2', 'result', 'ix', 'b'); + g.link('ix', 'result', 'iy', 'a'); + g.link('start', 'shapes', 'ax', 'list'); + g.link('ix', 'result', 'ax', 'index'); + g.link('start', 'shapes', 'ay', 'list'); + g.link('iy', 'result', 'ay', 'index'); + g.link('ax', 'item', 'sx', 'a'); + g.link('start', 'baseX', 'sx', 'b'); + g.link('ay', 'item', 'sy', 'a'); + g.link('start', 'baseY', 'sy', 'b'); + g.link('sx', 'result', 'end', 'x'); + g.link('sy', 'result', 'end', 'y'); + g.flow('start', 'end'); + return g.done(); } -const tetrisGraph = checkGraph(); +// --- behavior: Fits ------------------------------------------------------------------------------- + +/** + * Whether the four cells of a piece may stand where it is asked to: inside the well, and on empty + * board cells. Every attempt to turn, move or drop goes through this one answer. + */ +function fitsGraph() { + const g = graphBuilder(); + g.at('start', 'BehaviorStart', 0, 0, {}, [ + port('shapes', 'list'), + port('grid', 'list'), + port('shape', 'number'), + port('rot', 'number'), + port('x', 'number'), + port('y', 'number'), + ]); + g.set('ok', 'ok', 1, 0, true); + g.at('loop', 'Repeat', 2, 0, { times: 4 }); + g.flow('start', 'ok', 'loop'); + + g.at('cell', 'RunBehavior', 3, 0, { behavior: 'piece-cell' }); + g.link('loop', 'body', 'cell', 'exec'); + for (const key of ['shapes', 'shape', 'rot']) g.link('start', key, 'cell', key); + g.link('loop', 'index', 'cell', 'corner'); + g.link('start', 'x', 'cell', 'baseX'); + g.link('start', 'y', 'cell', 'baseY'); + + g.at('left', 'Compare', 4, 3, { op: '<', b: 0 }); + g.at('right', 'Compare', 4, 5, { op: '>', b: COLS - 1 }); + g.at('side', 'Or', 5, 4); + g.at('bottom', 'Compare', 4, 7, { op: '>', b: ROWS - 1 }); + g.at('outside', 'Or', 6, 5); + g.link('cell', 'x', 'left', 'a'); + g.link('cell', 'x', 'right', 'a'); + g.link('left', 'result', 'side', 'a'); + g.link('right', 'result', 'side', 'b'); + g.link('cell', 'y', 'bottom', 'a'); + g.link('side', 'result', 'outside', 'a'); + g.link('bottom', 'result', 'outside', 'b'); + + // A cell outside the board reads as empty here, but the check above has already caught it. + g.at('row', 'Multiply', 4, 9, { b: COLS }); + g.at('index', 'Add', 5, 9); + g.at('taken', 'ItemAt', 6, 9); + g.at('busy', 'Compare', 7, 9, { op: '>=', b: 0 }); + g.at('blocked', 'Or', 8, 6); + g.at('br', 'Branch', 9, 0); + g.set('no', 'ok', 10, 0, false); + g.link('cell', 'y', 'row', 'a'); + g.link('row', 'result', 'index', 'a'); + g.link('cell', 'x', 'index', 'b'); + g.link('start', 'grid', 'taken', 'list'); + g.link('index', 'result', 'taken', 'index'); + g.link('taken', 'item', 'busy', 'a'); + g.link('outside', 'result', 'blocked', 'a'); + g.link('busy', 'result', 'blocked', 'b'); + g.link('cell', 'exec', 'br', 'exec'); + g.link('blocked', 'result', 'br', 'condition'); + g.link('br', 'then', 'no', 'exec'); + + g.at('end', 'BehaviorEnd', 11, 0, {}, [port('ok', 'boolean')]); + g.get('result', 'ok', 10, 3); + g.link('loop', 'done', 'end', 'exec'); + g.link('result', 'value', 'end', 'ok'); + return g.done(); +} + +// --- behavior: Clear lines ------------------------------------------------------------------------- + +/** + * Removes every full row and counts it. Walks 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, which is how two full rows on top of each other both go. + */ +function clearLinesGraph() { + const g = graphBuilder(); + g.at('start', 'BehaviorStart', 0, 0, {}, [port('grid', 'list'), port('lines', 'number')]); + g.set('start-board', 'board', 1, 0); + g.set('start-count', 'cleared', 2, 0); + g.set('row', 'row', 3, 0, ROWS - 1); + g.at('loop', 'Repeat', 4, 0, { times: ROWS + 4 }); + g.link('start', 'grid', 'start-board', 'value'); + g.link('start', 'lines', 'start-count', 'value'); + g.flow('start', 'start-board', 'start-count', 'row', 'loop'); + + g.get('rowvar', 'row', 5, 3); + g.at('left', 'Compare', 6, 3, { op: '>=', b: 0 }); + g.at('brleft', 'Branch', 6, 0); + g.at('rowstart', 'Multiply', 7, 3, { b: COLS }); + g.get('grid', 'board', 7, 5); + g.at('cells', 'SubList', 8, 4, { count: COLS }); + g.at('gap', 'IndexOf', 9, 4, { value: EMPTY }); + g.at('full', 'Compare', 10, 4, { op: '<', b: 0 }); + g.at('brfull', 'Branch', 8, 0); + g.link('loop', 'body', 'brleft', 'exec'); + g.link('rowvar', 'value', 'left', 'a'); + g.link('left', 'result', 'brleft', 'condition'); + g.link('rowvar', 'value', 'rowstart', 'a'); + g.link('grid', 'value', 'cells', 'list'); + g.link('rowstart', 'result', 'cells', 'start'); + g.link('cells', 'result', 'gap', 'list'); + g.link('gap', 'index', 'full', 'a'); + g.link('brleft', 'then', 'brfull', 'exec'); + g.link('full', 'result', 'brfull', 'condition'); + + g.at('above', 'SubList', 9, 7, { start: 0 }); + g.at('past', 'Add', 8, 10, { b: COLS }); + g.at('below', 'SubList', 9, 10, { count: COLS * ROWS }); + g.at('blank', 'Fill', 9, 12, { value: EMPTY, count: COLS }); + g.at('top', 'Concat', 10, 8); + g.at('rest', 'Concat', 11, 9); + g.set('setgrid', 'board', 10, 0); + g.get('lines', 'cleared', 11, 3); + g.at('onemore', 'Add', 12, 3, { b: 1 }); + g.set('setlines', 'cleared', 12, 0); + g.link('brfull', 'then', 'setgrid', 'exec'); + g.link('grid', 'value', 'above', 'list'); + g.link('rowstart', 'result', 'above', 'count'); + g.link('rowstart', 'result', 'past', 'a'); + g.link('grid', 'value', 'below', 'list'); + g.link('past', 'result', 'below', 'start'); + g.link('blank', 'result', 'top', 'a'); + g.link('above', 'result', 'top', 'b'); + g.link('top', 'result', 'rest', 'a'); + g.link('below', 'result', 'rest', 'b'); + g.link('rest', 'result', 'setgrid', 'value'); + g.link('lines', 'value', 'onemore', 'a'); + g.link('onemore', 'result', 'setlines', 'value'); + g.flow('setgrid', 'setlines'); + + g.at('back', 'Subtract', 9, 14, { b: 1 }); + g.set('setrow', 'row', 10, 13); + g.link('brfull', 'else', 'setrow', 'exec'); + g.link('rowvar', 'value', 'back', 'a'); + g.link('back', 'result', 'setrow', 'value'); + + g.at('end', 'BehaviorEnd', 13, 0, {}, [port('grid', 'list'), port('lines', 'number')]); + g.get('outgrid', 'board', 12, 6); + g.get('outlines', 'cleared', 12, 8); + g.link('loop', 'done', 'end', 'exec'); + g.link('outgrid', 'value', 'end', 'grid'); + g.link('outlines', 'value', 'end', 'lines'); + return g.done(); +} + +// --- behavior: Draw board --------------------------------------------------------------------------- + +/** + * The board as it stands, cell by cell: switched off where the list says empty, in the colour of + * the shape that came to rest there otherwise. No end node – it answers nothing, it only draws. + */ +function drawBoardGraph() { + const g = graphBuilder(); + g.at('start', 'BehaviorStart', 0, 0, {}, [port('grid', 'list'), port('colors', 'list')]); + g.at('loop', 'Repeat', 1, 0, { times: COLS * ROWS }); + g.flow('start', 'loop'); + + g.at('cell', 'ItemAt', 2, 4); + g.at('filled', 'Compare', 3, 4, { op: '>=', b: 0 }); + const object = objectOfRound(g, 'c', 'loop', 'c', 2, 6); + g.at('show', 'SetEnabled', 6, 0); + g.at('br', 'Branch', 7, 0); + g.at('color', 'ItemAt', 7, 4); + g.at('paint', 'SetProperty', 8, 0, { property: 'color' }); + g.link('start', 'grid', 'cell', 'list'); + g.link('loop', 'index', 'cell', 'index'); + g.link('cell', 'item', 'filled', 'a'); + g.link('loop', 'body', 'show', 'exec'); + g.link(object, 'object', 'show', 'object'); + g.link('filled', 'result', 'show', 'enabled'); + g.link('filled', 'result', 'br', 'condition'); + g.link('start', 'colors', 'color', 'list'); + g.link('cell', 'item', 'color', 'index'); + g.link(object, 'object', 'paint', 'object'); + g.link('color', 'item', 'paint', 'value'); + g.flow('show', 'br'); + g.link('br', 'then', 'paint', 'exec'); + return g.done(); +} + +// --- behavior: Spawn piece ----------------------------------------------------------------------- + +/** Which piece appears next and in which column: one of the seven, centred over the well. */ +function spawnPieceGraph() { + const g = graphBuilder(); + g.at('start', 'BehaviorStart', 0, 0, {}, [port('spawnX', 'list')]); + g.at('random', 'Random', 1, 2, { min: 0, max: SHAPES.length }); + g.at('shape', 'Floor', 2, 2); + g.at('column', 'ItemAt', 3, 2); + g.at('end', 'BehaviorEnd', 4, 0, {}, [port('shape', 'number'), port('x', 'number')]); + g.link('random', 'result', 'shape', 'value'); + g.link('start', 'spawnX', 'column', 'list'); + g.link('shape', 'result', 'column', 'index'); + g.link('shape', 'result', 'end', 'shape'); + g.link('column', 'item', 'end', 'x'); + g.flow('start', 'end'); + return g.done(); +} + +// --- behavior: Next move ------------------------------------------------------------------------- + +/** + * The one move a frame attempts, and nothing else: turning beats moving beats falling. It only + * proposes – whether the piece may go there is `Fits`, one step later. A wish that was granted + * comes back as 0, which is how a key press is spent exactly once. + */ +function nextMoveGraph() { + const g = graphBuilder(); + const { at, link, flow, get, set } = g; + + at('start', 'BehaviorStart', 0, 0, {}, [ + port('px', 'number'), + port('py', 'number'), + port('rot', 'number'), + port('wantDx', 'number'), + port('wantRot', 'number'), + port('fall', 'number'), + port('dt', 'number'), + port('stepTime', 'number'), + ]); + + // The candidate starts out as where the piece already is, and no wish is spent yet. + set('i-nx', 'nx', 1, 0); + set('i-ny', 'ny', 2, 0); + set('i-nrot', 'nrot', 3, 0); + set('i-kind', 'kind', 4, 0, ''); + set('i-fall', 'outFall', 5, 0); + set('i-dx', 'outDx', 6, 0); + set('i-rot', 'outRot', 7, 0); + link('start', 'px', 'i-nx', 'value'); + link('start', 'py', 'i-ny', 'value'); + link('start', 'rot', 'i-nrot', 'value'); + link('start', 'fall', 'i-fall', 'value'); + link('start', 'wantDx', 'i-dx', 'value'); + link('start', 'wantRot', 'i-rot', 'value'); + flow('start', 'i-nx', 'i-ny', 'i-nrot', 'i-kind', 'i-fall', 'i-dx', 'i-rot', 't-br'); + + // Turning wins: a quarter turn clockwise is one step around the four rotations of the shape. + g.strand(); + at('t-want', 'Compare', 0, 3, { op: '>', b: 0 }); + at('t-br', 'Branch', 0, 0); + at('t-plus1', 'Add', 1, 3, { b: 1 }); + at('t-wrap', 'Modulo', 2, 3, { b: 4 }); + set('t-set', 'nrot', 1, 0); + set('t-kind', 'kind', 2, 0, 'turn'); + set('t-used', 'outRot', 3, 0, 0); + link('start', 'wantRot', 't-want', 'a'); + link('t-want', 'result', 't-br', 'condition'); + link('t-br', 'then', 't-set', 'exec'); + link('start', 'rot', 't-plus1', 'a'); + link('t-plus1', 'result', 't-wrap', 'a'); + link('t-wrap', 'result', 't-set', 'value'); + flow('t-set', 't-kind', 't-used', 'end'); + + // Then sideways, one column per key press. + at('m-still', 'Equals', 4, 3, { b: 0 }); + at('m-moving', 'Not', 5, 3); + at('m-br', 'Branch', 5, 0); + at('m-shift', 'Add', 6, 3); + set('m-set', 'nx', 6, 0); + set('m-kind', 'kind', 7, 0, 'move'); + set('m-used', 'outDx', 8, 0, 0); + link('t-br', 'else', 'm-br', 'exec'); + link('start', 'wantDx', 'm-still', 'a'); + link('m-still', 'result', 'm-moving', 'value'); + link('m-moving', 'result', 'm-br', 'condition'); + link('m-br', 'then', 'm-set', 'exec'); + link('start', 'px', 'm-shift', 'a'); + link('start', 'wantDx', 'm-shift', 'b'); + link('m-shift', 'result', 'm-set', 'value'); + flow('m-set', 'm-kind', 'm-used', 'end'); + + // And otherwise the clock: holding the down key lets it run ten times as fast. + g.strand(); + at('f-down', 'KeyIsDown', 0, 4, { key: 'ArrowDown' }); + at('f-boost', 'Multiply', 1, 4, { b: 9 }); + at('f-rate', 'Add', 2, 4, { a: 1 }); + at('f-step', 'Multiply', 3, 3); + at('f-add', 'Add', 4, 3); + set('f-set', 'outFall', 0, 0); + link('m-br', 'else', 'f-set', 'exec'); + link('f-down', 'down', 'f-boost', 'a'); + link('f-boost', 'result', 'f-rate', 'b'); + link('start', 'dt', 'f-step', 'a'); + link('f-rate', 'result', 'f-step', 'b'); + link('start', 'fall', 'f-add', 'a'); + link('f-step', 'result', 'f-add', 'b'); + link('f-add', 'result', 'f-set', 'value'); + + get('f-fall', 'outFall', 5, 3); + at('f-due', 'Compare', 6, 3, { op: '>=' }); + at('f-br', 'Branch', 5, 0); + at('f-minus', 'Subtract', 7, 3); + set('f-rest', 'outFall', 6, 0); + at('f-down1', 'Add', 7, 5, { b: 1 }); + set('f-ny', 'ny', 7, 0); + set('f-kind', 'kind', 8, 0, 'fall'); + flow('f-set', 'f-br'); + link('f-fall', 'value', 'f-due', 'a'); + link('start', 'stepTime', 'f-due', 'b'); + link('f-due', 'result', 'f-br', 'condition'); + link('f-br', 'then', 'f-rest', 'exec'); + link('f-fall', 'value', 'f-minus', 'a'); + link('start', 'stepTime', 'f-minus', 'b'); + link('f-minus', 'result', 'f-rest', 'value'); + link('start', 'py', 'f-down1', 'a'); + link('f-down1', 'result', 'f-ny', 'value'); + flow('f-rest', 'f-ny', 'f-kind', 'end'); + // Not due yet: nothing moves, and "kind" stays empty so the fit test below changes nothing. + link('f-br', 'else', 'end', 'exec'); + + // Whatever the frame decided is handed back – the caller owns the variables, not this behavior. + g.strand(); + at('end', 'BehaviorEnd', 2, 0, {}, [ + port('nx', 'number'), + port('ny', 'number'), + port('nrot', 'number'), + port('kind', 'text'), + port('fall', 'number'), + port('wantDx', 'number'), + port('wantRot', 'number'), + ]); + const back = [ + ['e-nx', 'nx', 'nx'], + ['e-ny', 'ny', 'ny'], + ['e-nrot', 'nrot', 'nrot'], + ['e-kind', 'kind', 'kind'], + ['e-fall', 'outFall', 'fall'], + ['e-dx', 'outDx', 'wantDx'], + ['e-rot', 'outRot', 'wantRot'], + ]; + back.forEach(([id, key, portKey], i) => { + get(id, key, 0, i * 2 + 2); + link(id, 'value', 'end', portKey); + }); + return g.done(); +} + +// --- behavior: Draw piece ------------------------------------------------------------------------ + +/** + * Moves the four cell objects of the falling piece 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 `Lock piece` + * reads back instead of working the shape out a second time. + */ +function drawPieceGraph() { + const g = graphBuilder(); + const { at, link, flow } = g; + + at('start', 'BehaviorStart', 0, 0, {}, [ + port('shapes', 'list'), + port('shape', 'number'), + port('rot', 'number'), + port('px', 'number'), + port('py', 'number'), + port('colors', 'list'), + ]); + at('loop', 'Repeat', 1, 0, { times: 4 }); + at('cell', 'RunBehavior', 2, 0, { behavior: 'piece-cell' }); + flow('start', 'loop'); + link('loop', 'body', 'cell', 'exec'); + for (const key of ['shapes', 'shape', 'rot']) link('start', key, 'cell', key); + link('loop', 'index', 'cell', 'corner'); + link('start', 'px', 'cell', 'baseX'); + link('start', 'py', 'cell', 'baseY'); + + const object = objectOfRound(g, 'p', 'loop', 'p', 3, 12); + at('x', 'Multiply', 3, 4, { b: CELL }); + at('atx', 'Add', 4, 4, { b: CELL_X(0) }); + at('y', 'Multiply', 3, 6, { b: CELL }); + at('aty', 'Add', 4, 6, { b: CELL_Y(0) }); + at('color', 'ItemAt', 4, 8); + link('cell', 'x', 'x', 'a'); + link('x', 'result', 'atx', 'a'); + link('cell', 'y', 'y', 'a'); + link('y', 'result', 'aty', 'a'); + link('start', 'colors', 'color', 'list'); + link('start', 'shape', 'color', 'index'); + + at('show', 'SetEnabled', 6, 0, { enabled: true }); + at('place', 'SetPosition', 7, 0); + at('paint', 'SetProperty', 8, 0, { property: 'color' }); + link('cell', 'exec', 'show', 'exec'); + for (const id of ['show', 'place', 'paint']) link(object, 'object', id, 'object'); + link('atx', 'result', 'place', 'x'); + link('aty', 'result', 'place', 'y'); + link('color', 'item', 'paint', 'value'); + flow('show', 'place', 'paint'); + return g.done(); +} + +// --- behavior: Lock piece ------------------------------------------------------------------------ + +/** Writes the four cells of the landed piece into the board, read back from where they stand. */ +function lockPieceGraph() { + const g = graphBuilder(); + const { at, link, flow, get, set } = g; + + at('start', 'BehaviorStart', 0, 0, {}, [port('grid', 'list'), port('shape', 'number')]); + set('take', 'board', 1, 0); + at('loop', 'Repeat', 2, 0, { times: 4 }); + link('start', 'grid', 'take', 'value'); + flow('start', 'take', 'loop'); + + const object = objectOfRound(g, 'p', 'loop', 'p', 3, 4); + at('pos', 'GetPosition', 6, 4); + at('offx', 'Add', 7, 3, { b: -CELL_X(0) }); + at('offy', 'Add', 7, 6, { b: -CELL_Y(0) }); + at('col', 'Divide', 8, 3, { b: CELL }); + at('rowof', 'Divide', 8, 6, { b: CELL }); + at('colr', 'Round', 9, 3); + at('rowr', 'Round', 9, 6); + link(object, 'object', 'pos', 'object'); + link('pos', 'x', 'offx', 'a'); + link('pos', 'y', 'offy', 'a'); + link('offx', 'result', 'col', 'a'); + link('offy', 'result', 'rowof', 'a'); + link('col', 'result', 'colr', 'value'); + link('rowof', 'result', 'rowr', 'value'); + + at('row10', 'Multiply', 10, 6, { b: COLS }); + at('index', 'Add', 11, 5); + get('board', 'board', 11, 8); + at('write', 'SetItem', 12, 6); + set('keep', 'board', 3, 0); + link('rowr', 'result', 'row10', 'a'); + link('row10', 'result', 'index', 'a'); + link('colr', 'result', 'index', 'b'); + link('board', 'value', 'write', 'list'); + link('index', 'result', 'write', 'index'); + link('start', 'shape', 'write', 'value'); + link('write', 'result', 'keep', 'value'); + link('loop', 'body', 'keep', 'exec'); + + get('done', 'board', 4, 3); + at('end', 'BehaviorEnd', 4, 0, {}, [port('grid', 'list')]); + link('loop', 'done', 'end', 'exec'); + link('done', 'value', 'end', 'grid'); + return g.done(); +} + +// --- behavior: Show lines ------------------------------------------------------------------------ + +/** The one place that writes the counter on screen. */ +function showLinesGraph() { + const g = graphBuilder(); + g.at('start', 'BehaviorStart', 0, 0, {}, [port('lines', 'number')]); + g.at('number', 'ToText', 1, 3, { decimals: 0 }); + g.at('caption', 'Join', 2, 3, { a: 'Lines ' }); + g.at('label', 'ObjectByName', 1, 5, { name: 'LinesLabel' }); + g.at('write', 'SetText', 3, 0); + g.link('start', 'lines', 'number', 'value'); + g.link('number', 'text', 'caption', 'b'); + g.link('caption', 'text', 'write', 'text'); + g.link('label', 'object', 'write', 'object'); + g.flow('start', 'write'); + return g.done(); +} + +// --- behavior: Tetris --------------------------------------------------------------------------- + +/** + * The master graph. It owns the state and does none of the work: every step below is a behavior of + * its own, called as a node. What is left here is the order the steps run in and which variable + * each answer lands in. + */ +function tetrisGraph() { + const g = graphBuilder(); + const { at, link, flow, get, set, strand } = g; + + // 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); + at('s-empty', 'Fill', 0, 3, { value: EMPTY, count: COLS * ROWS }); + set('s-grid', 'grid', 1, 0); + 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-show', 'RunBehavior', 7, 0, { behavior: 'show-lines', lines: 0 }); + link('s-empty', 'result', 's-grid', 'value'); + flow('s-start', 's-grid', 's-lines', 's-piece', 's-fall', 's-turn', 's-dx', 's-show'); + + // 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 attempts one move. + 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: ask for one and write down what came back. + at('n-spawn', 'RunBehavior', 2, 5, { behavior: 'spawn-piece' }); + get('n-columns', 'spawnX', 2, 8); + set('n-shape', 'shape', 3, 5); + set('n-rot', 'rot', 4, 5, 0); + set('n-px', 'px', 5, 5); + set('n-py', 'py', 6, 5, 0); + set('n-fall', 'fall', 7, 5, 0); + set('n-has', 'hasPiece', 8, 5, true); + link('u-br', 'else', 'n-spawn', 'exec'); + link('n-columns', 'value', 'n-spawn', 'spawnX'); + link('n-spawn', 'shape', 'n-shape', 'value'); + link('n-spawn', 'x', 'n-px', 'value'); + flow('n-spawn', 'n-shape', 'n-rot', 'n-px', 'n-py', 'n-fall', 'n-has'); + + // 3b) A piece is falling: one behavior decides which single move this frame attempts. + strand(); + at('c-move', 'RunBehavior', 0, 0, { behavior: 'next-move' }); + link('u-br', 'then', 'c-move', 'exec'); + const moveIn = [ + ['px', 'px'], + ['py', 'py'], + ['rot', 'rot'], + ['wantDx', 'wantDx'], + ['wantRot', 'wantRot'], + ['fall', 'fall'], + ['stepTime', 'stepTime'], + ]; + moveIn.forEach(([key, variableKey], i) => { + get(`c-in-${key}`, variableKey, 0, i * 2 + 3); + link(`c-in-${key}`, 'value', 'c-move', key); + }); + link('u-update', 'dt', 'c-move', 'dt'); + + const moveOut = [ + ['nx', 'nx'], + ['ny', 'ny'], + ['nrot', 'nrot'], + ['kind', 'kind'], + ['fall', 'fall'], + ['wantDx', 'wantDx'], + ['wantRot', 'wantRot'], + ]; + moveOut.forEach(([key, variableKey], i) => { + set(`c-out-${key}`, variableKey, i + 1, 0); + link('c-move', key, `c-out-${key}`, 'value'); + }); + flow('c-move', ...moveOut.map(([key]) => `c-out-${key}`)); + + // 4) The one test, and it is one node: does the candidate fit? Every attempt above ends here. + strand(); + at('t-fits', 'RunBehavior', 0, 0, { behavior: 'fits' }); + get('t-shapes', 'shapes', 0, 3); + get('t-grid', 'grid', 0, 5); + get('t-shape', 'shape', 0, 7); + get('t-nrot', 'nrot', 0, 9); + get('t-nx', 'nx', 0, 11); + get('t-ny', 'ny', 0, 13); + flow('c-out-wantRot', 't-fits'); + link('t-shapes', 'value', 't-fits', 'shapes'); + link('t-grid', 'value', 't-fits', 'grid'); + link('t-shape', 'value', 't-fits', 'shape'); + link('t-nrot', 'value', 't-fits', 'rot'); + link('t-nx', 'value', 't-fits', 'x'); + link('t-ny', 'value', 't-fits', 'y'); + + // 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). + 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); + flow('t-fits', 'm-br'); + link('t-fits', 'ok', '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) Wherever the piece ended up – newly spawned or moved – it is drawn there. + strand(); + at('d-draw', 'RunBehavior', 0, 0, { behavior: 'draw-piece' }); + flow('m-rot', 'd-draw'); + flow('n-has', 'd-draw'); + ['shapes', 'shape', 'rot', 'px', 'py', 'colors'].forEach((key, i) => { + get(`d-${key}`, key, 0, i * 2 + 3); + link(`d-${key}`, 'value', 'd-draw', key); + }); + + // 7) It landed: it becomes part of the board, full lines go, the counter and the board follow. + strand(); + at('l-lock', 'RunBehavior', 0, 0, { behavior: 'lock-piece' }); + get('l-grid', 'grid', 0, 3); + get('l-shape', 'shape', 0, 5); + set('l-setgrid', 'grid', 1, 0); + link('m-brover', 'else', 'l-lock', 'exec'); + link('l-grid', 'value', 'l-lock', 'grid'); + link('l-shape', 'value', 'l-lock', 'shape'); + link('l-lock', 'grid', 'l-setgrid', 'value'); + flow('l-lock', 'l-setgrid'); + + at('r-clear', 'RunBehavior', 2, 0, { behavior: 'clear-lines' }); + get('r-grid', 'grid', 2, 3); + get('r-lines', 'lines', 2, 5); + set('r-setgrid', 'grid', 3, 0); + set('r-setlines', 'lines', 4, 0); + link('r-grid', 'value', 'r-clear', 'grid'); + link('r-lines', 'value', 'r-clear', 'lines'); + link('r-clear', 'grid', 'r-setgrid', 'value'); + link('r-clear', 'lines', 'r-setlines', 'value'); + flow('l-setgrid', 'r-clear', 'r-setgrid', 'r-setlines'); + + at('b-show', 'RunBehavior', 5, 0, { behavior: 'show-lines' }); + get('b-lines', 'lines', 5, 3); + at('b-draw', 'RunBehavior', 6, 0, { behavior: 'draw-board' }); + get('b-grid', 'grid', 6, 3); + get('b-colors', 'colors', 6, 5); + set('b-nopiece', 'hasPiece', 7, 0, false); + link('b-lines', 'value', 'b-show', 'lines'); + link('b-grid', 'value', 'b-draw', 'grid'); + link('b-colors', 'value', 'b-draw', 'colors'); + // The board is drawn and the piece is part of it now: the next frame draws a new one. + flow('r-setlines', 'b-show', 'b-draw', 'b-nopiece'); + return g.done(); +} // --- project ----------------------------------------------------------------------------------- const label = (id, name, x, y, text, size, color, align = 'center') => sceneObject(id, 'label', { name, x, y, overrides: { text, fontSize: size, color, align } }); +/** A behavior asset whose `props` are exactly the declared variables' defaults. */ +function behavior(id, name, variables, graph) { + const props = {}; + for (const v of variables) props[v.key] = v.default; + return { id, type: 'behavior', name, props, variables, graph }; +} + +const tetrisVariables = [ + variable('grid', 'list', EMPTY_GRID, 'number'), + variable('shapes', 'list', shapeTable(), 'number'), + variable('colors', 'list', SHAPE_COLORS, 'color'), + variable('spawnX', 'list', spawnColumns(), 'number'), + 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('hasPiece', 'boolean', false), + variable('fall', 'number', 0), + variable('stepTime', 'number', 0.5), + variable('lines', 'number', 0), + variable('wantDx', 'number', 0), + variable('wantRot', 'number', 0), +]; + const project = { formatVersion: 2, id: projectId, @@ -751,56 +998,38 @@ const project = { 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), + behavior('piece-cell', 'Piece cell', [], pieceCellGraph()), + behavior('fits', 'Fits', [variable('ok', 'boolean', true)], fitsGraph()), + behavior( + 'clear-lines', + 'Clear lines', + [ + variable('board', 'list', [], 'number'), + variable('cleared', 'number', 0), + variable('row', 'number', 0), + ], + clearLinesGraph(), + ), + behavior('draw-board', 'Draw board', [], drawBoardGraph()), + behavior('spawn-piece', 'Spawn piece', [], spawnPieceGraph()), + behavior( + 'next-move', + 'Next move', + [ 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), + variable('outFall', 'number', 0), + variable('outDx', 'number', 0), + variable('outRot', 'number', 0), ], - graph: tetrisGraph, - }, + nextMoveGraph(), + ), + behavior('draw-piece', 'Draw piece', [], drawPieceGraph()), + behavior('lock-piece', 'Lock piece', [variable('board', 'list', [], 'number')], lockPieceGraph()), + behavior('show-lines', 'Show lines', [], showLinesGraph()), + behavior('tetris', 'Tetris', tetrisVariables, tetrisGraph()), ], }; diff --git a/tools/smoke-tetris.mjs b/tools/smoke-tetris.mjs index 284ee165..db3ad8a7 100644 --- a/tools/smoke-tetris.mjs +++ b/tools/smoke-tetris.mjs @@ -25,12 +25,43 @@ 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 }; +/** The master graph and the nine behaviors the game is split into. */ +const GRAPHS = { + tetris: { nodes: 84, edges: 94 }, + 'spawn-piece': { nodes: 5, edges: 6 }, + 'next-move': { nodes: 44, edges: 63 }, + fits: { nodes: 18, edges: 29 }, + 'draw-piece': { nodes: 14, edges: 26 }, + 'lock-piece': { nodes: 20, edges: 23 }, + 'clear-lines': { nodes: 29, edges: 36 }, + 'show-lines': { nodes: 5, edges: 5 }, + 'draw-board': { nodes: 11, edges: 17 }, + 'piece-cell': { nodes: 12, edges: 19 }, +}; + +/** The steps the master calls. `piece-cell` is missing on purpose: three of these call it. */ +const STEPS = [ + 'spawn-piece', + 'next-move', + 'fits', + 'draw-piece', + 'lock-piece', + 'clear-lines', + 'show-lines', + 'draw-board', +]; + +/** Node types that would mean the master is still doing a step's work itself. */ +const WORK_NODES = ['Repeat', 'ItemAt', 'SetItem', 'GetPosition', 'SetText', 'SetPosition']; const PIECE = (i) => instanceChild('oboard', `piece${i}`); -const EMPTY_ROW = '.'.repeat(COLS); -const FULL_ROW = '0'.repeat(COLS); +/** The board is a list of numbers: -1 is empty, anything else the shape that came to rest. */ +const EMPTY = -1; +/** Named here rather than read from the project: a test that copies the answer proves nothing. */ +const SHAPE_COLORS = [0x2ec9d6, 0xf5c542, 0xa35fd6, 0x63c74d, 0xe0553f, 0x4a7de0, 0xe08a3f]; +const emptyRows = (n) => Array.from({ length: n * COLS }, () => EMPTY); +const fullRows = (n) => Array.from({ length: n * COLS }, () => 0); await runChecks({ projectId, baseUrl, port: 9337, shotPrefix: 'gm-tetris' }, async (t) => { const { @@ -51,7 +82,7 @@ await runChecks({ projectId, baseUrl, port: 9337, shotPrefix: 'gm-tetris' }, asy /** 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 filledCells = (grid) => grid.filter((c) => c !== EMPTY).length; const pieceCells = async () => { const w = await world(); return [0, 1, 2, 3].map((i) => w.entities.find((e) => e.objectId === PIECE(i))); @@ -82,14 +113,26 @@ await runChecks({ projectId, baseUrl, port: 9337, shotPrefix: 'gm-tetris' }, asy 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 }; })()`, + // 1. The document survived the loader: ten graphs, no unknown node type, no dropped edge. + const graphs = await json( + `Object.fromEntries(__gm.state.project.assets.filter((a) => a.type === 'behavior').map((a) => [a.id, { 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), + 'all ten graphs load – no unknown node type, no dropped edge', + Object.entries(GRAPHS).every( + ([id, want]) => graphs[id]?.nodes === want.nodes && graphs[id]?.edges === want.edges, + ), + JSON.stringify(graphs), + ); + const types = await json( + `__gm.state.project.assets.find((a) => a.id === 'tetris').graph.nodes.map((n) => n.type === 'RunBehavior' ? 'run:' + n.params.behavior : n.type)`, + ); + const calls = types.filter((t) => t.startsWith('run:')).map((t) => t.slice(4)); + const work = types.filter((t) => WORK_NODES.includes(t)); + check( + 'the master calls every step as a node, and does none of their work itself', + STEPS.every((id) => calls.includes(id)) && work.length === 0, + JSON.stringify({ calls, work }), ); const stored = await evaluate('__gm.state.activeScene.objects.length'); const listed = await evaluate('__gm.sceneObjects().length'); @@ -153,7 +196,7 @@ await runChecks({ projectId, baseUrl, port: 9337, shotPrefix: 'gm-tetris' }, asy 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 setVariable('grid', emptyRows(ROWS)); await holdKey('ArrowDown', 1600); await sleep(300); const landedVars = await vars(); @@ -167,19 +210,34 @@ await runChecks({ projectId, baseUrl, port: 9337, shotPrefix: 'gm-tetris' }, asy (await litCells()) === filledCells((await vars()).grid), JSON.stringify({ lit: await litCells(), inText: filledCells((await vars()).grid) }), ); + // The colour is the one proof that goes all the way through the colour list: the board is drawn + // by `Draw board`, which picks it with `Item at(colors, cell)`. + const painted = await json( + `(() => { + const grid = __gm.world().entities.find((e) => e.name === 'Tetris').props.grid; + const index = grid.findIndex((c) => c !== -1); + const cell = __gm.world().entities.find((e) => e.name === 'c' + index); + return { index, shape: grid[index], color: cell && cell.props.color }; + })()`, + ); + check( + 'a cell that came to rest is painted in its shape colour, straight out of the colour list', + painted.color === SHAPE_COLORS[painted.shape], + JSON.stringify({ ...painted, expected: SHAPE_COLORS[painted.shape] }), + ); 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 setVariable('grid', [...emptyRows(ROWS - 1), ...fullRows(1)]); 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)}"`, + `lines ${cleared.lines}, bottom row [${cleared.grid.slice((ROWS - 1) * COLS).join(',')}]`, ); check( 'the counter on screen says so too', @@ -189,13 +247,15 @@ await runChecks({ projectId, baseUrl, port: 9337, shotPrefix: 'gm-tetris' }, asy 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`, + bottom.includes(EMPTY) && bottom.some((c) => c !== EMPTY), + `bottom row [${bottom.join(',')}], ${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)); + // 7. A well filled to the top ends the run: the new piece cannot come down even once. Every row + // keeps its last column free – a full row would be cleared away before the piece ever stands. + const blocked = Array.from({ length: COLS }, (_, column) => (column === COLS - 1 ? EMPTY : 0)); + await setVariable('grid', [...emptyRows(1), ...Array.from({ length: ROWS - 1 }, () => blocked).flat()]); await setVariable('hasPiece', false); await waitFor(async () => (await evaluate('__gm.state.mode')) === 'edit'); const mode = await evaluate('__gm.state.mode'); @@ -209,9 +269,8 @@ await runChecks({ projectId, baseUrl, port: 9337, shotPrefix: 'gm-tetris' }, asy py: over.py, hasPiece: over.hasPiece, kind: over.kind, - ok: over.ok, lines: over.lines, - top: over.grid.slice(0, 30), + top: over.grid.slice(0, 30).join(','), }) : 'stopped', );