Files
feuerwerk/tools/seed-tetris.mjs
T
rootandClaude Opus 5 f60358c951 Let a list hold structs, and read or write variables in bundles
A list item may now be a struct. The depth is bounded in the declaration
rather than by a comment: FieldKind (number|text|boolean|color) says what a
struct field holds, ListItemKind adds struct on top and says what a list
holds, so list -> struct -> primitive is the deepest storable shape and
sameValue stays finite. A struct item renders inline in the List tab, which
is why it needs no path in the slot address.

Four paths silently assumed a list item was a primitive: sameValue compared
items with ===, textOf would have printed [object Object], asListItem turned
a struct into text on Set item and Fill, and Index of used indexOf, which is
reference equality and could never have found a struct.

Tetris now runs on bundles. shapes is 112 {x, y} structs instead of 224 loose
numbers, piece and the candidate are structs, so Next move hands back a whole
candidate and each strand packs only the field it changes, since mergeStruct
leaves the rest standing. Declared behavior ports drop 49 -> 35, the master's
variables 18 -> 13, its graph 84/94 -> 69/76 nodes/edges.

Get variables and Set variables declare their ports from a list of variable
names, the way Pack and Expand already do, so one node can read or write
several at once. Renaming a variable rewires them, because there the port key
is the variable name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 14:01:56 +02:00

983 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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.
*
* What travels as a bundle rather than as loose numbers: the falling piece (`piece`) and the one
* move being tried on it (`next`) are structs of shape/rot/x/y, and `shapes` is a list of `{x, y}`
* cells. That is what lets `Next move` hand back a whole candidate for `Fits` to judge and the
* master to accept in a single write, and it halves the index arithmetic in `Piece cell`.
*
* 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, field, 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);
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 } }),
),
];
/**
* 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;
}
/**
* 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('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.
*/
function fitsGraph() {
const g = graphBuilder();
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', 'value', 'end', 'ok');
return g.done();
}
/**
* 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();
}
/**
* 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();
}
/** 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();
}
/**
* 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('piece', 'struct'),
port('wantDx', 'number'),
port('wantRot', 'number'),
port('fall', 'number'),
port('dt', 'number'),
port('stepTime', 'number'),
]);
// The candidate starts out as the piece itself – one write, because it is one value. Each
// strand below then packs only the field it changes: writing a bundle keeps the fields it
// does not name, so "the same piece, one row lower" needs no copying.
at('of', 'ExpandStruct', 1, 3, {}, PIECE_FIELDS);
link('start', 'piece', 'of', 'value');
set('i-next', 'next', 1, 0);
set('i-kind', 'kind', 2, 0, '');
set('i-fall', 'outFall', 3, 0);
set('i-dx', 'outDx', 4, 0);
set('i-rot', 'outRot', 5, 0);
link('start', 'piece', 'i-next', 'value');
link('start', 'fall', 'i-fall', 'value');
link('start', 'wantDx', 'i-dx', 'value');
link('start', 'wantRot', 'i-rot', 'value');
flow('start', 'i-next', '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 });
at('t-pack', 'PackStruct', 3, 3, {}, [field('rot')]);
set('t-set', 'next', 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('of', 'rot', 't-plus1', 'a');
link('t-plus1', 'result', 't-wrap', 'a');
link('t-wrap', 'result', 't-pack', 'rot');
link('t-pack', 'value', '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);
at('m-pack', 'PackStruct', 7, 3, {}, [field('x')]);
set('m-set', 'next', 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('of', 'x', 'm-shift', 'a');
link('start', 'wantDx', 'm-shift', 'b');
link('m-shift', 'result', 'm-pack', 'x');
link('m-pack', 'value', '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 });
at('f-pack', 'PackStruct', 8, 5, {}, [field('y')]);
set('f-ny', 'next', 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('of', 'y', 'f-down1', 'a');
link('f-down1', 'result', 'f-pack', 'y');
link('f-pack', 'value', '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('next', 'struct'),
port('kind', 'text'),
port('fall', 'number'),
port('wantDx', 'number'),
port('wantRot', 'number'),
]);
const back = [
['e-next', 'next', 'next'],
['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();
}
/**
* 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('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();
}
/** 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();
}
/** 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();
}
/**
* 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-piece', 'piece', 3, 5);
set('n-fall', 'fall', 4, 5, 0);
set('n-has', 'hasPiece', 5, 5, true);
link('u-br', 'else', 'n-spawn', 'exec');
link('n-columns', 'value', 'n-spawn', 'spawnX');
link('n-spawn', 'piece', 'n-piece', 'value');
flow('n-spawn', 'n-piece', '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 = [
['piece', 'piece'],
['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 = [
['next', 'next'],
['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-next', 'next', 0, 7);
flow('c-out-wantRot', 't-fits');
link('t-shapes', 'value', 't-fits', 'shapes');
link('t-grid', 'value', 't-fits', 'grid');
link('t-next', 'value', '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);
get('m-next', 'next', 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-next', 'value', 'm-piece', 'value');
get('m-kind', 'kind', 2, 7);
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-kind', 'value', 'm-landed', 'a');
link('m-landed', 'result', 'm-brland', 'condition');
link('m-brland', 'then', 'm-brover', 'exec');
link('m-py2', 'value', '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-has', 'd-draw');
['shapes', 'piece', '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-piece', 'piece', 0, 5);
at('l-shape', 'ExpandStruct', 0, 7, {}, [field('shape')]);
set('l-setgrid', 'grid', 1, 0);
link('m-brover', 'else', 'l-lock', 'exec');
link('l-grid', 'value', 'l-lock', 'grid');
link('l-piece', 'value', 'l-shape', 'value');
link('l-shape', 'shape', '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();
}
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(), 'struct', [field('x'), field('y')]),
variable('colors', 'list', SHAPE_COLORS, 'color'),
variable('spawnX', 'list', spawnColumns(), 'number'),
// The falling piece and the one move being tried on it – four numbers each, and never apart.
variable('piece', 'struct', NO_PIECE, undefined, PIECE_FIELDS),
variable('next', 'struct', NO_PIECE, undefined, 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', [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('next', 'struct', NO_PIECE, undefined, PIECE_FIELDS),
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);