Collapse Tetris' variable clusters, and give the long runs a home
Next move held four clusters of Set variable in a row, fourteen single nodes
in all; clear-lines read three variables one after the other. They are now
one plural node each, which takes Next move 40 -> 30 nodes and clear-lines
25 -> 23. Every remaining single node was checked and really is single: ok/no
are the same variable in opposite branches, take/keep the same one across a
write, and the three key setters hang on three different events.
Two contracts carry those merges, both pinned as tests. Set variables writes
per port with writeTyped exactly as the singular node does, so a Pack{rot}
still merges instead of overwriting - without it, turning would have erased
the other three fields. And the interpreter collects every input before the
node runs, so no input may come from a variable the same node writes; a data
node is evaluated once per pulse and keeps its answer. That second rule is
invisible in the game, because OnUpdate re-reads each frame and heals it, so
it lives in a unit test rather than in the smoke.
/opt/node22 and /opt/npm are gone from the tree. build.sh ran into the empty
path once they were moved away; the other seven mentions were documentation.
e2e.sh and check.sh replace the bare npm calls. A long run does not survive
the end of its shell - four full e2e runs died mid-suite with oom_kill 0 -
so e2e.sh start hands the run to a transient game-maker-e2e.service, which
does. e2e.sh status reports the running job or the last result, and calls a
log without a closing line ABORTED rather than reporting the passes it got
to. Both scripts end on an explicit exit line, because set -o pipefail inside
a script does not protect the caller: measured, ./check.sh | tail still
reports 0. Logs carry a timestamp and land in logs/, which is ignored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f60358c951
commit
fe3c63c48c
15 files changed
+439
-183
No files matched your search
@@ -6,4 +6,5 @@ data/projects/*.tmp
|
||||
# Uploaded images are user data like the projects: content-addressed, written by the API,
|
||||
# reproducible only by uploading them again.
|
||||
data/textures/
|
||||
logs/
|
||||
.DS_Store
|
||||
@@ -575,7 +575,7 @@ and reasons, not what the code does: a `/** */` on every export whose name does
|
||||
|
||||
## Development
|
||||
|
||||
Node 22 is required (on this machine: `PATH=/opt/node22/bin:$PATH`).
|
||||
Node 22 is required (`/usr/bin/node`, in the default PATH).
|
||||
|
||||
```bash
|
||||
npm install
|
||||
@@ -585,10 +585,14 @@ npm run typecheck
|
||||
npm run lint # ESLint, type-aware; --fix for the fixable half
|
||||
npm run format # Prettier over src, tests and tools
|
||||
npm run check # typecheck + lint + format:check + tests, what CI would run
|
||||
./check.sh # the same, ending in `=== CHECK EXIT n ===`: piped into `tail` npm's exit
|
||||
# code is lost (tail's is reported), so a red run reads as green
|
||||
npm run build # writes dist/ and regenerates index.html
|
||||
export GM_REMOTE=user@host # optional: run the browser elsewhere; see below
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 456 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 465 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step
|
||||
./e2e.sh start [GM_STEPS] # the same run detached, see below
|
||||
./e2e.sh status # live progress, or the last run's result
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs # 19 checks: the Tetris example, played end to end
|
||||
```
|
||||
@@ -606,6 +610,17 @@ four cores of this box (60% renderer, 18% GPU process, load ~1.0), which it carr
|
||||
step is being worked on, `GM_STEPS` keeps a run to seconds – take the whole suite before a commit,
|
||||
or when a change touches drawing, the widget toolkit or anything else every step sees.
|
||||
|
||||
**`./e2e.sh start` runs the suite detached**, in a transient `game-maker-e2e.service`, and returns at
|
||||
once; output goes to `logs/e2e-<date>_<time>.log` (gitignored) and ends in `=== E2E EXIT n ===`.
|
||||
Backgrounding it from a shell instead is not enough – four runs in a row died mid-suite with no
|
||||
closing line, because the whole process group is killed when the calling shell exits (`nohup` only
|
||||
covers SIGHUP, and `/proc/vmstat` confirmed `oom_kill 0`, so it was never memory). The unit outlives
|
||||
its caller. It takes the same `GM_STEPS` argument and refuses to start a second run over a live one.
|
||||
|
||||
**`./e2e.sh status`** reports the live run (elapsed, PASS/FAIL so far, the check it is on) or, when
|
||||
nothing is running, the newest log's result plus its FAIL lines. A log without a closing line is
|
||||
reported as `ABORTED`, not as its PASS count: a run that was cut short is not a result.
|
||||
|
||||
**`GM_REMOTE=user@host` moves the browser to another machine** – `tools/lib/browser.mjs` starts it
|
||||
over SSH and forwards its CDP port back to `127.0.0.1`, so the harness itself is unchanged. The
|
||||
checks load the public base URL, so the remote side needs only a browser and internet: no repo, no
|
||||
@@ -677,6 +692,16 @@ parallel variables copied back and forth, and each strand packs only the field i
|
||||
(`mergeStruct` keeps the rest). Reading a cell is `((shape · 4 + rot) · 4 + corner)` into that
|
||||
list instead of the same index doubled and read twice.
|
||||
|
||||
Where a step needs several variables at once, one `Get variables` / `Set variables` stands for the
|
||||
lot: the call to `Next move` has **one node at each side** of it rather than five, and the master
|
||||
graph is **44 nodes / 64 edges** where the same game in single nodes was 69 / 76. Inside `Next
|
||||
move` each strand writes its whole answer – the candidate, what kind of move it was and the wish
|
||||
it used up – in one node rather than three to five, which is **30 nodes** against 40. Two things
|
||||
bound how far such a node may reach: a data node answers once per frame and keeps that answer, so
|
||||
a `Get variables` must not straddle a write of one of its variables (which is why the board is
|
||||
read a second time after the lines were cleared), and a `Set variables` reads all its inputs
|
||||
before it writes any, so none of them may come from a variable it writes.
|
||||
|
||||
It is **ten behaviors**, not one graph. `Tetris` owns the state and does none of the work: each
|
||||
step of a frame 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` (where
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o pipefail && PATH=/opt/node22/bin:$PATH npm run build
|
||||
set -o pipefail && npm run build
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
# The full gate, ending in a line that survives a pipe: "=== CHECK EXIT n ===".
|
||||
# `npm run check | tail -20` reports tail's exit code, not npm's, so a red run reads as green –
|
||||
# and `set -o pipefail` in here does not help, it only covers pipes inside this script (measured).
|
||||
# Reading the closing line does.
|
||||
|
||||
set -o pipefail
|
||||
npm run check
|
||||
code=$?
|
||||
echo "=== CHECK EXIT $code ==="
|
||||
exit $code
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/bin/bash
|
||||
# Runs the editor e2e suite in a transient systemd unit, which outlives the calling shell.
|
||||
# A plain background job is not enough: when the caller exits, its whole process group is
|
||||
# killed and the run dies mid-suite with no closing line.
|
||||
#
|
||||
# Usage: ./e2e.sh start [GM_STEPS] e.g. ./e2e.sh start 1-12
|
||||
# ./e2e.sh status live progress, or the result of the last run
|
||||
#
|
||||
# Logs land in logs/e2e-<date>_<time>.log (gitignored) and end in "=== E2E EXIT n ===".
|
||||
|
||||
set -u
|
||||
|
||||
UNIT=game-maker-e2e
|
||||
DIR=$(dirname "$(readlink -f "$0")")
|
||||
LOGDIR=$DIR/logs
|
||||
|
||||
running() { systemctl is-active --quiet "$UNIT.service"; }
|
||||
rel() { echo "${1#"$DIR"/}"; }
|
||||
# Newest by name, not by mtime: the timestamped names sort chronologically and a share-mounted
|
||||
# worktree can hand out surprising mtimes. The glob demands that shape, so a hand-dropped file
|
||||
# in logs/ cannot outsort the newest run.
|
||||
latest_log() { ls -1 "$LOGDIR"/e2e-[0-9]*.log 2>/dev/null | tail -1; }
|
||||
|
||||
start() {
|
||||
if running; then
|
||||
echo "$UNIT.service is still running – ./e2e.sh status, or systemctl stop $UNIT.service" >&2
|
||||
exit 1
|
||||
fi
|
||||
systemctl reset-failed "$UNIT.service" 2>/dev/null
|
||||
|
||||
mkdir -p "$LOGDIR"
|
||||
local log="$LOGDIR/e2e-$(date '+%Y-%m-%d_%H%M%S').log"
|
||||
local steps=()
|
||||
if [ $# -gt 0 ]; then steps=(--setenv=GM_STEPS="$1"); fi
|
||||
|
||||
systemd-run --unit="$UNIT" --working-directory="$DIR" \
|
||||
--property=StandardOutput=append:"$log" \
|
||||
--property=StandardError=append:"$log" \
|
||||
"${steps[@]}" \
|
||||
bash -c 'node tools/seed-e2e.mjs && node tools/e2e.mjs; echo "=== E2E EXIT $? ==="' || exit 1
|
||||
|
||||
echo "$UNIT.service started${1:+ with GM_STEPS=$1}, logging to $(rel "$log")"
|
||||
echo " status: ./e2e.sh status"
|
||||
echo " follow: tail -f $(rel "$log")"
|
||||
echo " stop: systemctl stop $UNIT.service"
|
||||
}
|
||||
|
||||
# Wall-clock seconds as "2m 14s".
|
||||
span() { printf '%dm %02ds' $(($1 / 60)) $(($1 % 60)); }
|
||||
|
||||
status() {
|
||||
local log pass fail
|
||||
log=$(latest_log)
|
||||
if [ -z "$log" ]; then
|
||||
running && echo "running – but logs/ is empty, follow: journalctl -u $UNIT.service -f" ||
|
||||
echo "idle – no run logged yet, start one with ./e2e.sh start"
|
||||
return
|
||||
fi
|
||||
pass=$(grep -c '^PASS' "$log")
|
||||
fail=$(grep -c '^FAIL' "$log")
|
||||
|
||||
if running; then
|
||||
local since=0 now
|
||||
now=$(systemctl show "$UNIT.service" -p ActiveEnterTimestamp --value)
|
||||
[ -n "$now" ] && since=$(($(date +%s) - $(date -d "$now" +%s)))
|
||||
echo "running – $(span "$since") elapsed"
|
||||
echo " log: $(rel "$log")"
|
||||
echo " checks: $pass PASS, $fail FAIL"
|
||||
local at
|
||||
at=$(grep -E '^(PASS|FAIL)' "$log" | tail -1)
|
||||
[ -n "$at" ] && echo " at: $at"
|
||||
echo " follow: tail -f $(rel "$log")"
|
||||
return
|
||||
fi
|
||||
|
||||
# Started per the file name, ended when the file was last written to; an unparsable name
|
||||
# costs the duration, not the line.
|
||||
local name started epoch took verdict exit_line
|
||||
name=$(basename "$log" .log)
|
||||
started=$(echo "${name#e2e-}" | tr '_' ' ' | sed -E 's/(..)(..)(..)$/\1:\2:\3/')
|
||||
epoch=$(date -d "$started" +%s 2>/dev/null)
|
||||
took=""
|
||||
[ -n "$epoch" ] && took=$(($(date -r "$log" +%s) - epoch))
|
||||
verdict=$(grep -E '^(ALL PASSED|[0-9]+ FAILED|RUN DIED)' "$log" | tail -1)
|
||||
exit_line=$(grep -E '^=== E2E EXIT' "$log" | tail -1 | sed -E 's/^=== (.*) ===$/\1/')
|
||||
|
||||
echo "idle – last run $started$([ -n "$took" ] && [ "$took" -ge 0 ] && echo ", $(span "$took")")"
|
||||
echo " log: $(rel "$log")"
|
||||
if [ -z "$exit_line" ]; then
|
||||
# No closing line means the run was cut short. Reporting its PASS count as a result would
|
||||
# claim a suite that never finished, so say so instead.
|
||||
echo " result: ABORTED – no closing line, so this is not a result. Run it again."
|
||||
else
|
||||
echo " result: ${verdict:-no summary} ($exit_line)"
|
||||
fi
|
||||
echo " checks: $pass PASS, $fail FAIL"
|
||||
[ "$fail" -gt 0 ] && grep -E '^FAIL' "$log" | head -20 | sed 's/^/ /'
|
||||
return 0
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
start) shift; start "$@" ;;
|
||||
status) status ;;
|
||||
*)
|
||||
echo "usage: ./e2e.sh start [GM_STEPS] e.g. ./e2e.sh start 1-12" >&2
|
||||
echo " ./e2e.sh status" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -6,6 +6,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AddPortCommand, SetVariablePortCommand } from '../../src/core/commands/portCommands';
|
||||
import {
|
||||
AddStructFieldCommand,
|
||||
AddVariableCommand,
|
||||
RemoveVariableCommand,
|
||||
RenameVariableCommand,
|
||||
@@ -111,6 +112,42 @@ describe('a node that reads or writes several variables at once', () => {
|
||||
expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 });
|
||||
});
|
||||
|
||||
/**
|
||||
* The rule that decides how far a `Get variables` may reach: a data node answers once per pulse
|
||||
* and that answer is kept, so a single node cannot straddle a write of one of its variables – the
|
||||
* later reader would be served the value from before it. A second node is the way across.
|
||||
*/
|
||||
it('answers once per pulse, so a reader past a write needs a node of its own', () => {
|
||||
const fixture = makeProject();
|
||||
const behavior = makeBehavior(fixture, 'B', ['px', 'py', 'sum', 'fresh']);
|
||||
const edit = graphEditor(fixture, behavior);
|
||||
const start = edit.addNode('OnStart');
|
||||
const early = edit.addNode(GET_VARIABLES);
|
||||
const late = edit.addNode(GET_VARIABLES);
|
||||
const write = edit.addNode(SET_VARIABLES, { px: 7 });
|
||||
const before = edit.addNode('SetVariable', { variable: 'sum' });
|
||||
const after = edit.addNode('SetVariable', { variable: 'py' });
|
||||
const again = edit.addNode('SetVariable', { variable: 'fresh' });
|
||||
for (const node of [early, late, write])
|
||||
fixture.stack.execute(new AddPortCommand(behavior.id, node.id, { key: 'px', kind: 'number' }));
|
||||
edit.wire([
|
||||
[start, 'exec', before, 'exec'],
|
||||
[before, 'exec', write, 'exec'],
|
||||
[write, 'exec', after, 'exec'],
|
||||
[after, 'exec', again, 'exec'],
|
||||
[early, 'px', before, 'value'],
|
||||
[early, 'px', after, 'value'],
|
||||
[late, 'px', again, 'value'],
|
||||
]);
|
||||
// `py` is the point: the same node read past the write still answers 1, while `late` sees 7.
|
||||
expect(run(behavior, fixture, { px: 1, py: 0, sum: 0, fresh: 0 })).toEqual({
|
||||
px: 7,
|
||||
py: 1,
|
||||
sum: 1,
|
||||
fresh: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it('survives a save and load, which is what makes the pick more than a session', () => {
|
||||
const { fixture, behavior } = bothWays();
|
||||
const loaded = roundTrip(fixture.project);
|
||||
@@ -119,4 +156,51 @@ describe('a node that reads or writes several variables at once', () => {
|
||||
expect((set.ports ?? []).map((p) => `${p.key}:${p.kind}`)).toEqual(['px:number', 'py:number']);
|
||||
expect(run(asset, { project: loaded, stack: fixture.stack })).toEqual({ px: 7, py: 9, sum: 16 });
|
||||
});
|
||||
|
||||
/**
|
||||
* A struct port writes the way the singular node does: the bundle it is handed is merged in, so a
|
||||
* `Pack` of one field leaves the rest standing. Tetris leans on this – each strand of `Next move`
|
||||
* packs only the field it changes and lets the plural node keep the other three.
|
||||
*/
|
||||
it('merges a partial bundle into a struct variable instead of replacing it', () => {
|
||||
const fixture = makeProject();
|
||||
const behavior = makeBehavior(fixture, 'B');
|
||||
fixture.stack.execute(new AddVariableCommand(behavior.id, 'piece', 'struct'));
|
||||
for (const key of ['shape', 'rot'])
|
||||
fixture.stack.execute(new AddStructFieldCommand(behavior.id, 'piece', { key, kind: 'number' }));
|
||||
const edit = graphEditor(fixture, behavior);
|
||||
const start = edit.addNode('OnStart');
|
||||
const set = edit.addNode(SET_VARIABLES);
|
||||
const pack = edit.addNode('PackStruct', { rot: 2 });
|
||||
fixture.stack.execute(new AddPortCommand(behavior.id, set.id, { key: 'piece', kind: 'struct' }));
|
||||
fixture.stack.execute(new AddPortCommand(behavior.id, pack.id, { key: 'rot', kind: 'number' }));
|
||||
edit.wire([
|
||||
[start, 'exec', set, 'exec'],
|
||||
[pack, 'value', set, 'piece'],
|
||||
]);
|
||||
|
||||
const ctx = fakeContext({ variables: { piece: { shape: 3, rot: 0 } } });
|
||||
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
|
||||
expect(ctx.variables.piece).toEqual({ shape: 3, rot: 2 });
|
||||
});
|
||||
|
||||
/**
|
||||
* The other half of how far a plural node may reach: it collects every input before it writes any
|
||||
* of them, so an input fed from a variable the same node writes sees the value from before.
|
||||
*/
|
||||
it('reads every input before it writes any, so a plural set cannot feed itself', () => {
|
||||
const fixture = makeProject();
|
||||
const behavior = makeBehavior(fixture, 'B', ['px', 'py']);
|
||||
const edit = graphEditor(fixture, behavior);
|
||||
const start = edit.addNode('OnStart');
|
||||
const read = edit.addNode('GetVariable', { variable: 'px' });
|
||||
const set = edit.addNode(SET_VARIABLES, { px: 7 });
|
||||
for (const key of ['px', 'py'])
|
||||
fixture.stack.execute(new AddPortCommand(behavior.id, set.id, { key, kind: 'number' }));
|
||||
edit.wire([
|
||||
[start, 'exec', set, 'exec'],
|
||||
[read, 'value', set, 'py'],
|
||||
]);
|
||||
expect(run(behavior, fixture, { px: 1, py: 0 })).toEqual({ px: 7, py: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
Tetris Game mit Structs neu bauen
|
||||
...und mit neuen `Get variables` / `Set variables` node
|
||||
|
||||
---
|
||||
|
||||
**Gebaut (2026-09-10).** Beides, in zwei Durchgängen: erst die Structs, dann die Plural-Knoten.
|
||||
|
||||
| | vorher | jetzt |
|
||||
|---|---|---|
|
||||
| `shapes` | 224 lose Zahlen | 112 `{x, y}`-Bündel |
|
||||
| Zellindex | `((shape·4+rot)·8 + corner·2)`, zweimal gelesen | `((shape·4+rot)·4 + corner)`, einmal |
|
||||
| deklarierte Behavior-Ports | 49 | 35 |
|
||||
| Variablen im Master | 18 | 13 |
|
||||
| Master-Graph | 84 Knoten / 94 Kanten | 44 / 64 |
|
||||
| `next-move` | 44 Knoten | 30 |
|
||||
| `clear-lines` | 29 Knoten | 23 |
|
||||
|
||||
`piece` und der Kandidat `next` sind Structs aus shape/rot/x/y. `Next move` gibt einen ganzen
|
||||
Kandidaten zurück, `Fits` beurteilt ihn, der Master übernimmt ihn mit *einem* `Set variable`.
|
||||
Jeder Bewegungsstrang packt nur das Feld, das er ändert (`Pack{rot}`) — „dasselbe Teil, eine Reihe
|
||||
tiefer" ist dadurch wörtlich ein Feld.
|
||||
|
||||
## Zwei Verträge, an denen die Zusammenlegung hängt
|
||||
|
||||
Beide gemessen, beide als Unit-Test gepinnt (`tests/core/variable-ports.test.ts`), beide mit
|
||||
Gegenprobe abgesichert:
|
||||
|
||||
- **`Set variables` schreibt je Port mit `writeTyped`**, genau wie der Einzelknoten. Ein
|
||||
`Pack{rot}` wird deshalb weiterhin per `mergeStruct` eingemischt statt das Bündel zu
|
||||
überschreiben. Ohne das hätte Drehen die anderen drei Felder gelöscht.
|
||||
- **Ein Datenknoten wird einmal pro Puls ausgewertet und behält die Antwort** (`dataCache`,
|
||||
geleert je Schleifenrunde). Ein `Get variables` darf deshalb keine Schreibung einer seiner
|
||||
Variablen überspannen — sonst liest der spätere Verbraucher den Wert von davor. Deshalb sind
|
||||
`grid`/`outgrid` in `clear-lines` weiterhin zwei Knoten, und `take`/`keep` bleiben getrennt.
|
||||
|
||||
Die Cache-Regel ist im **Spiel** nicht messbar: eine absichtliche Verletzung ließ den Smoke grün,
|
||||
weil `OnUpdate` jeden Frame neu liest und der Fehler sich einen Frame später selbst heilt. Der
|
||||
Unit-Test fällt dagegen sauber (`py: 7` statt `1`).
|
||||
|
||||
## Was bewusst nicht passiert ist
|
||||
|
||||
Das **Brett bleibt eine flache Zahlenliste**. `-1`/Formnummer ist bereits die kompakte Kodierung;
|
||||
ein `{shape}`-Struct pro Zelle wären 200 Bündel für ein einziges Feld.
|
||||
|
||||
Eine **Listen-Variable kann ihre Item-Art im Editor nicht wählen** — eine Liste aus Structs
|
||||
entsteht heute nur aus einer Projektdatei. Steht in `struct-und-listen-restposten.md`.
|
||||
@@ -0,0 +1 @@
|
||||
Möglichkeit Nodes zuklappen können, wichtig: soll auch gespeichert werden.
|
||||
@@ -1,2 +0,0 @@
|
||||
Tetris Game mit Structs neu bauen
|
||||
...und mit neuen `Get variables` / `Set variables` node
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Headless end-to-end checks of the editor, driven over the Chrome DevTools Protocol.
|
||||
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-e2e.mjs && node tools/e2e.mjs [projectId] [baseUrl]
|
||||
* Usage: node tools/seed-e2e.mjs && node tools/e2e.mjs [projectId] [baseUrl]
|
||||
* Prints PASS/FAIL per check, writes screenshots to /tmp/gm-e2e-*.png. No dependencies.
|
||||
*
|
||||
* Runs against the fixture project written by tools/seed-e2e.mjs and mutates it heavily; the
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
* child each, a camera, the Mover behavior owning the red crate and a Spawner. The objects are
|
||||
* uniquely named and positioned exactly where the checks expect them.
|
||||
*
|
||||
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-e2e.mjs [projectId] [baseUrl]
|
||||
* Usage: node tools/seed-e2e.mjs [projectId] [baseUrl]
|
||||
*/
|
||||
import { edge, node, sceneObject, variable } from './lib/builders.mjs';
|
||||
import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs';
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* switch the inspector shows for every object. Everything else is nodes: gravity and jumping
|
||||
* on the player, timer, coin counter and screen flow on the "Game" behavior.
|
||||
*
|
||||
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-jumprun.mjs [projectId] [baseUrl]
|
||||
* Usage: node tools/seed-jumprun.mjs [projectId] [baseUrl]
|
||||
*/
|
||||
import { edge, node, sceneObject, variable } from './lib/builders.mjs';
|
||||
import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs';
|
||||
|
||||
+151
-170
@@ -12,6 +12,10 @@
|
||||
* 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`.
|
||||
*
|
||||
* Where a step needs several variables at once, one `Get variables` / `Set variables` stands for the
|
||||
* lot – the call to `Next move` has one node at each side of it instead of five. How far such a node
|
||||
* may reach is bounded by the pulse cache; `graphBuilder` below says how.
|
||||
*
|
||||
* 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`
|
||||
@@ -22,7 +26,7 @@
|
||||
* What the player sees is a pool of objects the graphs address by name: `c0…c199` for the board
|
||||
* and `p0…p3` for the piece that is falling. Nothing is spawned or destroyed while playing.
|
||||
*
|
||||
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-tetris.mjs [projectId] [baseUrl]
|
||||
* Usage: node tools/seed-tetris.mjs [projectId] [baseUrl]
|
||||
*/
|
||||
import { edge, field, node, port, sceneObject, variable } from './lib/builders.mjs';
|
||||
import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs';
|
||||
@@ -167,14 +171,24 @@ const boardTemplate = [
|
||||
),
|
||||
];
|
||||
|
||||
/** 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.
|
||||
* 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() {
|
||||
function graphBuilder(variables = []) {
|
||||
const nodes = [];
|
||||
const edges = [];
|
||||
let band = 0;
|
||||
const kinds = Object.fromEntries(variables.map((v) => [v.key, v.kind]));
|
||||
const declare = (keys) =>
|
||||
keys.map((key) => {
|
||||
if (!kinds[key]) throw new Error(`no variable "${key}" to declare a port for`);
|
||||
return 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));
|
||||
@@ -196,6 +210,20 @@ function graphBuilder() {
|
||||
get: (id, key, column, row) => at(id, 'GetVariable', column, row, { variable: key }),
|
||||
set: (id, key, column, row, value) =>
|
||||
at(id, 'SetVariable', column, row, value === undefined ? { variable: key } : { variable: key, value }),
|
||||
/**
|
||||
* The plural nodes: one node for a whole cluster, one port per variable. A data node is cached
|
||||
* for the entire pulse, so a `gets` must not span a write of one of its variables – the second
|
||||
* read would serve the value from before the write. Split it in two where it does. A `sets`
|
||||
* reads all its inputs before it writes any, so none of them may come from a variable it writes.
|
||||
*/
|
||||
gets: (id, keys, column, row) => at(id, 'GetVariables', 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, 'SetVariables', column, row, params, declare(keys));
|
||||
},
|
||||
/** A mistyped id would silently leave an input unconnected, so the seed refuses to write one. */
|
||||
done() {
|
||||
const ids = new Set();
|
||||
@@ -329,36 +357,41 @@ function fitsGraph() {
|
||||
return g.done();
|
||||
}
|
||||
|
||||
const CLEAR_LINES_VARS = [
|
||||
variable('board', 'list', [], 'number'),
|
||||
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();
|
||||
const g = graphBuilder(CLEAR_LINES_VARS);
|
||||
g.at('start', 'BehaviorStart', 0, 0, {}, [port('grid', 'list'), port('lines', 'number')]);
|
||||
g.set('start-board', 'board', 1, 0);
|
||||
g.set('start-count', 'cleared', 2, 0);
|
||||
g.set('row', 'row', 3, 0, ROWS - 1);
|
||||
g.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-board', 'value');
|
||||
g.link('start', 'lines', 'start-count', 'value');
|
||||
g.flow('start', 'start-board', 'start-count', 'row', 'loop');
|
||||
g.link('start', 'grid', 'start-vars', 'board');
|
||||
g.link('start', 'lines', 'start-vars', 'cleared');
|
||||
g.flow('start', 'start-vars', 'loop');
|
||||
|
||||
g.get('rowvar', 'row', 5, 3);
|
||||
// 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.get('grid', 'board', 7, 5);
|
||||
g.at('cells', 'SubList', 8, 4, { count: COLS });
|
||||
g.at('gap', 'IndexOf', 9, 4, { value: EMPTY });
|
||||
g.at('full', 'Compare', 10, 4, { op: '<', b: 0 });
|
||||
g.at('brfull', 'Branch', 8, 0);
|
||||
g.link('loop', 'body', 'brleft', 'exec');
|
||||
g.link('rowvar', 'value', 'left', 'a');
|
||||
g.link('row-vars', 'row', 'left', 'a');
|
||||
g.link('left', 'result', 'brleft', 'condition');
|
||||
g.link('rowvar', 'value', 'rowstart', 'a');
|
||||
g.link('grid', 'value', 'cells', 'list');
|
||||
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');
|
||||
@@ -371,37 +404,33 @@ function clearLinesGraph() {
|
||||
g.at('blank', 'Fill', 9, 12, { value: EMPTY, count: COLS });
|
||||
g.at('top', 'Concat', 10, 8);
|
||||
g.at('rest', 'Concat', 11, 9);
|
||||
g.set('setgrid', 'board', 10, 0);
|
||||
g.get('lines', 'cleared', 11, 3);
|
||||
g.at('onemore', 'Add', 12, 3, { b: 1 });
|
||||
g.set('setlines', 'cleared', 12, 0);
|
||||
g.link('brfull', 'then', 'setgrid', 'exec');
|
||||
g.link('grid', 'value', 'above', 'list');
|
||||
g.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('grid', 'value', 'below', 'list');
|
||||
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', 'setgrid', 'value');
|
||||
g.link('lines', 'value', 'onemore', 'a');
|
||||
g.link('onemore', 'result', 'setlines', 'value');
|
||||
g.flow('setgrid', 'setlines');
|
||||
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('rowvar', 'value', 'back', 'a');
|
||||
g.link('row-vars', 'row', 'back', 'a');
|
||||
g.link('back', 'result', 'setrow', 'value');
|
||||
|
||||
g.at('end', 'BehaviorEnd', 13, 0, {}, [port('grid', 'list'), port('lines', 'number')]);
|
||||
g.get('outgrid', 'board', 12, 6);
|
||||
g.get('outlines', 'cleared', 12, 8);
|
||||
g.gets('out-vars', ['board', 'cleared'], 12, 6);
|
||||
g.link('loop', 'done', 'end', 'exec');
|
||||
g.link('outgrid', 'value', 'end', 'grid');
|
||||
g.link('outlines', 'value', 'end', 'lines');
|
||||
g.link('out-vars', 'board', 'end', 'grid');
|
||||
g.link('out-vars', 'cleared', 'end', 'lines');
|
||||
return g.done();
|
||||
}
|
||||
|
||||
@@ -459,14 +488,22 @@ function spawnPieceGraph() {
|
||||
return g.done();
|
||||
}
|
||||
|
||||
const NEXT_MOVE_VARS = [
|
||||
variable('next', 'struct', NO_PIECE, undefined, 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();
|
||||
const { at, link, flow, get, set } = g;
|
||||
const g = graphBuilder(NEXT_MOVE_VARS);
|
||||
const { at, link, flow, get, set, sets } = g;
|
||||
|
||||
at('start', 'BehaviorStart', 0, 0, {}, [
|
||||
port('piece', 'struct'),
|
||||
@@ -477,21 +514,17 @@ function nextMoveGraph() {
|
||||
port('stepTime', 'number'),
|
||||
]);
|
||||
|
||||
// The candidate starts out as the piece itself – one write, because it is one value. Each
|
||||
// strand below then packs only the field it changes: writing a bundle keeps the fields it
|
||||
// does not name, so "the same piece, one row lower" needs no copying.
|
||||
// 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');
|
||||
set('i-next', 'next', 1, 0);
|
||||
set('i-kind', 'kind', 2, 0, '');
|
||||
set('i-fall', 'outFall', 3, 0);
|
||||
set('i-dx', 'outDx', 4, 0);
|
||||
set('i-rot', 'outRot', 5, 0);
|
||||
link('start', 'piece', 'i-next', 'value');
|
||||
link('start', 'fall', 'i-fall', 'value');
|
||||
link('start', 'wantDx', 'i-dx', 'value');
|
||||
link('start', 'wantRot', 'i-rot', 'value');
|
||||
flow('start', 'i-next', 'i-kind', 'i-fall', 'i-dx', 'i-rot', 't-br');
|
||||
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();
|
||||
@@ -500,17 +533,15 @@ function nextMoveGraph() {
|
||||
at('t-plus1', 'Add', 1, 3, { b: 1 });
|
||||
at('t-wrap', 'Modulo', 2, 3, { b: 4 });
|
||||
at('t-pack', 'PackStruct', 3, 3, {}, [field('rot')]);
|
||||
set('t-set', 'next', 1, 0);
|
||||
set('t-kind', 'kind', 2, 0, 'turn');
|
||||
set('t-used', 'outRot', 3, 0, 0);
|
||||
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-set', 'exec');
|
||||
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-set', 'value');
|
||||
flow('t-set', 't-kind', 't-used', 'end');
|
||||
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 });
|
||||
@@ -518,19 +549,17 @@ function nextMoveGraph() {
|
||||
at('m-br', 'Branch', 5, 0);
|
||||
at('m-shift', 'Add', 6, 3);
|
||||
at('m-pack', 'PackStruct', 7, 3, {}, [field('x')]);
|
||||
set('m-set', 'next', 6, 0);
|
||||
set('m-kind', 'kind', 7, 0, 'move');
|
||||
set('m-used', 'outDx', 8, 0, 0);
|
||||
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-set', 'exec');
|
||||
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-set', 'value');
|
||||
flow('m-set', 'm-kind', 'm-used', 'end');
|
||||
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();
|
||||
@@ -553,23 +582,21 @@ function nextMoveGraph() {
|
||||
at('f-due', 'Compare', 6, 3, { op: '>=' });
|
||||
at('f-br', 'Branch', 5, 0);
|
||||
at('f-minus', 'Subtract', 7, 3);
|
||||
set('f-rest', 'outFall', 6, 0);
|
||||
at('f-down1', 'Add', 7, 5, { b: 1 });
|
||||
at('f-pack', 'PackStruct', 8, 5, {}, [field('y')]);
|
||||
set('f-ny', 'next', 7, 0);
|
||||
set('f-kind', 'kind', 8, 0, 'fall');
|
||||
sets('f-vars', { outFall: LINKED, next: LINKED, kind: 'fall' }, 6, 0);
|
||||
flow('f-set', 'f-br');
|
||||
link('f-fall', 'value', 'f-due', 'a');
|
||||
link('start', 'stepTime', 'f-due', 'b');
|
||||
link('f-due', 'result', 'f-br', 'condition');
|
||||
link('f-br', 'then', 'f-rest', 'exec');
|
||||
link('f-br', 'then', 'f-vars', 'exec');
|
||||
link('f-fall', 'value', 'f-minus', 'a');
|
||||
link('start', 'stepTime', 'f-minus', 'b');
|
||||
link('f-minus', 'result', 'f-rest', 'value');
|
||||
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-ny', 'value');
|
||||
flow('f-rest', 'f-ny', 'f-kind', 'end');
|
||||
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');
|
||||
|
||||
@@ -583,16 +610,19 @@ function nextMoveGraph() {
|
||||
port('wantRot', 'number'),
|
||||
]);
|
||||
const back = [
|
||||
['e-next', 'next', 'next'],
|
||||
['e-kind', 'kind', 'kind'],
|
||||
['e-fall', 'outFall', 'fall'],
|
||||
['e-dx', 'outDx', 'wantDx'],
|
||||
['e-rot', 'outRot', 'wantRot'],
|
||||
['next', 'next'],
|
||||
['kind', 'kind'],
|
||||
['outFall', 'fall'],
|
||||
['outDx', 'wantDx'],
|
||||
['outRot', 'wantRot'],
|
||||
];
|
||||
back.forEach(([id, key, portKey], i) => {
|
||||
get(id, key, 0, i * 2 + 2);
|
||||
link(id, 'value', 'end', portKey);
|
||||
});
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -715,23 +745,18 @@ function showLinesGraph() {
|
||||
* each answer lands in.
|
||||
*/
|
||||
function tetrisGraph() {
|
||||
const g = graphBuilder();
|
||||
const { at, link, flow, get, set, strand } = g;
|
||||
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 });
|
||||
set('s-grid', 'grid', 1, 0);
|
||||
set('s-lines', 'lines', 2, 0, 0);
|
||||
set('s-piece', 'hasPiece', 3, 0, false);
|
||||
set('s-fall', 'fall', 4, 0, 0);
|
||||
set('s-dx', 'wantDx', 5, 0, 0);
|
||||
set('s-turn', 'wantRot', 6, 0, 0);
|
||||
at('s-show', 'RunBehavior', 7, 0, { behavior: 'show-lines', lines: 0 });
|
||||
link('s-empty', 'result', 's-grid', 'value');
|
||||
flow('s-start', 's-grid', 's-lines', 's-piece', 's-fall', 's-turn', 's-dx', 's-show');
|
||||
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.
|
||||
@@ -757,66 +782,47 @@ 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-piece', 'piece', 3, 5);
|
||||
set('n-fall', 'fall', 4, 5, 0);
|
||||
set('n-has', 'hasPiece', 5, 5, true);
|
||||
sets('n-vars', { piece: LINKED, fall: 0, hasPiece: true }, 3, 5);
|
||||
link('u-br', 'else', 'n-spawn', 'exec');
|
||||
link('n-columns', 'value', 'n-spawn', 'spawnX');
|
||||
link('n-spawn', 'piece', 'n-piece', 'value');
|
||||
flow('n-spawn', 'n-piece', 'n-fall', 'n-has');
|
||||
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');
|
||||
const moveIn = [
|
||||
['piece', 'piece'],
|
||||
['wantDx', 'wantDx'],
|
||||
['wantRot', 'wantRot'],
|
||||
['fall', 'fall'],
|
||||
['stepTime', 'stepTime'],
|
||||
];
|
||||
moveIn.forEach(([key, variableKey], i) => {
|
||||
get(`c-in-${key}`, variableKey, 0, i * 2 + 3);
|
||||
link(`c-in-${key}`, 'value', 'c-move', key);
|
||||
});
|
||||
// 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', 'next'],
|
||||
['kind', 'kind'],
|
||||
['fall', 'fall'],
|
||||
['wantDx', 'wantDx'],
|
||||
['wantRot', 'wantRot'],
|
||||
];
|
||||
moveOut.forEach(([key, variableKey], i) => {
|
||||
set(`c-out-${key}`, variableKey, i + 1, 0);
|
||||
link('c-move', key, `c-out-${key}`, 'value');
|
||||
});
|
||||
flow('c-move', ...moveOut.map(([key]) => `c-out-${key}`));
|
||||
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' });
|
||||
get('t-shapes', 'shapes', 0, 3);
|
||||
get('t-grid', 'grid', 0, 5);
|
||||
get('t-next', 'next', 0, 7);
|
||||
flow('c-out-wantRot', 't-fits');
|
||||
link('t-shapes', 'value', 't-fits', 'shapes');
|
||||
link('t-grid', 'value', 't-fits', 'grid');
|
||||
link('t-next', 'value', 't-fits', 'piece');
|
||||
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);
|
||||
get('m-next', 'next', 2, 3);
|
||||
// `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-next', 'value', 'm-piece', 'value');
|
||||
link('m-in', 'next', 'm-piece', 'value');
|
||||
|
||||
get('m-kind', 'kind', 2, 7);
|
||||
at('m-landed', 'Equals', 3, 7, { b: 'fall' });
|
||||
at('m-brland', 'Branch', 3, 5);
|
||||
get('m-py2', 'piece', 4, 8);
|
||||
@@ -825,7 +831,7 @@ function tetrisGraph() {
|
||||
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-in', 'kind', 'm-landed', 'a');
|
||||
link('m-landed', 'result', 'm-brland', 'condition');
|
||||
link('m-brland', 'then', 'm-brover', 'exec');
|
||||
link('m-py2', 'value', 'm-pieceY', 'value');
|
||||
@@ -837,48 +843,43 @@ function tetrisGraph() {
|
||||
strand();
|
||||
at('d-draw', 'RunBehavior', 0, 0, { behavior: 'draw-piece' });
|
||||
flow('m-piece', 'd-draw');
|
||||
flow('n-has', 'd-draw');
|
||||
['shapes', 'piece', 'colors'].forEach((key, i) => {
|
||||
get(`d-${key}`, key, 0, i * 2 + 3);
|
||||
link(`d-${key}`, 'value', 'd-draw', key);
|
||||
});
|
||||
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' });
|
||||
get('l-grid', 'grid', 0, 3);
|
||||
get('l-piece', 'piece', 0, 5);
|
||||
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-grid', 'value', 'l-lock', 'grid');
|
||||
link('l-piece', 'value', 'l-shape', 'value');
|
||||
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', 'value');
|
||||
flow('l-lock', 'l-setgrid');
|
||||
|
||||
at('r-clear', 'RunBehavior', 2, 0, { behavior: 'clear-lines' });
|
||||
get('r-grid', 'grid', 2, 3);
|
||||
get('r-lines', 'lines', 2, 5);
|
||||
set('r-setgrid', 'grid', 3, 0);
|
||||
set('r-setlines', 'lines', 4, 0);
|
||||
link('r-grid', 'value', 'r-clear', 'grid');
|
||||
link('r-lines', 'value', 'r-clear', 'lines');
|
||||
link('r-clear', 'grid', 'r-setgrid', 'value');
|
||||
link('r-clear', 'lines', 'r-setlines', 'value');
|
||||
flow('l-setgrid', 'r-clear', 'r-setgrid', 'r-setlines');
|
||||
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' });
|
||||
get('b-lines', 'lines', 5, 3);
|
||||
at('b-draw', 'RunBehavior', 6, 0, { behavior: 'draw-board' });
|
||||
get('b-grid', 'grid', 6, 3);
|
||||
get('b-colors', 'colors', 6, 5);
|
||||
// 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-lines', 'value', 'b-show', 'lines');
|
||||
link('b-grid', 'value', 'b-draw', 'grid');
|
||||
link('b-colors', 'value', 'b-draw', 'colors');
|
||||
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-setlines', 'b-show', 'b-draw', 'b-nopiece');
|
||||
flow('r-out', 'b-show', 'b-draw', 'b-nopiece');
|
||||
return g.done();
|
||||
}
|
||||
|
||||
@@ -948,30 +949,10 @@ const project = {
|
||||
{ id: 'board', type: 'container', name: 'Board', props: {}, objects: boardTemplate },
|
||||
behavior('piece-cell', 'Piece cell', [], pieceCellGraph()),
|
||||
behavior('fits', 'Fits', [variable('ok', 'boolean', true)], fitsGraph()),
|
||||
behavior(
|
||||
'clear-lines',
|
||||
'Clear lines',
|
||||
[
|
||||
variable('board', 'list', [], 'number'),
|
||||
variable('cleared', 'number', 0),
|
||||
variable('row', 'number', 0),
|
||||
],
|
||||
clearLinesGraph(),
|
||||
),
|
||||
behavior('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',
|
||||
[
|
||||
variable('next', 'struct', NO_PIECE, undefined, PIECE_FIELDS),
|
||||
variable('kind', 'text', ''),
|
||||
variable('outFall', 'number', 0),
|
||||
variable('outDx', 'number', 0),
|
||||
variable('outRot', 'number', 0),
|
||||
],
|
||||
nextMoveGraph(),
|
||||
),
|
||||
behavior('next-move', 'Next move', NEXT_MOVE_VARS, nextMoveGraph()),
|
||||
behavior('draw-piece', 'Draw piece', [], drawPieceGraph()),
|
||||
behavior('lock-piece', 'Lock piece', [variable('board', 'list', [], 'number')], lockPieceGraph()),
|
||||
behavior('show-lines', 'Show lines', [], showLinesGraph()),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* button, running, jumping, collecting a coin, the win screen, restart and the lose screen.
|
||||
* It is the proof that the engine features this game needs actually work together.
|
||||
*
|
||||
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
* Usage: node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
* Screenshots land in /tmp/gm-jr-*.png.
|
||||
*/
|
||||
import { instanceChild } from './lib/builders.mjs';
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* It is not part of the e2e suite – like smoke-jumprun.mjs it proves one seeded project, and is
|
||||
* run when that project or the engine underneath it changes.
|
||||
*
|
||||
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
* Usage: node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
* Screenshots land in /tmp/gm-tetris-*.png.
|
||||
*/
|
||||
import { instanceChild } from './lib/builders.mjs';
|
||||
@@ -27,13 +27,13 @@ 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: 69, edges: 76 },
|
||||
tetris: { nodes: 44, edges: 64 },
|
||||
'spawn-piece': { nodes: 6, edges: 7 },
|
||||
'next-move': { nodes: 44, edges: 61 },
|
||||
'next-move': { nodes: 30, edges: 51 },
|
||||
fits: { nodes: 18, edges: 26 },
|
||||
'draw-piece': { nodes: 15, edges: 24 },
|
||||
'lock-piece': { nodes: 20, edges: 23 },
|
||||
'clear-lines': { nodes: 29, edges: 36 },
|
||||
'clear-lines': { nodes: 23, edges: 33 },
|
||||
'show-lines': { nodes: 5, edges: 5 },
|
||||
'draw-board': { nodes: 11, edges: 17 },
|
||||
'piece-cell': { nodes: 11, edges: 17 },
|
||||
|
||||
Reference in new issue
Block a user