#!/usr/bin/env node /** * Writes the Tetris example from assets and graphs only. The board is a list of 200 numbers (-1 empty, * else the resting shape); `piece`/`next` are shape/rot/x/y structs. The `Tetris` master owns the state * and calls stateless behaviors as nodes, which lets `Fits` serve turning, moving and falling alike. * Board and piece are a fixed pool of named objects (`c0…c199`, `p0…p3`); nothing spawns while playing. * * Usage: node tools/seed-tetris.mjs [projectId] [baseUrl] */ import { colorItem, edge, field, node, port, propertiesOf, sceneObject, variable, written, } from './lib/builders.mjs'; import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs'; const { projectId, baseUrl } = cliArgs('seed-tetris.mjs', PROJECTS.tetris); 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); 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]; /** * 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, four cells each, so one rotation starts at (shape·4 + rotation)·4 * and its n-th cell is one entry 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 four numbers that say which piece is where. */ const PIECE_FIELDS = [field('shape'), field('rot'), field('x'), field('y')]; const NO_PIECE = { shape: 0, rot: 0, x: 0, y: 0 }; /** 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 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 } }), ), ]; /** A `sets` entry whose value arrives through an edge instead of being typed on the node. */ const LINKED = Symbol('linked'); /** * 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. `variables` is what the plural nodes * declare their ports from – pass the behavior's own list. */ function graphBuilder(variables = []) { const nodes = []; const edges = []; let band = 0; const kinds = Object.fromEntries(variables.map((v) => [v.key, v.kind])); const declare = (keys, write = false) => keys.map((key) => { if (!kinds[key]) throw new Error(`no variable "${key}" to declare a port for`); return write ? written(key, kinds[key]) : port(key, kinds[key]); }); 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'); }, /** A Variables node reading one variable; its output is named after it. */ get: (id, key, column, row) => at(id, 'Variables', column, row, {}, declare([key])), /** A Variables node writing one variable, typed in as `value` or through its input of that name. */ set: (id, key, column, row, value) => at(id, 'Variables', column, row, value === undefined ? {} : { [key]: value }, declare([key], true)), /** * One node for a whole cluster. A `sets` reads all its inputs before it writes any, so an input * fed from a variable it writes sees the value from before. */ gets: (id, keys, column, row) => at(id, 'Variables', column, row, {}, declare(keys)), sets: (id, spec, column, row) => { const keys = Array.isArray(spec) ? spec : Object.keys(spec); const params = Array.isArray(spec) ? {} : Object.fromEntries(Object.entries(spec).filter(([, v]) => v !== LINKED)); return at(id, 'Variables', column, row, params, declare(keys, true)); }, /** 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; } /** * Where one of the four cells of a piece sits: reads the rotation out of `shapes` and adds the * piece's own position. A behavior so the fit test and the drawing share it. */ function pieceCellGraph() { const g = graphBuilder(); g.at('start', 'BehaviorStart', 0, 0, {}, [ port('shapes', 'list'), port('piece', 'struct'), port('corner', 'number'), ]); // The whole piece arrives as one bundle and is taken apart here, where its parts are needed. g.at('of', 'ExpandStruct', 1, 2, {}, PIECE_FIELDS); g.at('x4', 'Multiply', 2, 2, { b: 4 }); g.at('plus', 'Add', 3, 2); g.at('x4b', 'Multiply', 4, 2, { b: 4 }); g.at('index', 'Add', 5, 2); g.at('cell', 'ItemAt', 6, 2); g.at('at', 'ExpandStruct', 7, 2, {}, [field('x'), field('y')]); g.at('sx', 'Add', 8, 1); g.at('sy', 'Add', 8, 4); g.at('end', 'BehaviorEnd', 9, 0, {}, [port('x', 'number'), port('y', 'number')]); g.link('start', 'piece', 'of', 'value'); g.link('of', 'shape', 'x4', 'a'); g.link('x4', 'result', 'plus', 'a'); g.link('of', 'rot', 'plus', 'b'); g.link('plus', 'result', 'x4b', 'a'); g.link('x4b', 'result', 'index', 'a'); g.link('start', 'corner', 'index', 'b'); g.link('start', 'shapes', 'cell', 'list'); g.link('index', 'result', 'cell', 'index'); g.link('cell', 'item', 'at', 'value'); g.link('at', 'x', 'sx', 'a'); g.link('of', 'x', 'sx', 'b'); g.link('at', 'y', 'sy', 'a'); g.link('of', 'y', 'sy', 'b'); g.link('sx', 'result', 'end', 'x'); g.link('sy', 'result', 'end', 'y'); g.flow('start', 'end'); return g.done(); } /** * 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. */ const FITS_VARS = [variable('ok', 'boolean', true)]; function fitsGraph() { const g = graphBuilder(FITS_VARS); g.at('start', 'BehaviorStart', 0, 0, {}, [ port('shapes', 'list'), port('grid', 'list'), port('piece', 'struct'), ]); 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', 'piece']) g.link('start', key, 'cell', key); g.link('loop', 'index', 'cell', 'corner'); 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', 'ok', 'end', 'ok'); return g.done(); } const CLEAR_LINES_VARS = [ variable('board', 'list', []), variable('cleared', 'number', 0), variable('row', 'number', 0), ]; /** * 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(CLEAR_LINES_VARS); g.at('start', 'BehaviorStart', 0, 0, {}, [port('grid', 'list'), port('lines', 'number')]); g.sets('start-vars', { board: LINKED, cleared: LINKED, row: ROWS - 1 }, 1, 0); g.at('loop', 'Repeat', 4, 0, { times: ROWS + 4 }); g.link('start', 'grid', 'start-vars', 'board'); g.link('start', 'lines', 'start-vars', 'cleared'); g.flow('start', 'start-vars', 'loop'); // Every round reads all three before it writes any of them, and the loop drops the pulse cache // between rounds – so one node serves the whole round. g.gets('row-vars', ['row', 'board', 'cleared'], 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.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('row-vars', 'row', 'left', 'a'); g.link('left', 'result', 'brleft', 'condition'); g.link('row-vars', 'row', 'rowstart', 'a'); g.link('row-vars', 'board', '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.at('onemore', 'Add', 12, 3, { b: 1 }); g.sets('cleared-row', ['board', 'cleared'], 10, 0); g.link('brfull', 'then', 'cleared-row', 'exec'); g.link('row-vars', 'board', 'above', 'list'); g.link('rowstart', 'result', 'above', 'count'); g.link('rowstart', 'result', 'past', 'a'); g.link('row-vars', 'board', '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', 'cleared-row', 'board'); g.link('row-vars', 'cleared', 'onemore', 'a'); g.link('onemore', 'result', 'cleared-row', 'cleared'); g.at('back', 'Subtract', 9, 14, { b: 1 }); g.set('setrow', 'row', 10, 13); g.link('brfull', 'else', 'setrow', 'exec'); g.link('row-vars', 'row', 'back', 'a'); g.link('back', 'result', 'setrow', 'row'); g.at('end', 'BehaviorEnd', 13, 0, {}, [port('grid', 'list'), port('lines', 'number')]); g.gets('out-vars', ['board', 'cleared'], 12, 6); g.link('loop', 'done', 'end', 'exec'); g.link('out-vars', 'board', 'end', 'grid'); g.link('out-vars', 'cleared', 'end', 'lines'); return g.done(); } /** * 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', 'Properties', 6, 0, propertiesOf('sprite'), [written('enabled', 'boolean')]); g.at('br', 'Branch', 7, 0); g.at('color', 'ItemAt', 7, 4); g.at('paint', 'Properties', 8, 0, propertiesOf('sprite'), [written('color', 'number')]); 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', 'color'); g.flow('show', 'br'); g.link('br', 'then', 'paint', 'exec'); return g.done(); } /** 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); // Handed back as a finished piece rather than as parts the caller has to assemble: the two // fields it does not choose are the ones a new piece is defined to start at. g.at('piece', 'PackStruct', 4, 2, { rot: 0, y: 0 }, PIECE_FIELDS); g.at('end', 'BehaviorEnd', 5, 0, {}, [port('piece', 'struct')]); g.link('random', 'result', 'shape', 'value'); g.link('start', 'spawnX', 'column', 'list'); g.link('shape', 'result', 'column', 'index'); g.link('shape', 'result', 'piece', 'shape'); g.link('column', 'item', 'piece', 'x'); g.link('piece', 'value', 'end', 'piece'); g.flow('start', 'end'); return g.done(); } const NEXT_MOVE_VARS = [ variable('next', 'struct', NO_PIECE, PIECE_FIELDS), variable('kind', 'text', ''), variable('outFall', 'number', 0), variable('outDx', 'number', 0), variable('outRot', 'number', 0), ]; /** * 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(NEXT_MOVE_VARS); const { at, link, flow, get, set, sets } = g; at('start', 'BehaviorStart', 0, 0, {}, [ port('piece', 'struct'), port('wantDx', 'number'), port('wantRot', 'number'), port('fall', 'number'), port('dt', 'number'), port('stepTime', 'number'), ]); // The frame starts out having decided nothing: the candidate is the piece itself and no wish has // been used up. Each strand below then packs only the field it changes, because writing a bundle // keeps the fields it does not name – "the same piece, one row lower" needs no copying. at('of', 'ExpandStruct', 1, 3, {}, PIECE_FIELDS); link('start', 'piece', 'of', 'value'); sets('i-vars', { next: LINKED, kind: '', outFall: LINKED, outDx: LINKED, outRot: LINKED }, 1, 0); link('start', 'piece', 'i-vars', 'next'); link('start', 'fall', 'i-vars', 'outFall'); link('start', 'wantDx', 'i-vars', 'outDx'); link('start', 'wantRot', 'i-vars', 'outRot'); flow('start', 'i-vars', '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 }); at('t-pack', 'PackStruct', 3, 3, {}, [field('rot')]); sets('t-vars', { next: LINKED, kind: 'turn', outRot: 0 }, 1, 0); link('start', 'wantRot', 't-want', 'a'); link('t-want', 'result', 't-br', 'condition'); link('t-br', 'then', 't-vars', 'exec'); link('of', 'rot', 't-plus1', 'a'); link('t-plus1', 'result', 't-wrap', 'a'); link('t-wrap', 'result', 't-pack', 'rot'); link('t-pack', 'value', 't-vars', 'next'); flow('t-vars', '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); at('m-pack', 'PackStruct', 7, 3, {}, [field('x')]); sets('m-vars', { next: LINKED, kind: 'move', outDx: 0 }, 6, 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-vars', 'exec'); link('of', 'x', 'm-shift', 'a'); link('start', 'wantDx', 'm-shift', 'b'); link('m-shift', 'result', 'm-pack', 'x'); link('m-pack', 'value', 'm-vars', 'next'); flow('m-vars', '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', 'outFall'); 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); at('f-down1', 'Add', 7, 5, { b: 1 }); at('f-pack', 'PackStruct', 8, 5, {}, [field('y')]); sets('f-vars', { outFall: LINKED, next: LINKED, kind: 'fall' }, 6, 0); flow('f-set', 'f-br'); link('f-fall', 'outFall', 'f-due', 'a'); link('start', 'stepTime', 'f-due', 'b'); link('f-due', 'result', 'f-br', 'condition'); link('f-br', 'then', 'f-vars', 'exec'); link('f-fall', 'outFall', 'f-minus', 'a'); link('start', 'stepTime', 'f-minus', 'b'); link('f-minus', 'result', 'f-vars', 'outFall'); link('of', 'y', 'f-down1', 'a'); link('f-down1', 'result', 'f-pack', 'y'); link('f-pack', 'value', 'f-vars', 'next'); flow('f-vars', '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('next', 'struct'), port('kind', 'text'), port('fall', 'number'), port('wantDx', 'number'), port('wantRot', 'number'), ]); const back = [ ['next', 'next'], ['kind', 'kind'], ['outFall', 'fall'], ['outDx', 'wantDx'], ['outRot', 'wantRot'], ]; g.gets( 'e-vars', back.map(([key]) => key), 0, 2, ); for (const [key, portKey] of back) link('e-vars', key, 'end', portKey); return g.done(); } /** * 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('piece', 'struct'), 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', 'piece']) link('start', key, 'cell', key); link('loop', 'index', 'cell', 'corner'); // Only the shape number is wanted here, for the colour. at('of', 'ExpandStruct', 2, 10, {}, [field('shape')]); link('start', 'piece', 'of', 'value'); 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('of', 'shape', 'color', 'index'); at('place', 'SetPosition', 6, 0); at('paint', 'Properties', 7, 0, { ...propertiesOf('sprite'), enabled: true }, [ written('enabled', 'boolean'), written('color', 'number'), ]); link('cell', 'exec', 'place', 'exec'); for (const id of ['place', 'paint']) link(object, 'object', id, 'object'); link('atx', 'result', 'place', 'x'); link('aty', 'result', 'place', 'y'); link('color', 'item', 'paint', 'color'); flow('place', 'paint'); return g.done(); } /** Writes the four cells of the landed piece into the board, read back from where they stand. */ const LOCK_PIECE_VARS = [variable('board', 'list', [])]; function lockPieceGraph() { const g = graphBuilder(LOCK_PIECE_VARS); 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', 'board'); 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', 'board', 'write', 'list'); link('index', 'result', 'write', 'index'); link('start', 'shape', 'write', 'value'); link('write', 'result', 'keep', 'board'); 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', 'board', 'end', 'grid'); return g.done(); } /** 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', 'Properties', 3, 0, propertiesOf('text'), [written('text', 'text')]); 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(); } /** * 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(tetrisVariables); const { at, link, flow, get, set, gets, sets, 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 }); sets('s-vars', { grid: LINKED, lines: 0, hasPiece: false, fall: 0, wantDx: 0, wantRot: 0 }, 1, 0); at('s-show', 'RunBehavior', 2, 0, { behavior: 'show-lines', lines: 0 }); link('s-empty', 'result', 's-vars', 'grid'); flow('s-start', 's-vars', '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', 'hasPiece', '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); sets('n-vars', { piece: LINKED, fall: 0, hasPiece: true }, 3, 5); link('u-br', 'else', 'n-spawn', 'exec'); link('n-columns', 'spawnX', 'n-spawn', 'spawnX'); link('n-spawn', 'piece', 'n-vars', 'piece'); flow('n-spawn', 'n-vars'); // 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'); // Every port of the call is a variable of the same name, so one node stands at each side of it. const moveIn = ['piece', 'wantDx', 'wantRot', 'fall', 'stepTime']; gets('c-in', moveIn, 0, 3); for (const key of moveIn) link('c-in', key, 'c-move', key); link('u-update', 'dt', 'c-move', 'dt'); const moveOut = ['next', 'kind', 'fall', 'wantDx', 'wantRot']; sets('c-out', moveOut, 1, 0); for (const key of moveOut) link('c-move', key, 'c-out', key); flow('c-move', 'c-out'); // 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' }); gets('t-in', ['shapes', 'grid', 'next'], 0, 3); flow('c-out', 't-fits'); link('t-in', 'shapes', 't-fits', 'shapes'); link('t-in', 'grid', 't-fits', 'grid'); link('t-in', 'next', 't-fits', 'piece'); // 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); // `piece` is not in here: it is written a line below, and `m-py2` has to see that write. gets('m-in', ['next', 'kind'], 2, 3); set('m-piece', 'piece', 2, 0); flow('t-fits', 'm-br'); link('t-fits', 'ok', 'm-br', 'condition'); link('m-br', 'then', 'm-piece', 'exec'); link('m-in', 'next', 'm-piece', 'piece'); at('m-landed', 'Equals', 3, 7, { b: 'fall' }); at('m-brland', 'Branch', 3, 5); get('m-py2', 'piece', 4, 8); at('m-pieceY', 'ExpandStruct', 5, 10, {}, [field('y')]); at('m-attop', 'Compare', 6, 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-in', 'kind', 'm-landed', 'a'); link('m-landed', 'result', 'm-brland', 'condition'); link('m-brland', 'then', 'm-brover', 'exec'); link('m-py2', 'piece', 'm-pieceY', 'value'); link('m-pieceY', 'y', '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-piece', 'd-draw'); flow('n-vars', 'd-draw'); const drawIn = ['shapes', 'piece', 'colors']; gets('d-in', drawIn, 0, 3); for (const key of drawIn) link('d-in', key, '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' }); gets('l-in', ['grid', 'piece'], 0, 3); at('l-shape', 'ExpandStruct', 0, 7, {}, [field('shape')]); set('l-setgrid', 'grid', 1, 0); link('m-brover', 'else', 'l-lock', 'exec'); link('l-in', 'grid', 'l-lock', 'grid'); link('l-in', 'piece', 'l-shape', 'value'); link('l-shape', 'shape', 'l-lock', 'shape'); link('l-lock', 'grid', 'l-setgrid', 'grid'); flow('l-lock', 'l-setgrid'); at('r-clear', 'RunBehavior', 2, 0, { behavior: 'clear-lines' }); gets('r-in', ['grid', 'lines'], 2, 3); sets('r-out', ['grid', 'lines'], 3, 0); link('r-in', 'grid', 'r-clear', 'grid'); link('r-in', 'lines', 'r-clear', 'lines'); link('r-clear', 'grid', 'r-out', 'grid'); link('r-clear', 'lines', 'r-out', 'lines'); flow('l-setgrid', 'r-clear', 'r-out'); at('b-show', 'RunBehavior', 5, 0, { behavior: 'show-lines' }); at('b-draw', 'RunBehavior', 6, 0, { behavior: 'draw-board' }); // A second reader of `grid` and `lines`, because `r-out` wrote them since `r-in` was asked. gets('b-in', ['lines', 'grid', 'colors'], 5, 3); set('b-nopiece', 'hasPiece', 7, 0, false); link('b-in', 'lines', 'b-show', 'lines'); link('b-in', 'grid', 'b-draw', 'grid'); link('b-in', 'colors', 'b-draw', 'colors'); // The board is drawn and the piece is part of it now: the next frame draws a new one. flow('r-out', 'b-show', 'b-draw', 'b-nopiece'); return g.done(); } 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), variable('shapes', 'list', shapeTable()), // Tagged, because a list says nothing about its items and a colour is a number like any other. variable('colors', 'list', SHAPE_COLORS.map(colorItem)), variable('spawnX', 'list', spawnColumns()), // The falling piece and the one move being tried on it – four numbers each, and never apart. variable('piece', 'struct', NO_PIECE, PIECE_FIELDS), variable('next', 'struct', NO_PIECE, PIECE_FIELDS), 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', FITS_VARS, fitsGraph()), behavior('clear-lines', 'Clear lines', CLEAR_LINES_VARS, clearLinesGraph()), behavior('draw-board', 'Draw board', [], drawBoardGraph()), behavior('spawn-piece', 'Spawn piece', [], spawnPieceGraph()), behavior('next-move', 'Next move', NEXT_MOVE_VARS, nextMoveGraph()), behavior('draw-piece', 'Draw piece', [], drawPieceGraph()), behavior('lock-piece', 'Lock piece', LOCK_PIECE_VARS, lockPieceGraph()), behavior('show-lines', 'Show lines', [], showLinesGraph()), behavior('tetris', 'Tetris', tetrisVariables, tetrisGraph()), ], }; await writeProject(projectId, baseUrl, project);