Files
rootandClaude Fable 5.1 42cafa7543
game-maker / suite (push) Failing after 3m59s
Shader assets: graph-built shaders, the Shader tab and sprite rows from the graph (#62)
A shader is an asset carrying a node graph (Texture color, Output, Property,
Color, Add/Subtract/Multiply/Divide, Mix, Luminance) that compiles to the GLSL
fragment of a Pixi filter. The Shader tab reuses the Behavior canvas; a sprite
wears a shader through its Shader picker, and the rows below it are the
graph's Property nodes. Property schema, resolution and deviation take the
project because a sprite's rows now live in another asset. The built-in
registry of the prototype is gone; "Adjust colors" ships as a graph in the
fixture and the three example games.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 22:41:14 +02:00

966 lines
36 KiB
JavaScript
Raw Permalink 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 Tetris example from assets and graphs only. The board is a list of 200 numbers (-1 empty,
* else the resting shape); `piece`/`next` are shape/rot/x/y structs. The `Tetris` master owns the state
* and calls stateless behaviors as nodes, which lets `Fits` serve turning, moving and falling alike.
* Board and piece are a fixed pool of named objects (`c0…c199`, `p0…p3`); nothing spawns while playing.
*
* Usage: node tools/seed-tetris.mjs [projectId] [baseUrl]
*/
import {
edge,
field,
node,
port,
propertiesOf,
sceneObject,
tagged,
variable,
written,
} from './lib/builders.mjs';
import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs';
import { adjustColorsShader } from './lib/shaders.mjs';
const { projectId, baseUrl } = cliArgs('seed-tetris.mjs', PROJECTS.tetris);
const COLS = 10;
const ROWS = 20;
const CELL = 16;
/** Cell 0 sits at the top left of the board; these turn a column/row into the object's position. */
const CELL_X = (col) => col * CELL - (COLS * CELL) / 2 + CELL / 2;
const CELL_Y = (row) => row * CELL - (ROWS * CELL) / 2 + CELL / 2;
/** No shape has this number, so it cannot be mistaken for one that came to rest. */
const EMPTY = -1;
const EMPTY_GRID = Array.from({ length: COLS * ROWS }, () => EMPTY);
const BACKGROUND = 0x151821;
const WELL = 0x0d1017;
const WHITE = 0xf2f5f7;
const HINT = 0x9aa3b2;
/** One per shape, in the order of `SHAPES` below – and the `colors` variable as it stands. */
const SHAPE_COLORS = [0x2ec9d6, 0xf5c542, 0xa35fd6, 0x63c74d, 0xe0553f, 0x4a7de0, 0xe08a3f];
/**
* Each shape as its four cells in a box of `size` × `size`, the box being what turning rotates
* inside: (x, y) → (size − 1 − y, x) is a quarter turn clockwise. I needs a 4-box, O a 2-box.
*/
const SHAPES = [
{
name: 'I',
size: 4,
cells: [
[0, 1],
[1, 1],
[2, 1],
[3, 1],
],
},
{
name: 'O',
size: 2,
cells: [
[0, 0],
[1, 0],
[0, 1],
[1, 1],
],
},
{
name: 'T',
size: 3,
cells: [
[1, 0],
[0, 1],
[1, 1],
[2, 1],
],
},
{
name: 'S',
size: 3,
cells: [
[1, 0],
[2, 0],
[0, 1],
[1, 1],
],
},
{
name: 'Z',
size: 3,
cells: [
[0, 0],
[1, 0],
[1, 1],
[2, 1],
],
},
{
name: 'J',
size: 3,
cells: [
[0, 0],
[0, 1],
[1, 1],
[2, 1],
],
},
{
name: 'L',
size: 3,
cells: [
[2, 0],
[0, 1],
[1, 1],
[2, 1],
],
},
];
const turned = (cells, size) => cells.map(([x, y]) => [size - 1 - y, x]);
/**
* All 28 rotations in one list, four cells each, so one rotation starts at (shape·4 + rotation)·4
* and its n-th cell is one entry further along.
*/
function shapeTable() {
const table = [];
for (const shape of SHAPES) {
let cells = shape.cells;
for (let rotation = 0; rotation < 4; rotation++) {
for (const [x, y] of cells) table.push({ x, y });
cells = turned(cells, shape.size);
}
}
return table;
}
/** The four numbers that say which piece is where. */
const PIECE_FIELDS = [field('shape'), field('rot'), field('x'), field('y')];
const NO_PIECE = { shape: 0, rot: 0, x: 0, y: 0 };
/** The column a shape starts in, so every piece appears centred over the well. */
const spawnColumns = () => SHAPES.map((s) => Math.floor((COLS - s.size) / 2));
/** The well, the 200 board cells (switched off = empty) and the four cells of the falling piece. */
const boardTemplate = [
sceneObject('well', 'well-sprite', { name: 'Well' }),
...Array.from({ length: COLS * ROWS }, (_, i) =>
sceneObject(`cell${i}`, 'cell-sprite', {
name: `c${i}`,
x: CELL_X(i % COLS),
y: CELL_Y(Math.floor(i / COLS)),
z: 1,
overrides: { enabled: false },
}),
),
...Array.from({ length: 4 }, (_, i) =>
sceneObject(`piece${i}`, 'cell-sprite', { name: `p${i}`, z: 2, overrides: { enabled: false } }),
),
];
/** A `sets` entry whose value arrives through an edge instead of being typed on the node. */
const LINKED = Symbol('linked');
/**
* One graph under construction. Nodes are laid out in bands, one per strand, so the editor shows a
* behavior as a stack of readable blocks rather than one cloud. `variables` is what the plural nodes
* declare their ports from – pass the behavior's own list.
*/
function graphBuilder(variables = []) {
const nodes = [];
const edges = [];
let band = 0;
const kinds = Object.fromEntries(variables.map((v) => [v.key, v.kind]));
const declare = (keys, write = false) =>
keys.map((key) => {
if (!kinds[key]) throw new Error(`no variable "${key}" to declare a port for`);
return write ? written(key, kinds[key]) : port(key, kinds[key]);
});
const at = (id, type, column, row, params = {}, ports) => {
nodes.push(node(id, type, column * 220, band * 1400 + row * 90, params, ports));
return id;
};
const link = (from, fromPort, to, toPort) => {
edges.push(edge(`e${edges.length + 1}`, from, fromPort, to, toPort));
};
return {
at,
link,
/** Starts the next band. */
strand: () => band++,
/** Chains exec outputs: `flow(a, b, c)` runs a, then b, then c. */
flow: (...ids) => {
for (let i = 0; i < ids.length - 1; i++) link(ids[i], 'exec', ids[i + 1], 'exec');
},
/** A Variables node reading one variable; its output is named after it. */
get: (id, key, column, row) => at(id, 'Variables', column, row, {}, declare([key])),
/** A Variables node writing one variable, typed in as `value` or through its input of that name. */
set: (id, key, column, row, value) =>
at(id, 'Variables', column, row, value === undefined ? {} : { [key]: value }, declare([key], true)),
/**
* One node for a whole cluster. A `sets` reads all its inputs before it writes any, so an input
* fed from a variable it writes sees the value from before.
*/
gets: (id, keys, column, row) => at(id, 'Variables', column, row, {}, declare(keys)),
sets: (id, spec, column, row) => {
const keys = Array.isArray(spec) ? spec : Object.keys(spec);
const params = Array.isArray(spec)
? {}
: Object.fromEntries(Object.entries(spec).filter(([, v]) => v !== LINKED));
return at(id, 'Variables', column, row, params, declare(keys, true));
},
/** A mistyped id would silently leave an input unconnected, so the seed refuses to write one. */
done() {
const ids = new Set();
for (const n of nodes) {
if (ids.has(n.id)) throw new Error(`duplicate node id "${n.id}"`);
ids.add(n.id);
}
for (const e of edges) {
for (const end of [e.from.node, e.to.node]) {
if (!ids.has(end)) throw new Error(`edge ${e.id} points at unknown node "${end}"`);
}
}
return { nodes, edges };
},
};
}
/** `ObjectByName` with a name built at runtime: prefix + the round the loop is in. */
function objectOfRound(g, prefix, loop, namePrefix, column, row) {
const text = g.at(`${prefix}-text`, 'ToText', column, row, { decimals: 0 });
const name = g.at(`${prefix}-name`, 'Join', column + 1, row, { a: namePrefix });
const object = g.at(`${prefix}-obj`, 'ObjectByName', column + 2, row);
g.link(loop, 'index', text, 'value');
g.link(text, 'text', name, 'b');
g.link(name, 'text', object, 'name');
return object;
}
/**
* Where one of the four cells of a piece sits: reads the rotation out of `shapes` and adds the
* piece's own position. A behavior so the fit test and the drawing share it.
*/
function pieceCellGraph() {
const g = graphBuilder();
g.at('start', 'BehaviorStart', 0, 0, {}, [
port('shapes', 'list'),
port('piece', 'struct'),
port('corner', 'number'),
]);
// The whole piece arrives as one bundle and is taken apart here, where its parts are needed.
g.at('of', 'ExpandStruct', 1, 2, {}, PIECE_FIELDS);
g.at('x4', 'Multiply', 2, 2, { b: 4 });
g.at('plus', 'Add', 3, 2);
g.at('x4b', 'Multiply', 4, 2, { b: 4 });
g.at('index', 'Add', 5, 2);
g.at('cell', 'ItemAt', 6, 2);
g.at('at', 'ExpandStruct', 7, 2, {}, [field('x'), field('y')]);
g.at('sx', 'Add', 8, 1);
g.at('sy', 'Add', 8, 4);
g.at('end', 'BehaviorEnd', 9, 0, {}, [port('x', 'number'), port('y', 'number')]);
g.link('start', 'piece', 'of', 'value');
g.link('of', 'shape', 'x4', 'a');
g.link('x4', 'result', 'plus', 'a');
g.link('of', 'rot', 'plus', 'b');
g.link('plus', 'result', 'x4b', 'a');
g.link('x4b', 'result', 'index', 'a');
g.link('start', 'corner', 'index', 'b');
g.link('start', 'shapes', 'cell', 'list');
g.link('index', 'result', 'cell', 'index');
g.link('cell', 'item', 'at', 'value');
g.link('at', 'x', 'sx', 'a');
g.link('of', 'x', 'sx', 'b');
g.link('at', 'y', 'sy', 'a');
g.link('of', 'y', 'sy', 'b');
g.link('sx', 'result', 'end', 'x');
g.link('sy', 'result', 'end', 'y');
g.flow('start', 'end');
return g.done();
}
/**
* Whether the four cells of a piece may stand where it is asked to: inside the well, and on empty
* board cells. Every attempt to turn, move or drop goes through this one answer.
*/
const FITS_VARS = [variable('ok', 'boolean', true)];
function fitsGraph() {
const g = graphBuilder(FITS_VARS);
g.at('start', 'BehaviorStart', 0, 0, {}, [
port('shapes', 'list'),
port('grid', 'list'),
port('piece', 'struct'),
]);
g.set('ok', 'ok', 1, 0, true);
g.at('loop', 'Repeat', 2, 0, { times: 4 });
g.flow('start', 'ok', 'loop');
g.at('cell', 'RunBehavior', 3, 0, { behavior: 'piece-cell' });
g.link('loop', 'body', 'cell', 'exec');
for (const key of ['shapes', 'piece']) g.link('start', key, 'cell', key);
g.link('loop', 'index', 'cell', 'corner');
g.at('left', 'Compare', 4, 3, { op: '<', b: 0 });
g.at('right', 'Compare', 4, 5, { op: '>', b: COLS - 1 });
g.at('side', 'Or', 5, 4);
g.at('bottom', 'Compare', 4, 7, { op: '>', b: ROWS - 1 });
g.at('outside', 'Or', 6, 5);
g.link('cell', 'x', 'left', 'a');
g.link('cell', 'x', 'right', 'a');
g.link('left', 'result', 'side', 'a');
g.link('right', 'result', 'side', 'b');
g.link('cell', 'y', 'bottom', 'a');
g.link('side', 'result', 'outside', 'a');
g.link('bottom', 'result', 'outside', 'b');
// A cell outside the board reads as empty here, but the check above has already caught it.
g.at('row', 'Multiply', 4, 9, { b: COLS });
g.at('index', 'Add', 5, 9);
g.at('taken', 'ItemAt', 6, 9);
g.at('busy', 'Compare', 7, 9, { op: '>=', b: 0 });
g.at('blocked', 'Or', 8, 6);
g.at('br', 'Branch', 9, 0);
g.set('no', 'ok', 10, 0, false);
g.link('cell', 'y', 'row', 'a');
g.link('row', 'result', 'index', 'a');
g.link('cell', 'x', 'index', 'b');
g.link('start', 'grid', 'taken', 'list');
g.link('index', 'result', 'taken', 'index');
g.link('taken', 'item', 'busy', 'a');
g.link('outside', 'result', 'blocked', 'a');
g.link('busy', 'result', 'blocked', 'b');
g.link('cell', 'exec', 'br', 'exec');
g.link('blocked', 'result', 'br', 'condition');
g.link('br', 'then', 'no', 'exec');
g.at('end', 'BehaviorEnd', 11, 0, {}, [port('ok', 'boolean')]);
g.get('result', 'ok', 10, 3);
g.link('loop', 'done', 'end', 'exec');
g.link('result', 'ok', 'end', 'ok');
return g.done();
}
const CLEAR_LINES_VARS = [
variable('board', 'list', []),
variable('cleared', 'number', 0),
variable('row', 'number', 0),
];
/**
* Removes every full row and counts it. Walks the board from the bottom up; a cleared row does not
* move the walk on, because everything above has dropped into it – so the same row is looked at
* again, which is how two full rows on top of each other both go.
*/
function clearLinesGraph() {
const g = graphBuilder(CLEAR_LINES_VARS);
g.at('start', 'BehaviorStart', 0, 0, {}, [port('grid', 'list'), port('lines', 'number')]);
g.sets('start-vars', { board: LINKED, cleared: LINKED, row: ROWS - 1 }, 1, 0);
g.at('loop', 'Repeat', 4, 0, { times: ROWS + 4 });
g.link('start', 'grid', 'start-vars', 'board');
g.link('start', 'lines', 'start-vars', 'cleared');
g.flow('start', 'start-vars', 'loop');
// Every round reads all three before it writes any of them, and the loop drops the pulse cache
// between rounds – so one node serves the whole round.
g.gets('row-vars', ['row', 'board', 'cleared'], 5, 3);
g.at('left', 'Compare', 6, 3, { op: '>=', b: 0 });
g.at('brleft', 'Branch', 6, 0);
g.at('rowstart', 'Multiply', 7, 3, { b: COLS });
g.at('cells', 'SubList', 8, 4, { count: COLS });
g.at('gap', 'IndexOf', 9, 4, { value: EMPTY });
g.at('full', 'Compare', 10, 4, { op: '<', b: 0 });
g.at('brfull', 'Branch', 8, 0);
g.link('loop', 'body', 'brleft', 'exec');
g.link('row-vars', 'row', 'left', 'a');
g.link('left', 'result', 'brleft', 'condition');
g.link('row-vars', 'row', 'rowstart', 'a');
g.link('row-vars', 'board', 'cells', 'list');
g.link('rowstart', 'result', 'cells', 'start');
g.link('cells', 'result', 'gap', 'list');
g.link('gap', 'index', 'full', 'a');
g.link('brleft', 'then', 'brfull', 'exec');
g.link('full', 'result', 'brfull', 'condition');
g.at('above', 'SubList', 9, 7, { start: 0 });
g.at('past', 'Add', 8, 10, { b: COLS });
g.at('below', 'SubList', 9, 10, { count: COLS * ROWS });
g.at('blank', 'Fill', 9, 12, { value: EMPTY, count: COLS });
g.at('top', 'Concat', 10, 8);
g.at('rest', 'Concat', 11, 9);
g.at('onemore', 'Add', 12, 3, { b: 1 });
g.sets('cleared-row', ['board', 'cleared'], 10, 0);
g.link('brfull', 'then', 'cleared-row', 'exec');
g.link('row-vars', 'board', 'above', 'list');
g.link('rowstart', 'result', 'above', 'count');
g.link('rowstart', 'result', 'past', 'a');
g.link('row-vars', 'board', 'below', 'list');
g.link('past', 'result', 'below', 'start');
g.link('blank', 'result', 'top', 'a');
g.link('above', 'result', 'top', 'b');
g.link('top', 'result', 'rest', 'a');
g.link('below', 'result', 'rest', 'b');
g.link('rest', 'result', 'cleared-row', 'board');
g.link('row-vars', 'cleared', 'onemore', 'a');
g.link('onemore', 'result', 'cleared-row', 'cleared');
g.at('back', 'Subtract', 9, 14, { b: 1 });
g.set('setrow', 'row', 10, 13);
g.link('brfull', 'else', 'setrow', 'exec');
g.link('row-vars', 'row', 'back', 'a');
g.link('back', 'result', 'setrow', 'row');
g.at('end', 'BehaviorEnd', 13, 0, {}, [port('grid', 'list'), port('lines', 'number')]);
g.gets('out-vars', ['board', 'cleared'], 12, 6);
g.link('loop', 'done', 'end', 'exec');
g.link('out-vars', 'board', 'end', 'grid');
g.link('out-vars', 'cleared', 'end', 'lines');
return g.done();
}
/**
* The board as it stands, cell by cell: switched off where the list says empty, in the colour of
* the shape that came to rest there otherwise. No end node – it answers nothing, it only draws.
*/
function drawBoardGraph() {
const g = graphBuilder();
g.at('start', 'BehaviorStart', 0, 0, {}, [port('grid', 'list'), port('colors', 'list')]);
g.at('loop', 'Repeat', 1, 0, { times: COLS * ROWS });
g.flow('start', 'loop');
g.at('cell', 'ItemAt', 2, 4);
g.at('filled', 'Compare', 3, 4, { op: '>=', b: 0 });
const object = objectOfRound(g, 'c', 'loop', 'c', 2, 6);
g.at('show', 'Properties', 6, 0, propertiesOf('sprite'), [written('enabled', 'boolean')]);
g.at('br', 'Branch', 7, 0);
g.at('color', 'ItemAt', 7, 4);
g.at('paint', 'Properties', 8, 0, propertiesOf('sprite'), [written('color', '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', 'color');
g.flow('show', 'br');
g.link('br', 'then', 'paint', 'exec');
return g.done();
}
/** Which piece appears next and in which column: one of the seven, centred over the well. */
function spawnPieceGraph() {
const g = graphBuilder();
g.at('start', 'BehaviorStart', 0, 0, {}, [port('spawnX', 'list')]);
g.at('random', 'Random', 1, 2, { min: 0, max: SHAPES.length });
g.at('shape', 'Floor', 2, 2);
g.at('column', 'ItemAt', 3, 2);
// Handed back as a finished piece rather than as parts the caller has to assemble: the two
// fields it does not choose are the ones a new piece is defined to start at.
g.at('piece', 'PackStruct', 4, 2, { rot: 0, y: 0 }, PIECE_FIELDS);
g.at('end', 'BehaviorEnd', 5, 0, {}, [port('piece', 'struct')]);
g.link('random', 'result', 'shape', 'value');
g.link('start', 'spawnX', 'column', 'list');
g.link('shape', 'result', 'column', 'index');
g.link('shape', 'result', 'piece', 'shape');
g.link('column', 'item', 'piece', 'x');
g.link('piece', 'value', 'end', 'piece');
g.flow('start', 'end');
return g.done();
}
const NEXT_MOVE_VARS = [
variable('next', 'struct', NO_PIECE, PIECE_FIELDS),
variable('kind', 'text', ''),
variable('outFall', 'number', 0),
variable('outDx', 'number', 0),
variable('outRot', 'number', 0),
];
/**
* The one move a frame attempts, and nothing else: turning beats moving beats falling. It only
* proposes – whether the piece may go there is `Fits`, one step later. A wish that was granted
* comes back as 0, which is how a key press is spent exactly once.
*/
function nextMoveGraph() {
const g = graphBuilder(NEXT_MOVE_VARS);
const { at, link, flow, get, set, sets } = g;
at('start', 'BehaviorStart', 0, 0, {}, [
port('piece', 'struct'),
port('wantDx', 'number'),
port('wantRot', 'number'),
port('fall', 'number'),
port('dt', 'number'),
port('stepTime', 'number'),
]);
// The frame starts out having decided nothing: the candidate is the piece itself and no wish has
// been used up. Each strand below then packs only the field it changes, because writing a bundle
// keeps the fields it does not name – "the same piece, one row lower" needs no copying.
at('of', 'ExpandStruct', 1, 3, {}, PIECE_FIELDS);
link('start', 'piece', 'of', 'value');
sets('i-vars', { next: LINKED, kind: '', outFall: LINKED, outDx: LINKED, outRot: LINKED }, 1, 0);
link('start', 'piece', 'i-vars', 'next');
link('start', 'fall', 'i-vars', 'outFall');
link('start', 'wantDx', 'i-vars', 'outDx');
link('start', 'wantRot', 'i-vars', 'outRot');
flow('start', 'i-vars', 't-br');
// Turning wins: a quarter turn clockwise is one step around the four rotations of the shape.
g.strand();
at('t-want', 'Compare', 0, 3, { op: '>', b: 0 });
at('t-br', 'Branch', 0, 0);
at('t-plus1', 'Add', 1, 3, { b: 1 });
at('t-wrap', 'Modulo', 2, 3, { b: 4 });
at('t-pack', 'PackStruct', 3, 3, {}, [field('rot')]);
sets('t-vars', { next: LINKED, kind: 'turn', outRot: 0 }, 1, 0);
link('start', 'wantRot', 't-want', 'a');
link('t-want', 'result', 't-br', 'condition');
link('t-br', 'then', 't-vars', 'exec');
link('of', 'rot', 't-plus1', 'a');
link('t-plus1', 'result', 't-wrap', 'a');
link('t-wrap', 'result', 't-pack', 'rot');
link('t-pack', 'value', 't-vars', 'next');
flow('t-vars', 'end');
// Then sideways, one column per key press.
at('m-still', 'Equals', 4, 3, { b: 0 });
at('m-moving', 'Not', 5, 3);
at('m-br', 'Branch', 5, 0);
at('m-shift', 'Add', 6, 3);
at('m-pack', 'PackStruct', 7, 3, {}, [field('x')]);
sets('m-vars', { next: LINKED, kind: 'move', outDx: 0 }, 6, 0);
link('t-br', 'else', 'm-br', 'exec');
link('start', 'wantDx', 'm-still', 'a');
link('m-still', 'result', 'm-moving', 'value');
link('m-moving', 'result', 'm-br', 'condition');
link('m-br', 'then', 'm-vars', 'exec');
link('of', 'x', 'm-shift', 'a');
link('start', 'wantDx', 'm-shift', 'b');
link('m-shift', 'result', 'm-pack', 'x');
link('m-pack', 'value', 'm-vars', 'next');
flow('m-vars', 'end');
// And otherwise the clock: holding the down key lets it run ten times as fast.
g.strand();
at('f-down', 'KeyIsDown', 0, 4, { key: 'ArrowDown' });
at('f-boost', 'Multiply', 1, 4, { b: 9 });
at('f-rate', 'Add', 2, 4, { a: 1 });
at('f-step', 'Multiply', 3, 3);
at('f-add', 'Add', 4, 3);
set('f-set', 'outFall', 0, 0);
link('m-br', 'else', 'f-set', 'exec');
link('f-down', 'down', 'f-boost', 'a');
link('f-boost', 'result', 'f-rate', 'b');
link('start', 'dt', 'f-step', 'a');
link('f-rate', 'result', 'f-step', 'b');
link('start', 'fall', 'f-add', 'a');
link('f-step', 'result', 'f-add', 'b');
link('f-add', 'result', 'f-set', 'outFall');
get('f-fall', 'outFall', 5, 3);
at('f-due', 'Compare', 6, 3, { op: '>=' });
at('f-br', 'Branch', 5, 0);
at('f-minus', 'Subtract', 7, 3);
at('f-down1', 'Add', 7, 5, { b: 1 });
at('f-pack', 'PackStruct', 8, 5, {}, [field('y')]);
sets('f-vars', { outFall: LINKED, next: LINKED, kind: 'fall' }, 6, 0);
flow('f-set', 'f-br');
link('f-fall', 'outFall', 'f-due', 'a');
link('start', 'stepTime', 'f-due', 'b');
link('f-due', 'result', 'f-br', 'condition');
link('f-br', 'then', 'f-vars', 'exec');
link('f-fall', 'outFall', 'f-minus', 'a');
link('start', 'stepTime', 'f-minus', 'b');
link('f-minus', 'result', 'f-vars', 'outFall');
link('of', 'y', 'f-down1', 'a');
link('f-down1', 'result', 'f-pack', 'y');
link('f-pack', 'value', 'f-vars', 'next');
flow('f-vars', 'end');
// Not due yet: nothing moves, and "kind" stays empty so the fit test below changes nothing.
link('f-br', 'else', 'end', 'exec');
// Whatever the frame decided is handed back – the caller owns the variables, not this behavior.
g.strand();
at('end', 'BehaviorEnd', 2, 0, {}, [
port('next', 'struct'),
port('kind', 'text'),
port('fall', 'number'),
port('wantDx', 'number'),
port('wantRot', 'number'),
]);
const back = [
['next', 'next'],
['kind', 'kind'],
['outFall', 'fall'],
['outDx', 'wantDx'],
['outRot', 'wantRot'],
];
g.gets(
'e-vars',
back.map(([key]) => key),
0,
2,
);
for (const [key, portKey] of back) link('e-vars', key, 'end', portKey);
return g.done();
}
/**
* Moves the four cell objects of the falling piece to where the piece is. This is the only place
* that touches them, so where they stand *is* where the piece is – which is what `Lock piece`
* reads back instead of working the shape out a second time.
*/
function drawPieceGraph() {
const g = graphBuilder();
const { at, link, flow } = g;
at('start', 'BehaviorStart', 0, 0, {}, [
port('shapes', 'list'),
port('piece', 'struct'),
port('colors', 'list'),
]);
at('loop', 'Repeat', 1, 0, { times: 4 });
at('cell', 'RunBehavior', 2, 0, { behavior: 'piece-cell' });
flow('start', 'loop');
link('loop', 'body', 'cell', 'exec');
for (const key of ['shapes', 'piece']) link('start', key, 'cell', key);
link('loop', 'index', 'cell', 'corner');
// Only the shape number is wanted here, for the colour.
at('of', 'ExpandStruct', 2, 10, {}, [field('shape')]);
link('start', 'piece', 'of', 'value');
const object = objectOfRound(g, 'p', 'loop', 'p', 3, 12);
at('x', 'Multiply', 3, 4, { b: CELL });
at('atx', 'Add', 4, 4, { b: CELL_X(0) });
at('y', 'Multiply', 3, 6, { b: CELL });
at('aty', 'Add', 4, 6, { b: CELL_Y(0) });
at('color', 'ItemAt', 4, 8);
link('cell', 'x', 'x', 'a');
link('x', 'result', 'atx', 'a');
link('cell', 'y', 'y', 'a');
link('y', 'result', 'aty', 'a');
link('start', 'colors', 'color', 'list');
link('of', 'shape', 'color', 'index');
at('place', 'SetPosition', 6, 0);
at('paint', 'Properties', 7, 0, { ...propertiesOf('sprite'), enabled: true }, [
written('enabled', 'boolean'),
written('color', 'color'),
]);
link('cell', 'exec', 'place', 'exec');
for (const id of ['place', 'paint']) link(object, 'object', id, 'object');
link('atx', 'result', 'place', 'x');
link('aty', 'result', 'place', 'y');
link('color', 'item', 'paint', 'color');
flow('place', 'paint');
return g.done();
}
/** Writes the four cells of the landed piece into the board, read back from where they stand. */
const LOCK_PIECE_VARS = [variable('board', 'list', [])];
function lockPieceGraph() {
const g = graphBuilder(LOCK_PIECE_VARS);
const { at, link, flow, get, set } = g;
at('start', 'BehaviorStart', 0, 0, {}, [port('grid', 'list'), port('shape', 'number')]);
set('take', 'board', 1, 0);
at('loop', 'Repeat', 2, 0, { times: 4 });
link('start', 'grid', 'take', 'board');
flow('start', 'take', 'loop');
const object = objectOfRound(g, 'p', 'loop', 'p', 3, 4);
at('pos', 'GetPosition', 6, 4);
at('offx', 'Add', 7, 3, { b: -CELL_X(0) });
at('offy', 'Add', 7, 6, { b: -CELL_Y(0) });
at('col', 'Divide', 8, 3, { b: CELL });
at('rowof', 'Divide', 8, 6, { b: CELL });
at('colr', 'Round', 9, 3);
at('rowr', 'Round', 9, 6);
link(object, 'object', 'pos', 'object');
link('pos', 'x', 'offx', 'a');
link('pos', 'y', 'offy', 'a');
link('offx', 'result', 'col', 'a');
link('offy', 'result', 'rowof', 'a');
link('col', 'result', 'colr', 'value');
link('rowof', 'result', 'rowr', 'value');
at('row10', 'Multiply', 10, 6, { b: COLS });
at('index', 'Add', 11, 5);
get('board', 'board', 11, 8);
at('write', 'SetItem', 12, 6);
set('keep', 'board', 3, 0);
link('rowr', 'result', 'row10', 'a');
link('row10', 'result', 'index', 'a');
link('colr', 'result', 'index', 'b');
link('board', 'board', 'write', 'list');
link('index', 'result', 'write', 'index');
link('start', 'shape', 'write', 'value');
link('write', 'result', 'keep', 'board');
link('loop', 'body', 'keep', 'exec');
get('done', 'board', 4, 3);
at('end', 'BehaviorEnd', 4, 0, {}, [port('grid', 'list')]);
link('loop', 'done', 'end', 'exec');
link('done', 'board', 'end', 'grid');
return g.done();
}
/** The one place that writes the counter on screen. */
function showLinesGraph() {
const g = graphBuilder();
g.at('start', 'BehaviorStart', 0, 0, {}, [port('lines', 'number')]);
g.at('number', 'ToText', 1, 3, { decimals: 0 });
g.at('caption', 'Join', 2, 3, { a: 'Lines ' });
g.at('label', 'ObjectByName', 1, 5, { name: 'LinesLabel' });
g.at('write', 'Properties', 3, 0, propertiesOf('text'), [written('text', 'text')]);
g.link('start', 'lines', 'number', 'value');
g.link('number', 'text', 'caption', 'b');
g.link('caption', 'text', 'write', 'text');
g.link('label', 'object', 'write', 'object');
g.flow('start', 'write');
return g.done();
}
/**
* The master graph. It owns the state and does none of the work: every step below is a behavior of
* its own, called as a node. What is left here is the order the steps run in and which variable
* each answer lands in.
*/
function tetrisGraph() {
const g = graphBuilder(tetrisVariables);
const { at, link, flow, get, set, gets, sets, strand } = g;
// 1) On start: an empty board, no piece, nothing pressed. The world is rebuilt for every run, so
// this only matters when the behavior was left running in the editor.
strand();
at('s-start', 'OnStart', 0, 0);
at('s-empty', 'Fill', 0, 3, { value: EMPTY, count: COLS * ROWS });
sets('s-vars', { grid: LINKED, lines: 0, hasPiece: false, fall: 0, wantDx: 0, wantRot: 0 }, 1, 0);
at('s-show', 'RunBehavior', 2, 0, { behavior: 'show-lines', lines: 0 });
link('s-empty', 'result', 's-vars', 'grid');
flow('s-start', 's-vars', 's-show');
// 2) The keys do not move anything themselves – they leave a wish that the next frame grants. That
// way every attempt to move goes through the one test below, whatever asked for it.
strand();
at('k-left', 'OnKeyDown', 0, 0, { key: 'ArrowLeft' });
set('k-setleft', 'wantDx', 1, 0, -1);
at('k-right', 'OnKeyDown', 0, 3, { key: 'ArrowRight' });
set('k-setright', 'wantDx', 1, 3, 1);
at('k-up', 'OnKeyDown', 0, 6, { key: 'ArrowUp' });
set('k-setup', 'wantRot', 1, 6, 1);
flow('k-left', 'k-setleft');
flow('k-right', 'k-setright');
flow('k-up', 'k-setup');
// 3) Every frame does one thing: either a new piece appears, or the piece attempts one move.
strand();
at('u-update', 'OnUpdate', 0, 0);
get('u-has', 'hasPiece', 0, 2);
at('u-br', 'Branch', 1, 0);
flow('u-update', 'u-br');
link('u-has', 'hasPiece', 'u-br', 'condition');
// 3a) No piece: ask for one and write down what came back.
at('n-spawn', 'RunBehavior', 2, 5, { behavior: 'spawn-piece' });
get('n-columns', 'spawnX', 2, 8);
sets('n-vars', { piece: LINKED, fall: 0, hasPiece: true }, 3, 5);
link('u-br', 'else', 'n-spawn', 'exec');
link('n-columns', 'spawnX', 'n-spawn', 'spawnX');
link('n-spawn', 'piece', 'n-vars', 'piece');
flow('n-spawn', 'n-vars');
// 3b) A piece is falling: one behavior decides which single move this frame attempts.
strand();
at('c-move', 'RunBehavior', 0, 0, { behavior: 'next-move' });
link('u-br', 'then', 'c-move', 'exec');
// Every port of the call is a variable of the same name, so one node stands at each side of it.
const moveIn = ['piece', 'wantDx', 'wantRot', 'fall', 'stepTime'];
gets('c-in', moveIn, 0, 3);
for (const key of moveIn) link('c-in', key, 'c-move', key);
link('u-update', 'dt', 'c-move', 'dt');
const moveOut = ['next', 'kind', 'fall', 'wantDx', 'wantRot'];
sets('c-out', moveOut, 1, 0);
for (const key of moveOut) link('c-move', key, 'c-out', key);
flow('c-move', 'c-out');
// 4) The one test, and it is one node: does the candidate fit? Every attempt above ends here.
strand();
at('t-fits', 'RunBehavior', 0, 0, { behavior: 'fits' });
gets('t-in', ['shapes', 'grid', 'next'], 0, 3);
flow('c-out', 't-fits');
link('t-in', 'shapes', 't-fits', 'shapes');
link('t-in', 'grid', 't-fits', 'grid');
link('t-in', 'next', 't-fits', 'piece');
// 5) It fits: the candidate becomes the piece. It does not: only a fall means anything – the
// piece has landed (or, still at the very top, the well is full).
at('m-br', 'Branch', 1, 0);
// `piece` is not in here: it is written a line below, and `m-py2` has to see that write.
gets('m-in', ['next', 'kind'], 2, 3);
set('m-piece', 'piece', 2, 0);
flow('t-fits', 'm-br');
link('t-fits', 'ok', 'm-br', 'condition');
link('m-br', 'then', 'm-piece', 'exec');
link('m-in', 'next', 'm-piece', 'piece');
at('m-landed', 'Equals', 3, 7, { b: 'fall' });
at('m-brland', 'Branch', 3, 5);
get('m-py2', 'piece', 4, 8);
at('m-pieceY', 'ExpandStruct', 5, 10, {}, [field('y')]);
at('m-attop', 'Compare', 6, 8, { op: '<=', b: 0 });
at('m-brover', 'Branch', 5, 5);
at('m-stop', 'StopGame', 6, 5);
link('m-br', 'else', 'm-brland', 'exec');
link('m-in', 'kind', 'm-landed', 'a');
link('m-landed', 'result', 'm-brland', 'condition');
link('m-brland', 'then', 'm-brover', 'exec');
link('m-py2', 'piece', 'm-pieceY', 'value');
link('m-pieceY', 'y', 'm-attop', 'a');
link('m-attop', 'result', 'm-brover', 'condition');
link('m-brover', 'then', 'm-stop', 'exec');
// 6) Wherever the piece ended up – newly spawned or moved – it is drawn there.
strand();
at('d-draw', 'RunBehavior', 0, 0, { behavior: 'draw-piece' });
flow('m-piece', 'd-draw');
flow('n-vars', 'd-draw');
const drawIn = ['shapes', 'piece', 'colors'];
gets('d-in', drawIn, 0, 3);
for (const key of drawIn) link('d-in', key, 'd-draw', key);
// 7) It landed: it becomes part of the board, full lines go, the counter and the board follow.
strand();
at('l-lock', 'RunBehavior', 0, 0, { behavior: 'lock-piece' });
gets('l-in', ['grid', 'piece'], 0, 3);
at('l-shape', 'ExpandStruct', 0, 7, {}, [field('shape')]);
set('l-setgrid', 'grid', 1, 0);
link('m-brover', 'else', 'l-lock', 'exec');
link('l-in', 'grid', 'l-lock', 'grid');
link('l-in', 'piece', 'l-shape', 'value');
link('l-shape', 'shape', 'l-lock', 'shape');
link('l-lock', 'grid', 'l-setgrid', 'grid');
flow('l-lock', 'l-setgrid');
at('r-clear', 'RunBehavior', 2, 0, { behavior: 'clear-lines' });
gets('r-in', ['grid', 'lines'], 2, 3);
sets('r-out', ['grid', 'lines'], 3, 0);
link('r-in', 'grid', 'r-clear', 'grid');
link('r-in', 'lines', 'r-clear', 'lines');
link('r-clear', 'grid', 'r-out', 'grid');
link('r-clear', 'lines', 'r-out', 'lines');
flow('l-setgrid', 'r-clear', 'r-out');
at('b-show', 'RunBehavior', 5, 0, { behavior: 'show-lines' });
at('b-draw', 'RunBehavior', 6, 0, { behavior: 'draw-board' });
// A second reader of `grid` and `lines`, because `r-out` wrote them since `r-in` was asked.
gets('b-in', ['lines', 'grid', 'colors'], 5, 3);
set('b-nopiece', 'hasPiece', 7, 0, false);
link('b-in', 'lines', 'b-show', 'lines');
link('b-in', 'grid', 'b-draw', 'grid');
link('b-in', 'colors', 'b-draw', 'colors');
// The board is drawn and the piece is part of it now: the next frame draws a new one.
flow('r-out', 'b-show', 'b-draw', 'b-nopiece');
return g.done();
}
const label = (id, name, x, y, text, size, color, align = 'center') =>
sceneObject(id, 'label', { name, x, y, overrides: { text, fontSize: size, color, align } });
/** A behavior asset whose `props` are exactly the declared variables' defaults. */
function behavior(id, name, variables, graph) {
const props = {};
for (const v of variables) props[v.key] = v.default;
return { id, type: 'behavior', name, props, variables, graph };
}
const tetrisVariables = [
variable('grid', 'list', EMPTY_GRID),
variable('shapes', 'list', shapeTable()),
// Tagged, because a list says nothing about its items and a colour is a number like any other.
variable(
'colors',
'list',
SHAPE_COLORS.map((color) => tagged('color', color)),
),
variable('spawnX', 'list', spawnColumns()),
// The falling piece and the one move being tried on it – four numbers each, and never apart.
variable('piece', 'struct', NO_PIECE, PIECE_FIELDS),
variable('next', 'struct', NO_PIECE, PIECE_FIELDS),
variable('kind', 'text', ''),
variable('hasPiece', 'boolean', false),
variable('fall', 'number', 0),
variable('stepTime', 'number', 0.5),
variable('lines', 'number', 0),
variable('wantDx', 'number', 0),
variable('wantRot', 'number', 0),
];
const project = {
formatVersion: 2,
id: projectId,
name: 'Tetris',
activeSceneId: 'scene1',
assets: [
{
id: 'scene1',
type: 'scene',
name: 'Tetris',
props: { backgroundColor: BACKGROUND },
objects: [
sceneObject('oboard', 'board', { name: 'Board', x: 150 }),
label('otitle', 'Title', -110, -110, 'TETRIS', 44, WHITE),
label('olines', 'LinesLabel', -110, -40, 'Lines 0', 26, WHITE),
label('ohint1', 'Hint', -110, 60, 'Left / Right · move', 16, HINT),
label('ohint2', 'Hint 2', -110, 84, 'Up · turn', 16, HINT),
label('ohint3', 'Hint 3', -110, 108, 'Down · drop faster', 16, HINT),
sceneObject('ocam', 'cam', { name: 'Main Camera' }),
sceneObject('ogame', 'tetris', { name: 'Tetris', x: -260, y: 150 }),
],
},
{ id: 'cam', type: 'camera', name: 'Main Camera', props: { width: 640, height: 360, zoom: 1 } },
{
id: 'well-sprite',
type: 'sprite',
name: 'Well',
// Wears the shader at its neutral values: the pass runs, the picture stays the same.
props: { width: COLS * CELL, height: ROWS * CELL, color: WELL, shader: 'adjust-colors' },
},
adjustColorsShader(),
{ id: 'cell-sprite', type: 'sprite', name: 'Cell', props: { width: CELL - 1, height: CELL - 1 } },
{
id: 'label',
type: 'text',
name: 'Label',
props: { text: 'Text', fontSize: 26, color: WHITE, align: 'center' },
},
{ id: 'board', type: 'container', name: 'Board', props: {}, objects: boardTemplate },
behavior('piece-cell', 'Piece cell', [], pieceCellGraph()),
behavior('fits', 'Fits', FITS_VARS, fitsGraph()),
behavior('clear-lines', 'Clear lines', CLEAR_LINES_VARS, clearLinesGraph()),
behavior('draw-board', 'Draw board', [], drawBoardGraph()),
behavior('spawn-piece', 'Spawn piece', [], spawnPieceGraph()),
behavior('next-move', 'Next move', NEXT_MOVE_VARS, nextMoveGraph()),
behavior('draw-piece', 'Draw piece', [], drawPieceGraph()),
behavior('lock-piece', 'Lock piece', LOCK_PIECE_VARS, lockPieceGraph()),
behavior('show-lines', 'Show lines', [], showLinesGraph()),
behavior('tetris', 'Tetris', tetrisVariables, tetrisGraph()),
],
};
await writeProject(projectId, baseUrl, project);