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', );