Files
feuerwerk/tools/seed-tetris.mjs
T
rootandClaude Opus 5 4f78c7518c Give the graph a loop, and build Tetris with it
A Tetris example project needs to draw 200 board cells and scan rows
for a full line. Neither is expressible without iteration, so the node
system gets a Repeat node plus the missing text side: Substring, Find
text and To number. The interpreter drops its data cache once per
round -- otherwise every round would recompute with the first round's
values.

Object by name takes its name as an input now, not just as a setting;
addressing "c" + index is impossible otherwise. An unconnected input
reads the stored setting, so saved projects are untouched.

The board itself is a 200-character text, "." for empty. That makes
both operations of Tetris a single expression instead of a loop, and
each frame attempts exactly one move -- rotate beats sideways beats
fall -- so the fit check exists once in the whole graph.

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

808 lines
29 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, again built only from assets and one node graph.
*
* The board is a text: 200 characters, one per cell, '.' for empty and the digit of the piece
* that came to rest there. That makes the two operations the game is built on single expressions
* instead of loops – a cell is `Substring(grid, row·10 + column, 1)`, and clearing a full line is
* an empty row joined in front of everything above and below it.
*
* What the player sees is a pool of objects the graph addresses by name: `c0…c199` for the board
* and `p0…p3` for the piece that is falling. Nothing is spawned or destroyed while playing.
*
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-tetris.mjs [projectId] [baseUrl]
*/
import { edge, node, sceneObject, variable } from './lib/builders.mjs';
import { 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;
const EMPTY_CELL = '.';
const EMPTY_ROW = EMPTY_CELL.repeat(COLS);
const EMPTY_GRID = EMPTY_CELL.repeat(COLS * ROWS);
// --- colours ---------------------------------------------------------------------------------
const BACKGROUND = 0x151821;
const WELL = 0x0d1017;
const WHITE = 0xf2f5f7;
const HINT = 0x9aa3b2;
/** One per shape, in the order of `SHAPES` below. */
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 text, eight digits each (x and y of the four cells), so the graph reads
* a rotation as `Substring(shapes, (shape·4 + rotation)·8, 8)`.
*/
function shapeTable() {
let table = '';
for (const shape of SHAPES) {
let cells = shape.cells;
for (let rotation = 0; rotation < 4; rotation++) {
table += cells.map(([x, y]) => `${x}${y}`).join('');
cells = turned(cells, shape.size);
}
}
return table;
}
/** Colours as eight digits each, so a cell's digit picks its colour out of one text. */
const colorTable = () => SHAPE_COLORS.map((c) => String(c).padStart(8, '0')).join('');
/** The column a shape starts in, so every piece appears centred over the well. */
const spawnColumns = () => SHAPES.map((s) => Math.floor((COLS - s.size) / 2)).join('');
// --- 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 -----------------------------------------------------------------------------
// Nodes are laid out in bands, one per strand of the graph, so the editor shows the game as a
// stack of readable blocks rather than one cloud.
const nodes = [];
const edges = [];
let band = 0;
/** Starts the next band. */
const strand = () => band++;
/** Places a node; `column`/`row` are grid steps inside the current band. */
function at(id, type, column, row, params = {}) {
nodes.push(node(id, type, column * 220, band * 1400 + row * 90, params));
return id;
}
function link(from, fromPort, to, toPort) {
edges.push(edge(`e${edges.length + 1}`, from, fromPort, to, toPort));
}
/** Chains exec outputs: `flow(a, b, c)` runs a, then b, then c. */
function flow(...ids) {
for (let i = 0; i < ids.length - 1; i++) link(ids[i], 'exec', ids[i + 1], 'exec');
}
// Reading a variable is so common that it gets a shorthand.
const get = (id, key, column, row) => at(id, 'GetVariable', column, row, { variable: key });
const set = (id, key, column, row, value) =>
at(id, 'SetVariable', column, row, value === undefined ? { variable: key } : { variable: key, value });
/**
* The four cells of one rotation, as the graph computes them: reads `shapes` at
* (shape·4 + rotation)·8 and takes the two digits belonging to the round the loop is in.
* Returns the ids carrying the cell's column and row, offset by the piece's position.
*/
function cellOfRound(prefix, { loop, shapeVar, rotVar, xVar, yVar }, column, row) {
const shape = get(`${prefix}-shape`, shapeVar, column, row);
const rot = get(`${prefix}-rot`, rotVar, column, row + 1);
const shapes = get(`${prefix}-shapes`, 'shapes', column, row + 2);
const four = at(`${prefix}-x4`, 'Multiply', column + 1, row, { b: 4 });
const plus = at(`${prefix}-plus`, 'Add', column + 2, row);
const eight = at(`${prefix}-x8`, 'Multiply', column + 3, row, { b: 8 });
const entry = at(`${prefix}-entry`, 'Substring', column + 4, row, { count: 8 });
link(shape, 'value', four, 'a');
link(four, 'result', plus, 'a');
link(rot, 'value', plus, 'b');
link(plus, 'result', eight, 'a');
link(shapes, 'value', entry, 'text');
link(eight, 'result', entry, 'start');
const twice = at(`${prefix}-k2`, 'Multiply', column + 4, row + 3, { b: 2 });
link(loop, 'index', twice, 'a');
const digitX = at(`${prefix}-dx`, 'Substring', column + 5, row + 2, { count: 1 });
const digitY = at(`${prefix}-dy`, 'Substring', column + 5, row + 5, { count: 1 });
const next = at(`${prefix}-k2b`, 'Add', column + 4, row + 5, { b: 1 });
link(twice, 'result', next, 'a');
link(entry, 'text', digitX, 'text');
link(twice, 'result', digitX, 'start');
link(entry, 'text', digitY, 'text');
link(next, 'result', digitY, 'start');
const numX = at(`${prefix}-nx`, 'ToNumber', column + 6, row + 2);
const numY = at(`${prefix}-ny`, 'ToNumber', column + 6, row + 5);
link(digitX, 'text', numX, 'value');
link(digitY, 'text', numY, 'value');
const baseX = get(`${prefix}-basex`, xVar, column + 6, row);
const baseY = get(`${prefix}-basey`, yVar, column + 6, row + 7);
const cellX = at(`${prefix}-cx`, 'Add', column + 7, row + 1);
const cellY = at(`${prefix}-cy`, 'Add', column + 7, row + 6);
link(baseX, 'value', cellX, 'a');
link(numX, 'result', cellX, 'b');
link(baseY, 'value', cellY, 'a');
link(numY, 'result', cellY, 'b');
return { cellX, cellY };
}
/** `ObjectByName` with a name built at runtime: prefix + the round the loop is in. */
function objectOfRound(prefix, loop, namePrefix, column, row) {
const text = at(`${prefix}-text`, 'ToText', column, row, { decimals: 0 });
const name = at(`${prefix}-name`, 'Join', column + 1, row, { a: namePrefix });
const object = at(`${prefix}-obj`, 'ObjectByName', column + 2, row);
link(loop, 'index', text, 'value');
link(text, 'text', name, 'b');
link(name, 'text', object, 'name');
return object;
}
// --- the graph ---------------------------------------------------------------------------------
// 1) On start: an empty board, no piece, nothing pressed. The world is rebuilt for every run, so
// this only matters when the behavior was left running in the editor.
strand();
at('s-start', 'OnStart', 0, 0);
set('s-grid', 'grid', 1, 0, EMPTY_GRID);
set('s-lines', 'lines', 2, 0, 0);
set('s-piece', 'hasPiece', 3, 0, false);
set('s-fall', 'fall', 4, 0, 0);
set('s-dx', 'wantDx', 5, 0, 0);
set('s-turn', 'wantRot', 6, 0, 0);
at('s-label', 'ObjectByName', 6, 2, { name: 'LinesLabel' });
at('s-text', 'SetText', 7, 0, { text: 'Lines 0' });
flow('s-start', 's-grid', 's-lines', 's-piece', 's-fall', 's-dx', 's-turn', 's-text');
link('s-label', 'object', 's-text', 'object');
// 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 tries one move. Which
// move is decided first (turning beats moving beats falling) and then tested in one place.
strand();
at('u-update', 'OnUpdate', 0, 0);
get('u-has', 'hasPiece', 0, 2);
at('u-br', 'Branch', 1, 0);
flow('u-update', 'u-br');
link('u-has', 'value', 'u-br', 'condition');
// 3a) No piece: draw one of the seven at random, at the column that centres it.
at('n-random', 'Random', 2, 4, { min: 0, max: 7 });
at('n-floor', 'Floor', 3, 4);
set('n-shape', 'shape', 4, 4);
set('n-rot', 'rot', 5, 4, 0);
get('n-shape2', 'shape', 5, 7);
get('n-columns', 'spawnX', 5, 9);
at('n-column', 'Substring', 6, 8, { count: 1 });
at('n-number', 'ToNumber', 7, 8);
set('n-px', 'px', 6, 4);
set('n-py', 'py', 7, 4, 0);
set('n-fall', 'fall', 8, 4, 0);
set('n-has', 'hasPiece', 9, 4, true);
link('u-br', 'else', 'n-shape', 'exec');
link('n-random', 'result', 'n-floor', 'value');
link('n-floor', 'result', 'n-shape', 'value');
link('n-shape2', 'value', 'n-column', 'start');
link('n-columns', 'value', 'n-column', 'text');
link('n-column', 'text', 'n-number', 'value');
link('n-number', 'result', 'n-px', 'value');
flow('n-shape', 'n-rot', 'n-px', 'n-py', 'n-fall', 'n-has');
// 3b) A piece is falling: the candidate starts out as where it already is.
strand();
get('c-px', 'px', 0, 2);
get('c-py', 'py', 1, 2);
get('c-rot', 'rot', 2, 2);
set('c-nx', 'nx', 0, 0);
set('c-ny', 'ny', 1, 0);
set('c-nrot', 'nrot', 2, 0);
set('c-kind', 'kind', 3, 0, '');
link('u-br', 'then', 'c-nx', 'exec');
link('c-px', 'value', 'c-nx', 'value');
link('c-py', 'value', 'c-ny', 'value');
link('c-rot', 'value', 'c-nrot', 'value');
flow('c-nx', 'c-ny', 'c-nrot', 'c-kind');
// Turning wins: a quarter turn clockwise is one step around the four rotations of the shape.
get('c-wantrot', 'wantRot', 4, 3);
at('c-turning', 'Compare', 5, 3, { op: '>', b: 0 });
at('c-brturn', 'Branch', 5, 0);
get('c-rot2', 'rot', 6, 3);
at('c-plus1', 'Add', 7, 3, { b: 1 });
at('c-wrap', 'Modulo', 8, 3, { b: 4 });
set('c-setrot', 'nrot', 6, 0);
set('c-kindturn', 'kind', 7, 0, 'turn');
set('c-usedrot', 'wantRot', 8, 0, 0);
flow('c-kind', 'c-brturn');
link('c-wantrot', 'value', 'c-turning', 'a');
link('c-turning', 'result', 'c-brturn', 'condition');
link('c-brturn', 'then', 'c-setrot', 'exec');
link('c-rot2', 'value', 'c-plus1', 'a');
link('c-plus1', 'result', 'c-wrap', 'a');
link('c-wrap', 'result', 'c-setrot', 'value');
flow('c-setrot', 'c-kindturn', 'c-usedrot');
// Then sideways, one column per key press.
get('c-wantdx', 'wantDx', 4, 8);
at('c-moving', 'Equals', 5, 8, { b: 0 });
at('c-notmoving', 'Not', 6, 8);
at('c-brmove', 'Branch', 6, 6);
get('c-px2', 'px', 7, 9);
at('c-shift', 'Add', 8, 9);
set('c-setnx', 'nx', 7, 6);
set('c-kindmove', 'kind', 8, 6, 'move');
set('c-useddx', 'wantDx', 9, 6, 0);
link('c-brturn', 'else', 'c-brmove', 'exec');
link('c-wantdx', 'value', 'c-moving', 'a');
link('c-moving', 'result', 'c-notmoving', 'value');
link('c-notmoving', 'result', 'c-brmove', 'condition');
link('c-brmove', 'then', 'c-setnx', 'exec');
link('c-px2', 'value', 'c-shift', 'a');
link('c-wantdx', 'value', 'c-shift', 'b');
link('c-shift', 'result', 'c-setnx', 'value');
flow('c-setnx', 'c-kindmove', 'c-useddx');
// And otherwise the clock: holding the down key lets it run ten times as fast.
strand();
get('f-fall', 'fall', 0, 3);
at('f-down', 'KeyIsDown', 0, 6, { key: 'ArrowDown' });
at('f-boost', 'Multiply', 1, 6, { b: 9 });
at('f-rate', 'Add', 2, 6, { a: 1 });
at('f-step', 'Multiply', 3, 5);
at('f-add', 'Add', 4, 3);
set('f-setfall', 'fall', 1, 0);
link('c-brmove', 'else', 'f-setfall', 'exec');
link('f-down', 'down', 'f-boost', 'a');
link('f-boost', 'result', 'f-rate', 'b');
link('u-update', 'dt', 'f-step', 'a');
link('f-rate', 'result', 'f-step', 'b');
link('f-fall', 'value', 'f-add', 'a');
link('f-step', 'result', 'f-add', 'b');
link('f-add', 'result', 'f-setfall', 'value');
get('f-fall2', 'fall', 2, 3);
get('f-time', 'stepTime', 2, 5);
at('f-due', 'Compare', 3, 3, { op: '>=' });
at('f-brdue', 'Branch', 4, 0);
at('f-minus', 'Subtract', 5, 3);
set('f-rest', 'fall', 5, 0);
get('f-py', 'py', 6, 3);
at('f-down1', 'Add', 7, 3, { b: 1 });
set('f-setny', 'ny', 6, 0);
set('f-kind', 'kind', 7, 0, 'fall');
flow('f-setfall', 'f-brdue');
link('f-fall2', 'value', 'f-due', 'a');
link('f-time', 'value', 'f-due', 'b');
link('f-due', 'result', 'f-brdue', 'condition');
link('f-brdue', 'then', 'f-rest', 'exec');
link('f-fall2', 'value', 'f-minus', 'a');
link('f-time', 'value', 'f-minus', 'b');
link('f-minus', 'result', 'f-rest', 'value');
link('f-py', 'value', 'f-down1', 'a');
link('f-down1', 'result', 'f-setny', 'value');
flow('f-rest', 'f-setny', 'f-kind');
// 4) The one test: do the four cells of the candidate fit? Every attempt above ends here.
strand();
set('t-ok', 'ok', 0, 0, true);
at('t-loop', 'Repeat', 1, 0, { times: 4 });
flow('c-usedrot', 't-ok');
flow('c-useddx', 't-ok');
flow('f-kind', 't-ok');
flow('t-ok', 't-loop');
const candidate = cellOfRound(
't',
{ loop: 't-loop', shapeVar: 'shape', rotVar: 'nrot', xVar: 'nx', yVar: 'ny' },
2,
4,
);
at('t-left', 'Compare', 10, 0, { op: '<', b: 0 });
at('t-right', 'Compare', 10, 2, { op: '>', b: COLS - 1 });
at('t-side', 'Or', 11, 1);
at('t-bottom', 'Compare', 10, 4, { op: '>', b: ROWS - 1 });
at('t-outside', 'Or', 12, 2);
link(candidate.cellX, 'result', 't-left', 'a');
link(candidate.cellX, 'result', 't-right', 'a');
link('t-left', 'result', 't-side', 'a');
link('t-right', 'result', 't-side', 'b');
link(candidate.cellY, 'result', 't-bottom', 'a');
link('t-side', 'result', 't-outside', 'a');
link('t-bottom', 'result', 't-outside', 'b');
// A cell outside the board reads as empty here, but the check above has already caught it.
at('t-row', 'Multiply', 10, 7, { b: COLS });
at('t-index', 'Add', 11, 7);
get('t-grid', 'grid', 11, 9);
at('t-cell', 'Substring', 12, 8, { count: 1 });
at('t-empty', 'Equals', 13, 8, { b: EMPTY_CELL });
at('t-taken', 'Not', 14, 8);
at('t-blocked', 'Or', 15, 4);
at('t-br', 'Branch', 16, 0);
set('t-no', 'ok', 17, 0, false);
link(candidate.cellY, 'result', 't-row', 'a');
link('t-row', 'result', 't-index', 'a');
link(candidate.cellX, 'result', 't-index', 'b');
link('t-grid', 'value', 't-cell', 'text');
link('t-index', 'result', 't-cell', 'start');
link('t-cell', 'text', 't-empty', 'a');
link('t-empty', 'result', 't-taken', 'value');
link('t-outside', 'result', 't-blocked', 'a');
link('t-taken', 'result', 't-blocked', 'b');
link('t-loop', 'body', 't-br', 'exec');
link('t-blocked', 'result', 't-br', 'condition');
link('t-br', 'then', 't-no', 'exec');
// 5) It fits: the candidate becomes the piece. It does not: only a fall means anything – the
// piece has landed (or, still at the very top, the well is full).
strand();
get('m-ok', 'ok', 0, 2);
at('m-br', 'Branch', 1, 0);
get('m-nx', 'nx', 2, 3);
get('m-ny', 'ny', 3, 3);
get('m-nrot', 'nrot', 4, 3);
set('m-px', 'px', 2, 0);
set('m-py', 'py', 3, 0);
set('m-rot', 'rot', 4, 0);
link('t-loop', 'done', 'm-br', 'exec');
link('m-ok', 'value', 'm-br', 'condition');
link('m-br', 'then', 'm-px', 'exec');
link('m-nx', 'value', 'm-px', 'value');
link('m-ny', 'value', 'm-py', 'value');
link('m-nrot', 'value', 'm-rot', 'value');
flow('m-px', 'm-py', 'm-rot');
get('m-kind', 'kind', 2, 7);
at('m-landed', 'Equals', 3, 7, { b: 'fall' });
at('m-brland', 'Branch', 3, 5);
get('m-py2', 'py', 4, 8);
at('m-attop', 'Compare', 5, 8, { op: '<=', b: 0 });
at('m-brover', 'Branch', 5, 5);
at('m-stop', 'StopGame', 6, 5);
link('m-br', 'else', 'm-brland', 'exec');
link('m-kind', 'value', 'm-landed', 'a');
link('m-landed', 'result', 'm-brland', 'condition');
link('m-brland', 'then', 'm-brover', 'exec');
link('m-py2', 'value', 'm-attop', 'a');
link('m-attop', 'result', 'm-brover', 'condition');
link('m-brover', 'then', 'm-stop', 'exec');
// 6) Drawing the piece: the four cell objects are moved to where the piece is. This is the only
// place that touches them, so where they stand *is* where the piece is – which is what the
// landing below reads back instead of working the shape out a second time.
strand();
at('d-loop', 'Repeat', 0, 0, { times: 4 });
flow('m-rot', 'd-loop');
flow('n-has', 'd-loop');
const drawn = cellOfRound(
'd',
{ loop: 'd-loop', shapeVar: 'shape', rotVar: 'rot', xVar: 'px', yVar: 'py' },
1,
4,
);
const pieceObject = objectOfRound('d-p', 'd-loop', 'p', 9, 0);
at('d-x', 'Multiply', 9, 4, { b: CELL });
at('d-atx', 'Add', 10, 4, { b: CELL_X(0) });
at('d-y', 'Multiply', 9, 6, { b: CELL });
at('d-aty', 'Add', 10, 6, { b: CELL_Y(0) });
link(drawn.cellX, 'result', 'd-x', 'a');
link('d-x', 'result', 'd-atx', 'a');
link(drawn.cellY, 'result', 'd-y', 'a');
link('d-y', 'result', 'd-aty', 'a');
get('d-shapec', 'shape', 12, 8);
get('d-colors', 'colors', 12, 10);
at('d-offset', 'Multiply', 13, 8, { b: 8 });
at('d-hex', 'Substring', 14, 9, { count: 8 });
at('d-color', 'ToNumber', 15, 9);
at('d-show', 'SetEnabled', 12, 0, { enabled: true });
at('d-place', 'SetPosition', 13, 0);
at('d-paint', 'SetProperty', 14, 0, { property: 'color' });
link('d-loop', 'body', 'd-show', 'exec');
link(pieceObject, 'object', 'd-show', 'object');
link(pieceObject, 'object', 'd-place', 'object');
link(pieceObject, 'object', 'd-paint', 'object');
link('d-atx', 'result', 'd-place', 'x');
link('d-aty', 'result', 'd-place', 'y');
link('d-shapec', 'value', 'd-offset', 'a');
link('d-colors', 'value', 'd-hex', 'text');
link('d-offset', 'result', 'd-hex', 'start');
link('d-hex', 'text', 'd-color', 'value');
link('d-color', 'result', 'd-paint', 'value');
flow('d-show', 'd-place', 'd-paint');
// 7) Landing: each of the four cell objects writes its digit into the board text, read back from
// where it stands.
strand();
at('l-loop', 'Repeat', 0, 0, { times: 4 });
link('m-brover', 'else', 'l-loop', 'exec');
const landed = objectOfRound('l-p', 'l-loop', 'p', 1, 4);
at('l-pos', 'GetPosition', 4, 4);
at('l-offx', 'Add', 5, 3, { b: -CELL_X(0) });
at('l-offy', 'Add', 5, 6, { b: -CELL_Y(0) });
at('l-col', 'Divide', 6, 3, { b: CELL });
at('l-rowof', 'Divide', 6, 6, { b: CELL });
at('l-colr', 'Round', 7, 3);
at('l-rowr', 'Round', 7, 6);
link(landed, 'object', 'l-pos', 'object');
link('l-pos', 'x', 'l-offx', 'a');
link('l-pos', 'y', 'l-offy', 'a');
link('l-offx', 'result', 'l-col', 'a');
link('l-offy', 'result', 'l-rowof', 'a');
link('l-col', 'result', 'l-colr', 'value');
link('l-rowof', 'result', 'l-rowr', 'value');
at('l-row10', 'Multiply', 8, 6, { b: COLS });
at('l-index', 'Add', 9, 4);
at('l-next', 'Add', 10, 8, { b: 1 });
get('l-grid', 'grid', 9, 10);
get('l-shape', 'shape', 9, 12);
at('l-digit', 'ToText', 10, 12, { decimals: 0 });
at('l-before', 'Substring', 10, 6);
at('l-after', 'Substring', 11, 8, { count: -1 });
at('l-joined', 'Join', 11, 10);
at('l-whole', 'Join', 12, 10);
set('l-setgrid', 'grid', 13, 0);
link('l-rowr', 'result', 'l-row10', 'a');
link('l-row10', 'result', 'l-index', 'a');
link('l-colr', 'result', 'l-index', 'b');
link('l-index', 'result', 'l-next', 'a');
link('l-grid', 'value', 'l-before', 'text');
link('l-index', 'result', 'l-before', 'count');
link('l-grid', 'value', 'l-after', 'text');
link('l-next', 'result', 'l-after', 'start');
link('l-shape', 'value', 'l-digit', 'value');
link('l-before', 'text', 'l-joined', 'a');
link('l-digit', 'text', 'l-joined', 'b');
link('l-joined', 'text', 'l-whole', 'a');
link('l-after', 'text', 'l-whole', 'b');
link('l-whole', 'text', 'l-setgrid', 'value');
link('l-loop', 'body', 'l-setgrid', 'exec');
// 8) Full lines: walk the board from the bottom up. A cleared row does not move the walk on,
// because everything above has dropped into it – so the same row is looked at again.
strand();
set('r-row', 'row', 0, 0, ROWS - 1);
at('r-loop', 'Repeat', 1, 0, { times: ROWS + 4 });
link('l-loop', 'done', 'r-row', 'exec');
flow('r-row', 'r-loop');
get('r-rowvar', 'row', 2, 3);
at('r-left', 'Compare', 3, 3, { op: '>=', b: 0 });
at('r-brleft', 'Branch', 3, 0);
at('r-start', 'Multiply', 4, 3, { b: COLS });
get('r-grid', 'grid', 4, 5);
at('r-text', 'Substring', 5, 4, { count: COLS });
at('r-gap', 'FindText', 6, 4, { search: EMPTY_CELL });
at('r-full', 'Compare', 7, 4, { op: '<', b: 0 });
at('r-brfull', 'Branch', 5, 0);
link('r-loop', 'body', 'r-brleft', 'exec');
link('r-rowvar', 'value', 'r-left', 'a');
link('r-left', 'result', 'r-brleft', 'condition');
link('r-rowvar', 'value', 'r-start', 'a');
link('r-grid', 'value', 'r-text', 'text');
link('r-start', 'result', 'r-text', 'start');
link('r-text', 'text', 'r-gap', 'text');
link('r-gap', 'index', 'r-full', 'a');
link('r-brleft', 'then', 'r-brfull', 'exec');
link('r-full', 'result', 'r-brfull', 'condition');
at('r-above', 'Substring', 6, 7);
at('r-below', 'Substring', 6, 9, { count: -1 });
at('r-past', 'Add', 5, 9, { b: COLS });
at('r-top', 'Join', 7, 7, { a: EMPTY_ROW });
at('r-rest', 'Join', 8, 8);
set('r-setgrid', 'grid', 7, 0);
get('r-lines', 'lines', 8, 3);
at('r-onemore', 'Add', 9, 3, { b: 1 });
set('r-setlines', 'lines', 9, 0);
link('r-brfull', 'then', 'r-setgrid', 'exec');
link('r-grid', 'value', 'r-above', 'text');
link('r-start', 'result', 'r-above', 'count');
link('r-start', 'result', 'r-past', 'a');
link('r-grid', 'value', 'r-below', 'text');
link('r-past', 'result', 'r-below', 'start');
link('r-above', 'text', 'r-top', 'b');
link('r-top', 'text', 'r-rest', 'a');
link('r-below', 'text', 'r-rest', 'b');
link('r-rest', 'text', 'r-setgrid', 'value');
link('r-lines', 'value', 'r-onemore', 'a');
link('r-onemore', 'result', 'r-setlines', 'value');
flow('r-setgrid', 'r-setlines');
at('r-back', 'Subtract', 6, 12, { b: 1 });
set('r-setrow', 'row', 7, 11);
link('r-brfull', 'else', 'r-setrow', 'exec');
link('r-rowvar', 'value', 'r-back', 'a');
link('r-back', 'result', 'r-setrow', 'value');
// 9) The board as it now stands, cell by cell: switched off where the text says empty, and in the
// colour of the shape that came to rest there otherwise.
strand();
at('b-count', 'SetText', 0, 0);
at('b-label', 'ObjectByName', 0, 3, { name: 'LinesLabel' });
get('b-lines', 'lines', 0, 5);
at('b-number', 'ToText', 1, 5, { decimals: 0 });
at('b-caption', 'Join', 2, 5, { a: 'Lines ' });
at('b-loop', 'Repeat', 1, 0, { times: COLS * ROWS });
link('r-loop', 'done', 'b-count', 'exec');
link('b-label', 'object', 'b-count', 'object');
link('b-lines', 'value', 'b-number', 'value');
link('b-number', 'text', 'b-caption', 'b');
link('b-caption', 'text', 'b-count', 'text');
flow('b-count', 'b-loop');
get('b-grid', 'grid', 2, 4);
at('b-cell', 'Substring', 3, 3, { count: 1 });
at('b-empty', 'Equals', 4, 3, { b: EMPTY_CELL });
at('b-filled', 'Not', 5, 3);
const boardObject = objectOfRound('b-c', 'b-loop', 'c', 2, 0);
at('b-show', 'SetEnabled', 6, 0);
at('b-br', 'Branch', 7, 0);
at('b-paint', 'SetProperty', 8, 0, { property: 'color' });
link('b-loop', 'index', 'b-cell', 'start');
link('b-grid', 'value', 'b-cell', 'text');
link('b-cell', 'text', 'b-empty', 'a');
link('b-empty', 'result', 'b-filled', 'value');
link('b-loop', 'body', 'b-show', 'exec');
link(boardObject, 'object', 'b-show', 'object');
link('b-filled', 'result', 'b-show', 'enabled');
link('b-filled', 'result', 'b-br', 'condition');
link(boardObject, 'object', 'b-paint', 'object');
flow('b-show', 'b-br');
link('b-br', 'then', 'b-paint', 'exec');
at('b-digit', 'ToNumber', 8, 4);
get('b-colors', 'colors', 8, 8);
at('b-offset', 'Multiply', 9, 4, { b: 8 });
at('b-hex', 'Substring', 9, 6, { count: 8 });
at('b-color', 'ToNumber', 10, 6);
link('b-cell', 'text', 'b-digit', 'value');
link('b-digit', 'result', 'b-offset', 'a');
link('b-colors', 'value', 'b-hex', 'text');
link('b-offset', 'result', 'b-hex', 'start');
link('b-hex', 'text', 'b-color', 'value');
link('b-color', 'result', 'b-paint', 'value');
// The board is drawn and the piece is part of it now: the next frame draws a new one.
set('b-nopiece', 'hasPiece', 12, 0, false);
link('b-loop', 'done', 'b-nopiece', 'exec');
/** A mistyped id would silently leave an input unconnected, so the seed refuses to write one. */
function checkGraph() {
const ids = new Set();
for (const n of nodes) {
if (ids.has(n.id)) throw new Error(`duplicate node id "${n.id}"`);
ids.add(n.id);
}
for (const e of edges) {
for (const end of [e.from.node, e.to.node]) {
if (!ids.has(end)) throw new Error(`edge ${e.id} points at unknown node "${end}"`);
}
}
return { nodes, edges };
}
const tetrisGraph = checkGraph();
// --- project -----------------------------------------------------------------------------------
const label = (id, name, x, y, text, size, color, align = 'center') =>
sceneObject(id, 'label', { name, x, y, overrides: { text, fontSize: size, color, align } });
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 },
{
id: 'tetris',
type: 'behavior',
name: 'Tetris',
props: {
grid: EMPTY_GRID,
shapes: shapeTable(),
colors: colorTable(),
spawnX: spawnColumns(),
shape: 0,
rot: 0,
px: 3,
py: 0,
nx: 0,
ny: 0,
nrot: 0,
kind: '',
ok: true,
hasPiece: false,
fall: 0,
stepTime: 0.5,
lines: 0,
wantDx: 0,
wantRot: 0,
row: 0,
},
variables: [
variable('grid', 'text', EMPTY_GRID),
variable('shapes', 'text', shapeTable()),
variable('colors', 'text', colorTable()),
variable('spawnX', 'text', spawnColumns()),
variable('shape', 'number', 0),
variable('rot', 'number', 0),
variable('px', 'number', 3),
variable('py', 'number', 0),
variable('nx', 'number', 0),
variable('ny', 'number', 0),
variable('nrot', 'number', 0),
variable('kind', 'text', ''),
variable('ok', 'boolean', true),
variable('hasPiece', 'boolean', false),
variable('fall', 'number', 0),
variable('stepTime', 'number', 0.5),
variable('lines', 'number', 0),
variable('wantDx', 'number', 0),
variable('wantRot', 'number', 0),
variable('row', 'number', 0),
],
graph: tetrisGraph,
},
],
};
await writeProject(projectId, baseUrl, project);