Let a list hold structs, and read or write variables in bundles

A list item may now be a struct. The depth is bounded in the declaration
rather than by a comment: FieldKind (number|text|boolean|color) says what a
struct field holds, ListItemKind adds struct on top and says what a list
holds, so list -> struct -> primitive is the deepest storable shape and
sameValue stays finite. A struct item renders inline in the List tab, which
is why it needs no path in the slot address.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-10 14:01:56 +02:00
1 parent 9d62dfea7b
commit f60358c951
32 files changed
+1083 -253

No files matched your search

+77 -1
View File
@@ -110,8 +110,84 @@ export async function listTab(t) {
check('a deleted target empties the tab', info.title === '' && info.items.length === 0, info.title);
await shot('04-empty');
// 8. A list whose items are structs: each row is the bundle's fields under each other, built by
// the inspector's own struct renderer. Values are two digits so no row's index can match one.
await mutate(`
const p = __gm.state.project;
p.assets.push({
id: 'celly',
type: 'behavior',
name: 'Celly',
props: { enabled: true, runInEditor: false, cells: [{ x: 30, y: 11 }, { x: 44, y: 55 }] },
variables: [
{
key: 'cells',
label: 'cells',
kind: 'list',
default: [],
items: 'struct',
fields: [{ key: 'x', kind: 'number' }, { key: 'y', kind: 'number' }],
},
],
graph: { nodes: [], edges: [] },
});
__gm.state.select({ kind: 'asset', assetId: 'celly' });
__gm.state.openList({ kind: 'asset', assetId: 'celly', key: 'cells' });
`);
await sleep(250);
info = await json('__gm.list.info()');
check('a list of structs opens with a row per bundle', info.items.length === 2, JSON.stringify(info));
check(
'and a row reads as its fields, not as an object',
info.items[0] === 'x: 30, y: 11',
JSON.stringify(info.items),
);
check('the tab knows its items are structs', info.kind === 'struct', info.kind);
check('the field labels of a bundle are on screen', (await find('x')) !== null);
await shot('05-struct-rows');
// Editing one field must leave the others and the neighbouring item alone – the whole item is
// rewritten on every keystroke, so a snapshot anywhere would silently revive an old value.
const firstField = await json("__gm.list.fieldAt(0, 'x')");
await click(firstField.x, firstField.y);
await typeText('77');
await press('Enter');
await sleep(250);
const cells = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`);
check(
'editing one field of one item leaves every other value untouched',
JSON.stringify(cells) ===
JSON.stringify([
{ x: 77, y: 11 },
{ x: 44, y: 55 },
]),
JSON.stringify(cells),
);
await undo();
await sleep(200);
const back = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`);
check(
'and undo takes back just that field',
JSON.stringify(back) ===
JSON.stringify([
{ x: 30, y: 11 },
{ x: 44, y: 55 },
]),
JSON.stringify(back),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'celly');
__gm.state.select(null);
__gm.state.preview();
`);
await sleep(200);
await mutate('__gm.state.openList(null)');
await sleep(200);
const left = await json('__gm.state.project.assets.filter((a) => a.id === "listy").length');
const left = await json(
'__gm.state.project.assets.filter((a) => a.id === "listy" || a.id === "celly").length',
);
check('the step leaves the fixture as it found it', left === 0, String(left));
}
+162
View File
@@ -0,0 +1,162 @@
import { edge, node, port, variable } from '../lib/builders.mjs';
/**
* Get variables / Set variables: one node for several of them. Their ports are the behavior's
* variables, so the step checks all three sides of that – the run, the picking of a port from the
* variable list, and pointing an existing port at another variable without losing its edge.
*
* The step brings its own box and behavior; everything it adds is removed at the end.
*/
const SCALE = 100;
/**
* `On update → Set variables{vx = 2, vy = 3} → Move(owner, dx = vx, dy = vy)`, where the two
* numbers reach Move through the *variables* – one node writes both, one node reads both back.
* Neither speed is on the Move node, so a node that dropped a port would leave the box still.
*/
const logicGraph = {
nodes: [
node('vp-update', 'OnUpdate', 0, 0),
node('vp-owner', 'Owner', 0, 300),
node('vp-set', 'SetVariables', 260, 0, { vx: 2, vy: 3 }, [port('vx', 'number'), port('vy', 'number')]),
node('vp-get', 'GetVariables', 260, 180, {}, [port('vx', 'number'), port('vy', 'number')]),
node('vp-move', 'Move', 560, 0, { scale: SCALE }),
],
edges: [
edge('vp-e1', 'vp-update', 'exec', 'vp-set', 'exec'),
edge('vp-e2', 'vp-set', 'exec', 'vp-move', 'exec'),
edge('vp-e3', 'vp-get', 'vx', 'vp-move', 'dx'),
edge('vp-e4', 'vp-get', 'vy', 'vp-move', 'dy'),
edge('vp-e5', 'vp-update', 'dt', 'vp-move', 'dt'),
edge('vp-e6', 'vp-owner', 'object', 'vp-move', 'object'),
],
};
export async function variableNodes(t) {
const { check, sleep, evaluate, json, shot, click, locate, undo, entity, asset, mutate, stop } = t;
const portAt = (nodeId, key, isOutput) =>
json(`__gm.graph.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`);
const portsOf = async (nodeId) =>
(await asset('vp-logic')).graph.nodes.find((n) => n.id === nodeId).ports ?? [];
const select = async (nodeId) => {
await evaluate(
`__gm.state.select({ kind: 'node', assetId: 'vp-logic', nodeId: ${JSON.stringify(nodeId)}, nodeIds: [${JSON.stringify(nodeId)}] })`,
);
await sleep(300);
};
/** The inspector is drawn over the graph, so the last match is the row rather than the port. */
const clickLast = async (text) => {
const p = await locate(text, true);
await click(p.x, p.y);
await sleep(200);
};
// 23a. A box wearing a behavior whose two speeds travel through its variables.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const proto = scene.objects[0];
const mover = p.assets.find((a) => a.id === 'mover');
const a = structuredClone(mover);
Object.assign(a, {
id: 'vp-logic',
name: 'Speeds',
props: { enabled: true, runInEditor: false, vx: 0, vy: 0, vz: 0 },
variables: ${JSON.stringify([variable('vx', 'number', 0), variable('vy', 'number', 0), variable('vz', 'number', 0)])},
graph: ${JSON.stringify(logicGraph)},
});
p.assets.unshift(a);
const make = (id, assetId, parentId, name) => {
const o = structuredClone(proto);
Object.assign(o, {
id, assetId, name,
position: { x: 0, y: 900, z: 0 },
parentId, overrides: {}, instanceEdits: {},
});
return o;
};
scene.objects.push(make('vp-box', 'crate', '', 'Speedy box'), make('vp-brain', 'vp-logic', 'vp-box', 'Speeds'));
`);
await sleep(400);
// 23b. The run: both variables are written by one node and read back by another, and the two
// speeds stay apart – 2 to the right for every 3 down.
await t.play();
await sleep(300);
const before = await entity('vp-box');
await sleep(1000);
const after = await entity('vp-box');
const dx = after.x - before.x;
const dy = after.y - before.y;
check(
'one node writes both variables, another reads both back, and the run uses them',
dx > SCALE * 1.4 && dx < SCALE * 2.6 && dy > SCALE * 2.4 && dy < SCALE * 3.6,
`moved (${dx.toFixed(1)}, ${dy.toFixed(1)}) px in ~1 s – (200, 300) means both ports carried their own variable`,
);
await stop();
await sleep(300);
// 23c. The node draws one port per variable it names, on the side its type says.
await select('vp-set');
const vxIn = await portAt('vp-set', 'vx', false);
const vyIn = await portAt('vp-set', 'vy', false);
const vxOut = await portAt('vp-get', 'vx', true);
check(
'the picked variables are ports: inputs on Set, outputs on Get',
Boolean(vxIn) && Boolean(vyIn) && vyIn.y > vxIn.y && Boolean(vxOut),
JSON.stringify({ vxIn, vyIn, vxOut }),
);
// 23d. The inspector picks a port from the behavior's variables instead of having it typed.
const add = await locate('+ Variable');
check('the node offers its variables in the inspector', Boolean(add), JSON.stringify(add));
await shot('01-variable-ports');
await click(add.x, add.y);
await sleep(200);
await clickLast('vz');
await sleep(300);
const grown = await portsOf('vp-set');
check(
'picking one adds it as a port, typed from the variable',
grown.length === 3 && grown[2].key === 'vz' && grown[2].kind === 'number',
JSON.stringify(grown),
);
check('and the node grew that input', Boolean(await portAt('vp-set', 'vz', false)));
await shot('02-variable-added');
await undo();
await sleep(300);
check('undo takes it back', (await portsOf('vp-set')).length === 2);
// 23e. Pointing a port at another variable keeps the edge hanging on it – a correction, not a
// rewiring. `vp-e3` runs from this port to Move's dx.
await select('vp-get');
await clickLast('vx');
await sleep(200);
await clickLast('vz');
await sleep(300);
const swapped = await portsOf('vp-get');
const edgeStill = await json(
`(__gm.state.project.assets.find((a) => a.id === 'vp-logic').graph.edges.find((e) => e.id === 'vp-e3') || {}).from`,
);
check(
'a port pointed at another variable keeps its edge',
swapped[0].key === 'vz' && edgeStill.port === 'vz',
JSON.stringify({ swapped, edgeStill }),
);
await undo();
await sleep(300);
check('and undo points it back', (await portsOf('vp-get'))[0].key === 'vx');
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'vp-logic');
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'vp-box' && o.id !== 'vp-brain');
__gm.state.select(null);
__gm.state.preview();
`);
await sleep(300);
const left = await json('__gm.state.project.assets.filter((a) => a.id === "vp-logic").length');
check('the step leaves the fixture as it found it', left === 0, String(left));
}
+2
View File
@@ -25,6 +25,7 @@ import { nestedBehavior } from './19-nested-behavior.mjs';
import { keyRepeat } from './20-key-repeat.mjs';
import { listTab } from './21-list.mjs';
import { structVariable } from './22-struct.mjs';
import { variableNodes } from './23-variable-nodes.mjs';
export const STEPS = [
hierarchy,
@@ -49,4 +50,5 @@ export const STEPS = [
keyRepeat,
listTab,
structVariable,
variableNodes,
];
+9 -2
View File
@@ -46,11 +46,18 @@ export const node = (id, type, x, y, params = {}, ports) => ({
/** A port a behavior's start or end node declares. */
export const port = (key, kind) => ({ key, kind });
/** A behavior variable declaration. `items` is the item kind of a `list`, ignored otherwise. */
export const variable = (key, kind, value, items) => ({
/**
* A behavior variable declaration. `items` is the item kind of a `list`, `fields` the shape of a
* `struct` – or of the items of a `list` whose items are structs.
*/
export const variable = (key, kind, value, items, fields) => ({
key,
label: key,
kind,
default: value,
...(items ? { items } : {}),
...(fields ? { fields } : {}),
});
/** A struct field declaration, and the same shape a list of structs declares for its items. */
export const field = (key, kind = 'number') => ({ key, kind });
+112 -134
View File
@@ -7,6 +7,11 @@
* empty row joined in front of everything above and below it – with no text encoding in between,
* so the numbers in the inspector are the numbers the game means.
*
* What travels as a bundle rather than as loose numbers: the falling piece (`piece`) and the one
* move being tried on it (`next`) are structs of shape/rot/x/y, and `shapes` is a list of `{x, y}`
* cells. That is what lets `Next move` hand back a whole candidate for `Fits` to judge and the
* master to accept in a single write, and it halves the index arithmetic in `Piece cell`.
*
* The game is ten behaviors rather than one graph. `Tetris` owns the state and does none of the
* work: every step is a behavior of its own, called as a node – `Spawn piece`, `Next move`,
* `Fits`, `Draw piece`, `Lock piece`, `Clear lines`, `Show lines`, `Draw board`, with `Piece cell`
@@ -19,7 +24,7 @@
*
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-tetris.mjs [projectId] [baseUrl]
*/
import { edge, node, port, sceneObject, variable } from './lib/builders.mjs';
import { edge, field, node, port, sceneObject, variable } from './lib/builders.mjs';
import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs';
const { projectId, baseUrl } = cliArgs('seed-tetris.mjs', PROJECTS.tetris);
@@ -123,21 +128,25 @@ const SHAPES = [
const turned = (cells, size) => cells.map(([x, y]) => [size - 1 - y, x]);
/**
* All 28 rotations in one list, eight numbers each (x and y of the four cells), so one rotation
* starts at (shape·4 + rotation)·8 and its n-th cell two numbers further along.
* 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);
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));
@@ -224,41 +233,36 @@ function pieceCellGraph() {
const g = graphBuilder();
g.at('start', 'BehaviorStart', 0, 0, {}, [
port('shapes', 'list'),
port('shape', 'number'),
port('rot', 'number'),
port('piece', 'struct'),
port('corner', 'number'),
port('baseX', 'number'),
port('baseY', 'number'),
]);
g.at('x4', 'Multiply', 1, 2, { b: 4 });
g.at('plus', 'Add', 2, 2);
g.at('x8', 'Multiply', 3, 2, { b: 8 });
g.at('c2', 'Multiply', 1, 5, { b: 2 });
g.at('ix', 'Add', 4, 2);
g.at('iy', 'Add', 4, 5, { b: 1 });
g.at('ax', 'ItemAt', 5, 1);
g.at('ay', 'ItemAt', 5, 5);
g.at('sx', 'Add', 6, 1);
g.at('sy', 'Add', 6, 5);
g.at('end', 'BehaviorEnd', 7, 0, {}, [port('x', 'number'), port('y', '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', 'shape', 'x4', 'a');
g.link('start', 'piece', 'of', 'value');
g.link('of', 'shape', 'x4', 'a');
g.link('x4', 'result', 'plus', 'a');
g.link('start', 'rot', 'plus', 'b');
g.link('plus', 'result', 'x8', 'a');
g.link('start', 'corner', 'c2', 'a');
g.link('x8', 'result', 'ix', 'a');
g.link('c2', 'result', 'ix', 'b');
g.link('ix', 'result', 'iy', 'a');
g.link('start', 'shapes', 'ax', 'list');
g.link('ix', 'result', 'ax', 'index');
g.link('start', 'shapes', 'ay', 'list');
g.link('iy', 'result', 'ay', 'index');
g.link('ax', 'item', 'sx', 'a');
g.link('start', 'baseX', 'sx', 'b');
g.link('ay', 'item', 'sy', 'a');
g.link('start', 'baseY', 'sy', 'b');
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');
@@ -274,10 +278,7 @@ function fitsGraph() {
g.at('start', 'BehaviorStart', 0, 0, {}, [
port('shapes', 'list'),
port('grid', 'list'),
port('shape', 'number'),
port('rot', 'number'),
port('x', 'number'),
port('y', 'number'),
port('piece', 'struct'),
]);
g.set('ok', 'ok', 1, 0, true);
g.at('loop', 'Repeat', 2, 0, { times: 4 });
@@ -285,10 +286,8 @@ function fitsGraph() {
g.at('cell', 'RunBehavior', 3, 0, { behavior: 'piece-cell' });
g.link('loop', 'body', 'cell', 'exec');
for (const key of ['shapes', 'shape', 'rot']) g.link('start', key, 'cell', key);
for (const key of ['shapes', 'piece']) g.link('start', key, 'cell', key);
g.link('loop', 'index', 'cell', 'corner');
g.link('start', 'x', 'cell', 'baseX');
g.link('start', 'y', 'cell', 'baseY');
g.at('left', 'Compare', 4, 3, { op: '<', b: 0 });
g.at('right', 'Compare', 4, 5, { op: '>', b: COLS - 1 });
@@ -446,12 +445,16 @@ function spawnPieceGraph() {
g.at('random', 'Random', 1, 2, { min: 0, max: SHAPES.length });
g.at('shape', 'Floor', 2, 2);
g.at('column', 'ItemAt', 3, 2);
g.at('end', 'BehaviorEnd', 4, 0, {}, [port('shape', 'number'), port('x', 'number')]);
// 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', 'end', 'shape');
g.link('column', 'item', 'end', 'x');
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();
}
@@ -466,9 +469,7 @@ function nextMoveGraph() {
const { at, link, flow, get, set } = g;
at('start', 'BehaviorStart', 0, 0, {}, [
port('px', 'number'),
port('py', 'number'),
port('rot', 'number'),
port('piece', 'struct'),
port('wantDx', 'number'),
port('wantRot', 'number'),
port('fall', 'number'),
@@ -476,21 +477,21 @@ function nextMoveGraph() {
port('stepTime', 'number'),
]);
// The candidate starts out as where the piece already is, and no wish is spent yet.
set('i-nx', 'nx', 1, 0);
set('i-ny', 'ny', 2, 0);
set('i-nrot', 'nrot', 3, 0);
set('i-kind', 'kind', 4, 0, '');
set('i-fall', 'outFall', 5, 0);
set('i-dx', 'outDx', 6, 0);
set('i-rot', 'outRot', 7, 0);
link('start', 'px', 'i-nx', 'value');
link('start', 'py', 'i-ny', 'value');
link('start', 'rot', 'i-nrot', 'value');
// The candidate starts out as the piece itself – one write, because it is one value. Each
// strand below then packs only the field it changes: writing a bundle keeps the fields it
// does not name, so "the same piece, one row lower" needs no copying.
at('of', 'ExpandStruct', 1, 3, {}, PIECE_FIELDS);
link('start', 'piece', 'of', 'value');
set('i-next', 'next', 1, 0);
set('i-kind', 'kind', 2, 0, '');
set('i-fall', 'outFall', 3, 0);
set('i-dx', 'outDx', 4, 0);
set('i-rot', 'outRot', 5, 0);
link('start', 'piece', 'i-next', 'value');
link('start', 'fall', 'i-fall', 'value');
link('start', 'wantDx', 'i-dx', 'value');
link('start', 'wantRot', 'i-rot', 'value');
flow('start', 'i-nx', 'i-ny', 'i-nrot', 'i-kind', 'i-fall', 'i-dx', 'i-rot', 't-br');
flow('start', 'i-next', 'i-kind', 'i-fall', 'i-dx', 'i-rot', 't-br');
// Turning wins: a quarter turn clockwise is one step around the four rotations of the shape.
g.strand();
@@ -498,15 +499,17 @@ function nextMoveGraph() {
at('t-br', 'Branch', 0, 0);
at('t-plus1', 'Add', 1, 3, { b: 1 });
at('t-wrap', 'Modulo', 2, 3, { b: 4 });
set('t-set', 'nrot', 1, 0);
at('t-pack', 'PackStruct', 3, 3, {}, [field('rot')]);
set('t-set', 'next', 1, 0);
set('t-kind', 'kind', 2, 0, 'turn');
set('t-used', 'outRot', 3, 0, 0);
link('start', 'wantRot', 't-want', 'a');
link('t-want', 'result', 't-br', 'condition');
link('t-br', 'then', 't-set', 'exec');
link('start', 'rot', 't-plus1', 'a');
link('of', 'rot', 't-plus1', 'a');
link('t-plus1', 'result', 't-wrap', 'a');
link('t-wrap', 'result', 't-set', 'value');
link('t-wrap', 'result', 't-pack', 'rot');
link('t-pack', 'value', 't-set', 'value');
flow('t-set', 't-kind', 't-used', 'end');
// Then sideways, one column per key press.
@@ -514,7 +517,8 @@ function nextMoveGraph() {
at('m-moving', 'Not', 5, 3);
at('m-br', 'Branch', 5, 0);
at('m-shift', 'Add', 6, 3);
set('m-set', 'nx', 6, 0);
at('m-pack', 'PackStruct', 7, 3, {}, [field('x')]);
set('m-set', 'next', 6, 0);
set('m-kind', 'kind', 7, 0, 'move');
set('m-used', 'outDx', 8, 0, 0);
link('t-br', 'else', 'm-br', 'exec');
@@ -522,9 +526,10 @@ function nextMoveGraph() {
link('m-still', 'result', 'm-moving', 'value');
link('m-moving', 'result', 'm-br', 'condition');
link('m-br', 'then', 'm-set', 'exec');
link('start', 'px', 'm-shift', 'a');
link('of', 'x', 'm-shift', 'a');
link('start', 'wantDx', 'm-shift', 'b');
link('m-shift', 'result', 'm-set', 'value');
link('m-shift', 'result', 'm-pack', 'x');
link('m-pack', 'value', 'm-set', 'value');
flow('m-set', 'm-kind', 'm-used', 'end');
// And otherwise the clock: holding the down key lets it run ten times as fast.
@@ -550,7 +555,8 @@ function nextMoveGraph() {
at('f-minus', 'Subtract', 7, 3);
set('f-rest', 'outFall', 6, 0);
at('f-down1', 'Add', 7, 5, { b: 1 });
set('f-ny', 'ny', 7, 0);
at('f-pack', 'PackStruct', 8, 5, {}, [field('y')]);
set('f-ny', 'next', 7, 0);
set('f-kind', 'kind', 8, 0, 'fall');
flow('f-set', 'f-br');
link('f-fall', 'value', 'f-due', 'a');
@@ -560,8 +566,9 @@ function nextMoveGraph() {
link('f-fall', 'value', 'f-minus', 'a');
link('start', 'stepTime', 'f-minus', 'b');
link('f-minus', 'result', 'f-rest', 'value');
link('start', 'py', 'f-down1', 'a');
link('f-down1', 'result', 'f-ny', 'value');
link('of', 'y', 'f-down1', 'a');
link('f-down1', 'result', 'f-pack', 'y');
link('f-pack', 'value', 'f-ny', 'value');
flow('f-rest', 'f-ny', 'f-kind', 'end');
// Not due yet: nothing moves, and "kind" stays empty so the fit test below changes nothing.
link('f-br', 'else', 'end', 'exec');
@@ -569,18 +576,14 @@ function nextMoveGraph() {
// Whatever the frame decided is handed back – the caller owns the variables, not this behavior.
g.strand();
at('end', 'BehaviorEnd', 2, 0, {}, [
port('nx', 'number'),
port('ny', 'number'),
port('nrot', 'number'),
port('next', 'struct'),
port('kind', 'text'),
port('fall', 'number'),
port('wantDx', 'number'),
port('wantRot', 'number'),
]);
const back = [
['e-nx', 'nx', 'nx'],
['e-ny', 'ny', 'ny'],
['e-nrot', 'nrot', 'nrot'],
['e-next', 'next', 'next'],
['e-kind', 'kind', 'kind'],
['e-fall', 'outFall', 'fall'],
['e-dx', 'outDx', 'wantDx'],
@@ -604,20 +607,18 @@ function drawPieceGraph() {
at('start', 'BehaviorStart', 0, 0, {}, [
port('shapes', 'list'),
port('shape', 'number'),
port('rot', 'number'),
port('px', 'number'),
port('py', 'number'),
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', 'shape', 'rot']) link('start', key, 'cell', key);
for (const key of ['shapes', 'piece']) link('start', key, 'cell', key);
link('loop', 'index', 'cell', 'corner');
link('start', 'px', 'cell', 'baseX');
link('start', 'py', 'cell', 'baseY');
// 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 });
@@ -630,7 +631,7 @@ function drawPieceGraph() {
link('cell', 'y', 'y', 'a');
link('y', 'result', 'aty', 'a');
link('start', 'colors', 'color', 'list');
link('start', 'shape', 'color', 'index');
link('of', 'shape', 'color', 'index');
at('show', 'SetEnabled', 6, 0, { enabled: true });
at('place', 'SetPosition', 7, 0);
@@ -756,26 +757,20 @@ function tetrisGraph() {
// 3a) No piece: ask for one and write down what came back.
at('n-spawn', 'RunBehavior', 2, 5, { behavior: 'spawn-piece' });
get('n-columns', 'spawnX', 2, 8);
set('n-shape', 'shape', 3, 5);
set('n-rot', 'rot', 4, 5, 0);
set('n-px', 'px', 5, 5);
set('n-py', 'py', 6, 5, 0);
set('n-fall', 'fall', 7, 5, 0);
set('n-has', 'hasPiece', 8, 5, true);
set('n-piece', 'piece', 3, 5);
set('n-fall', 'fall', 4, 5, 0);
set('n-has', 'hasPiece', 5, 5, true);
link('u-br', 'else', 'n-spawn', 'exec');
link('n-columns', 'value', 'n-spawn', 'spawnX');
link('n-spawn', 'shape', 'n-shape', 'value');
link('n-spawn', 'x', 'n-px', 'value');
flow('n-spawn', 'n-shape', 'n-rot', 'n-px', 'n-py', 'n-fall', 'n-has');
link('n-spawn', 'piece', 'n-piece', 'value');
flow('n-spawn', 'n-piece', 'n-fall', 'n-has');
// 3b) A piece is falling: one behavior decides which single move this frame attempts.
strand();
at('c-move', 'RunBehavior', 0, 0, { behavior: 'next-move' });
link('u-br', 'then', 'c-move', 'exec');
const moveIn = [
['px', 'px'],
['py', 'py'],
['rot', 'rot'],
['piece', 'piece'],
['wantDx', 'wantDx'],
['wantRot', 'wantRot'],
['fall', 'fall'],
@@ -788,9 +783,7 @@ function tetrisGraph() {
link('u-update', 'dt', 'c-move', 'dt');
const moveOut = [
['nx', 'nx'],
['ny', 'ny'],
['nrot', 'nrot'],
['next', 'next'],
['kind', 'kind'],
['fall', 'fall'],
['wantDx', 'wantDx'],
@@ -807,56 +800,45 @@ function tetrisGraph() {
at('t-fits', 'RunBehavior', 0, 0, { behavior: 'fits' });
get('t-shapes', 'shapes', 0, 3);
get('t-grid', 'grid', 0, 5);
get('t-shape', 'shape', 0, 7);
get('t-nrot', 'nrot', 0, 9);
get('t-nx', 'nx', 0, 11);
get('t-ny', 'ny', 0, 13);
get('t-next', 'next', 0, 7);
flow('c-out-wantRot', 't-fits');
link('t-shapes', 'value', 't-fits', 'shapes');
link('t-grid', 'value', 't-fits', 'grid');
link('t-shape', 'value', 't-fits', 'shape');
link('t-nrot', 'value', 't-fits', 'rot');
link('t-nx', 'value', 't-fits', 'x');
link('t-ny', 'value', 't-fits', 'y');
link('t-next', 'value', 't-fits', 'piece');
// 5) It fits: the candidate becomes the piece. It does not: only a fall means anything – the
// piece has landed (or, still at the very top, the well is full).
at('m-br', 'Branch', 1, 0);
get('m-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);
get('m-next', 'next', 2, 3);
set('m-piece', 'piece', 2, 0);
flow('t-fits', 'm-br');
link('t-fits', 'ok', 'm-br', 'condition');
link('m-br', 'then', 'm-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');
link('m-br', 'then', 'm-piece', 'exec');
link('m-next', 'value', 'm-piece', 'value');
get('m-kind', 'kind', 2, 7);
at('m-landed', 'Equals', 3, 7, { b: 'fall' });
at('m-brland', 'Branch', 3, 5);
get('m-py2', 'py', 4, 8);
at('m-attop', 'Compare', 5, 8, { op: '<=', b: 0 });
get('m-py2', 'piece', 4, 8);
at('m-pieceY', 'ExpandStruct', 5, 10, {}, [field('y')]);
at('m-attop', 'Compare', 6, 8, { op: '<=', b: 0 });
at('m-brover', 'Branch', 5, 5);
at('m-stop', 'StopGame', 6, 5);
link('m-br', 'else', 'm-brland', 'exec');
link('m-kind', 'value', 'm-landed', 'a');
link('m-landed', 'result', 'm-brland', 'condition');
link('m-brland', 'then', 'm-brover', 'exec');
link('m-py2', 'value', 'm-attop', 'a');
link('m-py2', 'value', 'm-pieceY', 'value');
link('m-pieceY', 'y', 'm-attop', 'a');
link('m-attop', 'result', 'm-brover', 'condition');
link('m-brover', 'then', 'm-stop', 'exec');
// 6) Wherever the piece ended up – newly spawned or moved – it is drawn there.
strand();
at('d-draw', 'RunBehavior', 0, 0, { behavior: 'draw-piece' });
flow('m-rot', 'd-draw');
flow('m-piece', 'd-draw');
flow('n-has', 'd-draw');
['shapes', 'shape', 'rot', 'px', 'py', 'colors'].forEach((key, i) => {
['shapes', 'piece', 'colors'].forEach((key, i) => {
get(`d-${key}`, key, 0, i * 2 + 3);
link(`d-${key}`, 'value', 'd-draw', key);
});
@@ -865,11 +847,13 @@ function tetrisGraph() {
strand();
at('l-lock', 'RunBehavior', 0, 0, { behavior: 'lock-piece' });
get('l-grid', 'grid', 0, 3);
get('l-shape', 'shape', 0, 5);
get('l-piece', 'piece', 0, 5);
at('l-shape', 'ExpandStruct', 0, 7, {}, [field('shape')]);
set('l-setgrid', 'grid', 1, 0);
link('m-brover', 'else', 'l-lock', 'exec');
link('l-grid', 'value', 'l-lock', 'grid');
link('l-shape', 'value', 'l-lock', 'shape');
link('l-piece', 'value', 'l-shape', 'value');
link('l-shape', 'shape', 'l-lock', 'shape');
link('l-lock', 'grid', 'l-setgrid', 'value');
flow('l-lock', 'l-setgrid');
@@ -910,16 +894,12 @@ function behavior(id, name, variables, graph) {
const tetrisVariables = [
variable('grid', 'list', EMPTY_GRID, 'number'),
variable('shapes', 'list', shapeTable(), 'number'),
variable('shapes', 'list', shapeTable(), 'struct', [field('x'), field('y')]),
variable('colors', 'list', SHAPE_COLORS, 'color'),
variable('spawnX', 'list', spawnColumns(), 'number'),
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),
// The falling piece and the one move being tried on it – four numbers each, and never apart.
variable('piece', 'struct', NO_PIECE, undefined, PIECE_FIELDS),
variable('next', 'struct', NO_PIECE, undefined, PIECE_FIELDS),
variable('kind', 'text', ''),
variable('hasPiece', 'boolean', false),
variable('fall', 'number', 0),
@@ -984,9 +964,7 @@ const project = {
'next-move',
'Next move',
[
variable('nx', 'number', 0),
variable('ny', 'number', 0),
variable('nrot', 'number', 0),
variable('next', 'struct', NO_PIECE, undefined, PIECE_FIELDS),
variable('kind', 'text', ''),
variable('outFall', 'number', 0),
variable('outDx', 'number', 0),
+12 -10
View File
@@ -27,16 +27,16 @@ const OBJECT_COUNT = STORED_COUNT + 1 + COLS * ROWS + 4;
/** The master graph and the nine behaviors the game is split into. */
const GRAPHS = {
tetris: { nodes: 84, edges: 94 },
'spawn-piece': { nodes: 5, edges: 6 },
'next-move': { nodes: 44, edges: 63 },
fits: { nodes: 18, edges: 29 },
'draw-piece': { nodes: 14, edges: 26 },
tetris: { nodes: 69, edges: 76 },
'spawn-piece': { nodes: 6, edges: 7 },
'next-move': { nodes: 44, edges: 61 },
fits: { nodes: 18, edges: 26 },
'draw-piece': { nodes: 15, edges: 24 },
'lock-piece': { nodes: 20, edges: 23 },
'clear-lines': { nodes: 29, edges: 36 },
'show-lines': { nodes: 5, edges: 5 },
'draw-board': { nodes: 11, edges: 17 },
'piece-cell': { nodes: 12, edges: 19 },
'piece-cell': { nodes: 11, edges: 17 },
};
/** The steps the master calls. `piece-cell` is missing on purpose: three of these call it. */
@@ -82,6 +82,8 @@ await runChecks({ projectId, baseUrl, port: 9337, shotPrefix: 'gm-tetris' }, asy
/** The variables of the Tetris behavior, live from the running world. */
const vars = async () => (await entity('ogame')).props;
/** The falling piece as the game now keeps it: one bundle, not four loose numbers. */
const piece = async () => (await vars()).piece;
const filledCells = (grid) => grid.filter((c) => c !== EMPTY).length;
const pieceCells = async () => {
const w = await world();
@@ -151,7 +153,7 @@ await runChecks({ projectId, baseUrl, port: 9337, shotPrefix: 'gm-tetris' }, asy
check(
'playing puts a piece into the well',
spawned.hasPiece === true && cells.every((c) => c && c.props.enabled === true),
JSON.stringify({ hasPiece: spawned.hasPiece, shape: spawned.shape, px: spawned.px }),
JSON.stringify({ hasPiece: spawned.hasPiece, piece: spawned.piece }),
);
const start = await piecePosition();
check(
@@ -184,10 +186,10 @@ await runChecks({ projectId, baseUrl, port: 9337, shotPrefix: 'gm-tetris' }, asy
back.every((c, i) => c.column === beforeMove[i].column),
back.map((c) => c.column).join(','),
);
const beforeTurn = (await vars()).rot;
const beforeTurn = (await piece()).rot;
await press('ArrowUp');
await sleep(150);
const afterTurn = (await vars()).rot;
const afterTurn = (await piece()).rot;
check(
'the up arrow turns the piece',
afterTurn === (beforeTurn + 1) % 4,
@@ -266,7 +268,7 @@ await runChecks({ projectId, baseUrl, port: 9337, shotPrefix: 'gm-tetris' }, asy
over
? JSON.stringify({
mode,
py: over.py,
piece: over.piece,
hasPiece: over.hasPiece,
kind: over.kind,
lines: over.lines,