#!/usr/bin/env node /** * Writes the second example game: Tetris, built only from assets and node graphs. * * 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. * * 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, port, sceneObject, variable } from './lib/builders.mjs'; import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs'; const { projectId, baseUrl } = cliArgs('seed-tetris.mjs', PROJECTS.tetris); // --- board geometry -------------------------------------------------------------------------- const COLS = 10; const ROWS = 20; const CELL = 16; /** Cell 0 sits at the top left of the board; these turn a column/row into the object's position. */ const CELL_X = (col) => col * CELL - (COLS * CELL) / 2 + CELL / 2; const CELL_Y = (row) => row * CELL - (ROWS * CELL) / 2 + CELL / 2; /** 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 --------------------------------------------------------------------------------- const BACKGROUND = 0x151821; const WELL = 0x0d1017; const WHITE = 0xf2f5f7; const HINT = 0x9aa3b2; /** 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 ------------------------------------------------------------------------ /** * Each shape as its four cells in a box of `size` × `size`, the box being what turning rotates * inside: (x, y) → (size − 1 − y, x) is a quarter turn clockwise. I needs a 4-box, O a 2-box. */ const SHAPES = [ { name: 'I', size: 4, cells: [ [0, 1], [1, 1], [2, 1], [3, 1], ], }, { name: 'O', size: 2, cells: [ [0, 0], [1, 0], [0, 1], [1, 1], ], }, { name: 'T', size: 3, cells: [ [1, 0], [0, 1], [1, 1], [2, 1], ], }, { name: 'S', size: 3, cells: [ [1, 0], [2, 0], [0, 1], [1, 1], ], }, { name: 'Z', size: 3, cells: [ [0, 0], [1, 0], [1, 1], [2, 1], ], }, { name: 'J', size: 3, cells: [ [0, 0], [0, 1], [1, 1], [2, 1], ], }, { name: 'L', size: 3, cells: [ [2, 0], [0, 1], [1, 1], [2, 1], ], }, ]; const turned = (cells, size) => cells.map(([x, y]) => [size - 1 - y, x]); /** * All 28 rotations in one 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() { const table = []; for (const shape of SHAPES) { let cells = shape.cells; for (let rotation = 0; rotation < 4; rotation++) { for (const [x, y] of cells) table.push(x, y); cells = turned(cells, shape.size); } } return table; } /** 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)); // --- the board container --------------------------------------------------------------------- /** The well, the 200 board cells (switched off = empty) and the four cells of the falling piece. */ const boardTemplate = [ sceneObject('well', 'well-sprite', { name: 'Well' }), ...Array.from({ length: COLS * ROWS }, (_, i) => sceneObject(`cell${i}`, 'cell-sprite', { name: `c${i}`, x: CELL_X(i % COLS), y: CELL_Y(Math.floor(i / COLS)), z: 1, overrides: { enabled: false }, }), ), ...Array.from({ length: 4 }, (_, i) => sceneObject(`piece${i}`, 'cell-sprite', { name: `p${i}`, z: 2, overrides: { enabled: false } }), ), ]; // --- graph helpers ----------------------------------------------------------------------------- /** * 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 graphBuilder() { const nodes = []; const edges = []; let band = 0; 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)); }; 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(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; } // --- behavior: Piece cell ------------------------------------------------------------------------ /** * 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'), ]); 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')]); 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(); } // --- 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, name: 'Tetris', activeSceneId: 'scene1', assets: [ { id: 'scene1', type: 'scene', name: 'Tetris', props: { backgroundColor: BACKGROUND }, objects: [ sceneObject('oboard', 'board', { name: 'Board', x: 150 }), label('otitle', 'Title', -110, -110, 'TETRIS', 44, WHITE), label('olines', 'LinesLabel', -110, -40, 'Lines 0', 26, WHITE), label('ohint1', 'Hint', -110, 60, 'Left / Right · move', 16, HINT), label('ohint2', 'Hint 2', -110, 84, 'Up · turn', 16, HINT), label('ohint3', 'Hint 3', -110, 108, 'Down · drop faster', 16, HINT), sceneObject('ocam', 'cam', { name: 'Main Camera' }), sceneObject('ogame', 'tetris', { name: 'Tetris', x: -260, y: 150 }), ], }, { id: 'cam', type: 'camera', name: 'Main Camera', props: { width: 640, height: 360, zoom: 1 } }, { id: 'well-sprite', type: 'sprite', name: 'Well', props: { width: COLS * CELL, height: ROWS * CELL, color: WELL }, }, { id: 'cell-sprite', type: 'sprite', name: 'Cell', props: { width: CELL - 1, height: CELL - 1 } }, { id: 'label', type: 'text', name: 'Label', props: { text: 'Text', fontSize: 26, color: WHITE, align: 'center' }, }, { id: 'board', type: 'container', name: 'Board', props: {}, objects: boardTemplate }, 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('outFall', 'number', 0), variable('outDx', 'number', 0), variable('outRot', 'number', 0), ], 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()), ], }; await writeProject(projectId, baseUrl, project);