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>
808 lines
29 KiB
JavaScript
808 lines
29 KiB
JavaScript
#!/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);
|