diff --git a/README.md b/README.md index 6064d8d6..e0ff8e3e 100644 --- a/README.md +++ b/README.md @@ -97,10 +97,11 @@ shown on the node and editable in the inspector. viewport). The `Owner` node returns it; `Self` returns the behavior itself; `Parent` walks up from any object. - **Node set** (`src/core/graph/nodes/`, one file per node): events `On start`, `On update`, `On key down/up/held`, - `On timer`, `On collision enter/stay/exit`, `On click` (the mouse pressed on the owner – anywhere when it has none) · flow `Branch`, `Sequence` · data `Self`, `Owner`, `Parent`, `Object by name`, `Name of`, + `On timer`, `On collision enter/stay/exit`, `On click` (the mouse pressed on the owner – anywhere when it has none) · flow `Branch`, `Sequence`, `Repeat` · data `Self`, `Owner`, `Parent`, `Object by name`, `Name of`, `Is asset` (is this object an instance of asset X – how you tell what you collided with), `Get/Set variable`, `Get/Set property`, `Get position` (local and world), `Is enabled`, literals, `Add/Subtract/Multiply/Divide`, - `Min/Max/Abs/Sqrt/Clamp`, `Floor/Ceil/Round`, `Modulo`, `Random`, `Time`, `To text`, `Join text`, `Equals`, + `Min/Max/Abs/Sqrt/Clamp`, `Floor/Ceil/Round`, `Modulo`, `Random`, `Time`, `To text`, `To number`, `Join text`, + `Substring`, `Find text`, `Equals`, `Length`, `Distance`, `Normalize`, `Axis input` (four keys → a normalized direction, so diagonals are not faster), `Compare`, `And/Or/Not`, `Key is down`, `Mouse position`, `Mouse button`, `Is hovered`, `Is touching` · @@ -109,6 +110,13 @@ shown on the node and editable in the inspector. (ports, settings, `run`), listed in `nodes/index.ts`; palette, ports, validation and inspector fields are generated from it. A port may declare a `default` for its inline value (e.g. `Move.scale = 1`), shown on the node and in the inspector. +- **Loops**: `Repeat` runs its `body` output a number of times, counting `index` up from 0, and + continues at `done`. Data nodes are cached for the length of one event pulse, but a loop round + drops that cache, so an expression built on `index` really changes with it. That is what makes + a graph able to work through a list: the Tetris example draws its 200 board cells that way, + `Object by name` taking the name the round put together (`"c" + index`). Two guards, not one: + a pulse may take 100 000 steps (a loop legitimately runs long), but exec edges may only nest + 1000 deep – that is what catches a flow feeding back into itself before the call stack does. - **Cross-object state**: a behavior's variables *are* the properties of its object, so another graph reads and writes them with `Object by name` → `Get property` / `Set property`. That is how the example game keeps one coin counter for the whole run. @@ -460,7 +468,7 @@ src/editor/graph/ GraphEditor (header + picker), GraphCanvas, NodeView, Nod src/editor/container/ ContainerEditor (header + picker), ContainerCanvas (a SceneViewport on a template) src/editor/texture/ TextureEditor (header + picker), TextureCanvas (picture + frames), SliceDialog, sheetView (the grid maths) api/ PHP backend data/projects/ persisted projects -tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/), smoke-jumprun, lib/ +tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/), smoke-jumprun, smoke-tetris, lib/ ``` The toolbar's right edge shows the frame rate (frames counted over a 0.5 s window, updated @@ -490,6 +498,7 @@ npm run build # writes dist/ and regenerates index.html export GM_REMOTE=user@host # where the browser runs; see below node tools/seed-e2e.mjs && node tools/e2e.mjs # 368 editor checks, screenshots in /tmp/gm-e2e-*.png 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 # 17 checks: the Tetris example, played end to end ``` **A run needs a working GPU.** An idle editor page keeps rendering a picture that never changes, and @@ -532,8 +541,9 @@ imports, base-to-string. Where a rule fires on something deliberate, the disable why (the `Log` node's `console.log`, a guard against untyped test hooks). Each script writes to its own project (`tools/lib/projects.mjs`): the **fixture** project is -rewritten by every e2e run and the **jump-run** project by its seeder. The **playground** -project is the author's own and is maintained in the editor – every tool refuses to touch it. +rewritten by every e2e run, the **jump-run** and **tetris** projects by their seeders. The +**playground** project is the author's own and is maintained in the editor – every tool refuses +to touch it. The e2e run is a list of steps in `tools/e2e/` (hierarchy, inspector, play, assets, drawing, graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect, @@ -551,6 +561,17 @@ and picking one up destroys the container, not just the sprite the collider repo (start, jump, collect, win, restart, lose) – it is the regression test for everything screens, text, mouse and platformer physics need. +**The second example** (`tools/seed-tetris.mjs`, project *Tetris*) is the same idea with data +instead of physics. The board is a **text of 200 characters**, one per cell – `.` for empty, +otherwise the digit of the shape that came to rest there. A cell is `Substring(grid, row · 10 + +column, 1)`, and clearing a full line is one expression: an empty row joined in front of +everything above and below it. What the player sees is a pool of objects the graph addresses by +name – `c0…c199` for the well, `p0…p3` for the piece that is falling – so nothing is spawned or +destroyed while playing. Every frame tries exactly **one** move (turning beats moving beats +falling), which is why the graph needs only a single "does it fit?" test instead of one per key. +`tools/smoke-tetris.mjs` plays it in a headless browser (fall, move, turn, drop, land, clear a +line, fill the well) – it is the regression test for loops, text nodes and names built at runtime. + The workspace folder itself is what nginx serves, so `npm run build` is the deployment. ## API diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index a65c1f0f..d4da6140 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -1,13 +1,17 @@ import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph'; import type { NodeContext } from './NodeContext'; -import { hasExecInput, inlineValue, isEventNode, requireNodeDef } from './NodeRegistry'; +import { hasExecInput, inlineValue, isEventNode, type RepeatResult, requireNodeDef } from './NodeRegistry'; import type { Value } from './values'; +/** How much work one pulse may do. A Repeat over a 10×20 board is 200 rounds, so this is generous. */ +const MAX_STEPS_PER_FIRE = 100_000; + /** - * Guards against runaway flows (a Sequence looping back into itself, …). The interpreter - * recurses along exec edges, so the limit also keeps the call stack shallow. + * How far exec edges may nest. The interpreter recurses along them, so this is what keeps a flow + * feeding back into itself off the call stack – a job the step budget can no longer do now that a + * legitimate pulse may run tens of thousands of steps. */ -const MAX_STEPS_PER_FIRE = 1000; +const MAX_DEPTH = 1000; export interface TraceListener { onEdge(edgeId: string): void; @@ -23,6 +27,8 @@ export class GraphInterpreter { /** Per-node state that persists across pulses (timer accumulators etc.). */ readonly nodeState = new Map>(); private outputCache = new Map>(); + /** Kept apart from `outputCache` because a loop round has to forget exactly these again. */ + private dataCache = new Map>(); private steps = 0; trace: TraceListener | null = null; @@ -43,35 +49,56 @@ export class GraphInterpreter { for (const node of this.eventNodes(type)) { if (filter && !filter(node)) continue; this.outputCache = new Map(); + this.dataCache = new Map(); this.steps = 0; this.outputCache.set(node.id, { ...outputs }); this.trace?.onNode(node.id); - this.follow(node, 'exec', ctx); + this.follow(node, 'exec', ctx, 0); fired++; } return fired; } - private follow(node: GraphNode, port: string, ctx: NodeContext): void { + private follow(node: GraphNode, port: string, ctx: NodeContext, depth: number): void { for (const edge of edgesOutOf(this.graph, node.id, port)) { const target = this.node(edge.to.node); if (!target) continue; this.trace?.onEdge(edge.id); - this.execute(target, ctx); + this.execute(target, ctx, depth + 1); } } - private execute(node: GraphNode, ctx: NodeContext): void { + private execute(node: GraphNode, ctx: NodeContext, depth: number): void { if (++this.steps > MAX_STEPS_PER_FIRE) - throw new Error('Behavior graph exceeded the step limit (infinite loop?)'); + throw new Error('Behavior graph exceeded the step limit (endless loop?)'); + if (depth > MAX_DEPTH) + throw new Error('Behavior graph nested too deeply (a flow feeding back on itself?)'); const def = requireNodeDef(node.type); if (!def.run) return; const inputs = this.collectInputs(node, ctx); const result = def.run(ctx, inputs, node.params); this.outputCache.set(node.id, result.outputs ?? {}); this.trace?.onNode(node.id); + if (result.repeat) this.loop(node, result.repeat, ctx, depth); const next = result.next === undefined ? [] : Array.isArray(result.next) ? result.next : [result.next]; - for (const port of next) this.follow(node, port, ctx); + for (const port of next) this.follow(node, port, ctx, depth); + } + + /** + * One round per count, the loop node's `index` output carrying the round number. Data nodes are + * cached for the whole pulse, so the round has to drop that cache first – otherwise every round + * would read the values the first one computed. + */ + private loop(node: GraphNode, repeat: RepeatResult, ctx: NodeContext, depth: number): void { + const outputs = this.outputCache.get(node.id) ?? {}; + for (let round = 0; round < repeat.times; round++) { + if (++this.steps > MAX_STEPS_PER_FIRE) + throw new Error('Behavior graph exceeded the step limit (endless loop?)'); + this.dataCache.clear(); + this.outputCache.set(node.id, { ...outputs, [repeat.index]: round }); + this.follow(node, repeat.port, ctx, depth); + } + this.dataCache.clear(); } /** Resolves every data input: connected → pulled from the source, else the inline param. */ @@ -89,7 +116,7 @@ export class GraphInterpreter { /** Value of one output port: from the pulse cache, or by evaluating a pure data node. */ private readOutput(nodeId: string, port: string, ctx: NodeContext): Value { - const cached = this.outputCache.get(nodeId); + const cached = this.outputCache.get(nodeId) ?? this.dataCache.get(nodeId); if (cached) return cached[port] ?? null; const source = this.node(nodeId); if (!source) return null; @@ -99,9 +126,9 @@ export class GraphInterpreter { if (++this.steps > MAX_STEPS_PER_FIRE) throw new Error('Behavior graph exceeded the step limit (data cycle?)'); // Mark before evaluating so a data cycle reads null instead of recursing forever. - this.outputCache.set(nodeId, {}); + this.dataCache.set(nodeId, {}); const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params).outputs ?? {}; - this.outputCache.set(nodeId, outputs); + this.dataCache.set(nodeId, outputs); this.trace?.onNode(nodeId); return outputs[port] ?? null; } diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 9e4bee60..008c4c1e 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -14,11 +14,23 @@ export interface PortDef { export type NodeCategory = 'event' | 'flow' | 'data' | 'variable' | 'action'; +/** + * How a loop node asks for one of its exec outputs to be run repeatedly: `port` carries the body, + * `index` is the number output seeded with the round number. + */ +export interface RepeatResult { + port: string; + index: string; + times: number; +} + export interface RunResult { /** Cached for the rest of the pulse. */ outputs?: Record; /** Exec output(s) to continue along; omitted = the node ends the flow. */ next?: string | string[]; + /** Runs `port` that many times before continuing along `next`. */ + repeat?: RepeatResult; } /** Palette, port rendering, connection validation and inspector fields are all derived from this. */ diff --git a/src/core/graph/nodes/data/FindText.ts b/src/core/graph/nodes/data/FindText.ts new file mode 100644 index 00000000..cbe2f8f4 --- /dev/null +++ b/src/core/graph/nodes/data/FindText.ts @@ -0,0 +1,16 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asString } from '../../values'; +import { num, str } from '../shared'; + +export const FindText: NodeDef = { + type: 'FindText', + label: 'Find text', + category: 'data', + description: 'Where "search" first appears in "text", counting from 0, and −1 when it does not appear.', + inputs: [str('text'), str('search')], + outputs: [num('index')], + params: [], + run: (_ctx, inputs) => ({ + outputs: { index: asString(inputs.text).indexOf(asString(inputs.search)) }, + }), +}; diff --git a/src/core/graph/nodes/data/ObjectByName.ts b/src/core/graph/nodes/data/ObjectByName.ts index b266df3d..50b85472 100644 --- a/src/core/graph/nodes/data/ObjectByName.ts +++ b/src/core/graph/nodes/data/ObjectByName.ts @@ -1,13 +1,16 @@ import type { NodeDef } from '../../NodeRegistry'; -import { obj } from '../shared'; +import { asString } from '../../values'; +import { obj, str } from '../shared'; export const ObjectByName: NodeDef = { type: 'ObjectByName', label: 'Object by name', category: 'data', - description: 'Finds a scene object by its name.', - inputs: [], + description: + 'Finds a scene object by its name. The name is an input, so it can be put together at runtime ' + + '("cell" + index).', + inputs: [str('name')], outputs: [obj('object')], - params: [{ key: 'name', label: 'Name', kind: 'text', default: '' }], - run: (ctx, _inputs, params) => ({ outputs: { object: ctx.findByName(String(params.name ?? '')) } }), + params: [], + run: (ctx, inputs) => ({ outputs: { object: ctx.findByName(asString(inputs.name)) } }), }; diff --git a/src/core/graph/nodes/data/Substring.ts b/src/core/graph/nodes/data/Substring.ts new file mode 100644 index 00000000..422bac87 --- /dev/null +++ b/src/core/graph/nodes/data/Substring.ts @@ -0,0 +1,21 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asNumber, asString } from '../../values'; +import { num, str, withDefault } from '../shared'; + +export const Substring: NodeDef = { + type: 'Substring', + label: 'Substring', + category: 'data', + description: + 'A part of a text: "count" characters starting at "start" (the first is 0). ' + + 'A negative count reaches to the end.', + inputs: [str('text'), num('start'), withDefault(num('count'), -1)], + outputs: [str('text')], + params: [], + run: (_ctx, inputs) => { + const text = asString(inputs.text); + const start = Math.max(0, Math.floor(asNumber(inputs.start))); + const count = Math.floor(asNumber(inputs.count)); + return { outputs: { text: count < 0 ? text.slice(start) : text.slice(start, start + count) } }; + }, +}; diff --git a/src/core/graph/nodes/data/ToNumber.ts b/src/core/graph/nodes/data/ToNumber.ts new file mode 100644 index 00000000..53b5fc09 --- /dev/null +++ b/src/core/graph/nodes/data/ToNumber.ts @@ -0,0 +1,14 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asNumber } from '../../values'; +import { any, num } from '../shared'; + +export const ToNumber: NodeDef = { + type: 'ToNumber', + label: 'To number', + category: 'data', + description: 'Reads a text as a number. Anything that is not one – an empty text, a word – reads as 0.', + inputs: [any('value')], + outputs: [num('result')], + params: [], + run: (_ctx, inputs) => ({ outputs: { result: asNumber(inputs.value) } }), +}; diff --git a/src/core/graph/nodes/flow/Repeat.ts b/src/core/graph/nodes/flow/Repeat.ts new file mode 100644 index 00000000..1ff03659 --- /dev/null +++ b/src/core/graph/nodes/flow/Repeat.ts @@ -0,0 +1,19 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asNumber } from '../../values'; +import { exec, num, withDefault } from '../shared'; + +export const Repeat: NodeDef = { + type: 'Repeat', + label: 'Repeat', + category: 'flow', + description: + 'Runs "body" a number of times, counting "index" up from 0, and continues at "done" afterwards. ' + + 'Every round reads its data inputs anew, so an expression built on "index" changes with it.', + inputs: [exec(), withDefault(num('times'), 1)], + outputs: [exec('body', 'body'), num('index'), exec('done', 'done')], + params: [], + run: (_ctx, inputs) => ({ + repeat: { port: 'body', index: 'index', times: Math.max(0, Math.floor(asNumber(inputs.times))) }, + next: 'done', + }), +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index f530dc8c..9cb58f0d 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -20,6 +20,7 @@ import { Compare } from './data/Compare'; import { Distance } from './data/Distance'; import { Divide } from './data/Divide'; import { Equals } from './data/Equals'; +import { FindText } from './data/FindText'; import { Floor } from './data/Floor'; import { GetPosition } from './data/GetPosition'; import { GetProperty } from './data/GetProperty'; @@ -49,8 +50,10 @@ import { Round } from './data/Round'; import { Self } from './data/Self'; import { Sqrt } from './data/Sqrt'; import { StringLiteral } from './data/String'; +import { Substring } from './data/Substring'; import { Subtract } from './data/Subtract'; import { Time } from './data/Time'; +import { ToNumber } from './data/ToNumber'; import { ToText } from './data/ToText'; import { OnClick } from './events/OnClick'; import { OnCollisionEnter } from './events/OnCollisionEnter'; @@ -63,6 +66,7 @@ import { OnStart } from './events/OnStart'; import { OnTimer } from './events/OnTimer'; import { OnUpdate } from './events/OnUpdate'; import { Branch } from './flow/Branch'; +import { Repeat } from './flow/Repeat'; import { Sequence } from './flow/Sequence'; import { GetVariable } from './variable/GetVariable'; import { SetVariable } from './variable/SetVariable'; @@ -86,6 +90,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ // flow Branch, Sequence, + Repeat, // variables GetVariable, SetVariable, @@ -120,7 +125,10 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ Time, // data: text and comparison ToText, + ToNumber, Join, + Substring, + FindText, Equals, Compare, And, diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index 744855af..b6e1a791 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -28,6 +28,7 @@ import { deserializeProject, serializeProject } from '../../src/core/store/seria import { type ProjectFixture, fakeContext, + fakeEntity, graphEditor, makeBehavior, makeProject, @@ -241,10 +242,146 @@ describe('GraphInterpreter', () => { [a, 'a', b, 'exec'], [b, 'a', a, 'exec'], ]); + expect(() => new GraphInterpreter(behavior.graph).fire('OnStart', fakeContext())).toThrow( + /nested too deeply/, + ); + }); + + it('stops a loop that would run forever', () => { + const { behavior, edit } = emptyBehavior(['count']); + const start = edit.addNode('OnStart'); + const repeat = edit.addNode('Repeat', { times: 10_000_000 }); + const log = edit.addNode('Log', { message: 'x' }); + edit.wire([ + [start, 'exec', repeat, 'exec'], + [repeat, 'body', log, 'exec'], + ]); expect(() => new GraphInterpreter(behavior.graph).fire('OnStart', fakeContext())).toThrow(/step limit/); }); }); +describe('Object by name', () => { + /** Both graphs below name "Target"; only the way the name reaches the node differs. */ + function lookup(nodeParams: Record, wireName: boolean) { + const { behavior, edit } = emptyBehavior(); + const start = edit.addNode('OnStart'); + const byName = edit.addNode('ObjectByName', nodeParams); + const log = edit.addNode('Log'); + edit.wire([ + [start, 'exec', log, 'exec'], + [byName, 'object', log, 'message'], + ]); + if (wireName) { + const join = edit.addNode('Join', { a: 'Tar', b: 'get' }); + edit.wire([[join, 'text', byName, 'name']]); + } + const ctx = fakeContext({ findByName: (name) => (name === 'Target' ? fakeEntity('Target') : null) }); + new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + return ctx.logs; + } + + it('still reads a name stored as a node setting, the way every saved project holds it', () => { + expect(lookup({ name: 'Target' }, false)).toEqual(['Target']); + }); + + it('takes a name put together at runtime', () => { + expect(lookup({}, true)).toEqual(['Target']); + }); +}); + +/** + * A loop is the one place where the per-pulse data cache would lie: every round has to read its + * inputs anew, or the body would keep computing with the values of the first round. + */ +describe('Repeat', () => { + /** "count = count + index" in the body of a Repeat, so the sum proves each round saw its own index. */ + function summingLoop(times: number) { + const fixture = emptyBehavior(['count']); + const { edit } = fixture; + const start = edit.addNode('OnStart'); + const repeat = edit.addNode('Repeat', { times }); + const get = edit.addNode('GetVariable', { variable: 'count' }); + const add = edit.addNode('Add'); + const set = edit.addNode('SetVariable', { variable: 'count' }); + edit.wire([ + [start, 'exec', repeat, 'exec'], + [repeat, 'body', set, 'exec'], + [get, 'value', add, 'a'], + [repeat, 'index', add, 'b'], + [add, 'result', set, 'value'], + ]); + return { ...fixture, nodes: { repeat, set } }; + } + + it('runs the body once per round, each round seeing its own index', () => { + const { behavior } = summingLoop(5); + const ctx = fakeContext({ variables: { count: 0 } }); + new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + // 0+1+2+3+4. A stale data cache would add the first round's index five times (0) instead. + expect(ctx.variables.count).toBe(10); + }); + + it('runs nothing at all for a count of zero', () => { + const { behavior } = summingLoop(0); + const ctx = fakeContext({ variables: { count: 7 } }); + new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + expect(ctx.variables.count).toBe(7); + }); + + it('continues along done when the rounds are over', () => { + const { behavior, edit, nodes } = summingLoop(3); + const done = edit.addNode('Log', { message: 'done' }); + edit.wire([[nodes.repeat, 'done', done, 'exec']]); + const ctx = fakeContext({ variables: { count: 0 } }); + new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + expect({ count: ctx.variables.count, logs: ctx.logs }).toEqual({ count: 3, logs: ['done'] }); + }); + + it('nests: the inner loop starts over for every round of the outer one', () => { + const { behavior, edit } = emptyBehavior(['count']); + const start = edit.addNode('OnStart'); + const outer = edit.addNode('Repeat', { times: 3 }); + const inner = edit.addNode('Repeat', { times: 4 }); + const get = edit.addNode('GetVariable', { variable: 'count' }); + const add = edit.addNode('Add', { b: 1 }); + const set = edit.addNode('SetVariable', { variable: 'count' }); + edit.wire([ + [start, 'exec', outer, 'exec'], + [outer, 'body', inner, 'exec'], + [inner, 'body', set, 'exec'], + [get, 'value', add, 'a'], + [add, 'result', set, 'value'], + ]); + const ctx = fakeContext({ variables: { count: 0 } }); + new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + expect(ctx.variables.count).toBe(12); + }); + + it('leaves the world alone after the loop: what follows reads current values, not the last round', () => { + const { behavior, edit } = emptyBehavior(['count']); + const start = edit.addNode('OnStart'); + const repeat = edit.addNode('Repeat', { times: 3 }); + const get = edit.addNode('GetVariable', { variable: 'count' }); + const add = edit.addNode('Add', { b: 1 }); + const set = edit.addNode('SetVariable', { variable: 'count' }); + const after = edit.addNode('SetVariable', { variable: 'count' }); + const afterAdd = edit.addNode('Multiply', { b: 10 }); + edit.wire([ + [start, 'exec', repeat, 'exec'], + [repeat, 'body', set, 'exec'], + [get, 'value', add, 'a'], + [add, 'result', set, 'value'], + [repeat, 'done', after, 'exec'], + [get, 'value', afterAdd, 'a'], + [afterAdd, 'result', after, 'value'], + ]); + const ctx = fakeContext({ variables: { count: 0 } }); + new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + // 3 rounds, then ×10 on the *current* count – a cache left over from the last round would give 20. + expect(ctx.variables.count).toBe(30); + }); +}); + describe('graph commands', () => { it('rejects incompatible, self and duplicate connections', () => { const { diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index fa988c2c..85291028 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -18,6 +18,8 @@ interface Case { outputs?: Record; /** Expected exec continuation; `null` asserts the flow ends here. */ next?: string | string[] | null; + /** Expected loop request; `null` asserts the node does not loop. */ + repeat?: RunResult['repeat'] | null; /** Assertions on what the node did to the context or to the handles it was given. */ after?(ctx: FakeContext): void; } @@ -43,6 +45,19 @@ const CASES: Record = { { name: '"false" as text is false', inputs: { condition: 'false' }, next: 'else' }, ], Sequence: [{ name: 'runs all three outputs in order', next: ['a', 'b', 'c'] }], + Repeat: [ + { + name: 'asks for as many body rounds as it was given, then continues at done', + inputs: { times: 3 }, + repeat: { port: 'body', index: 'index', times: 3 }, + next: 'done', + }, + { + name: 'a fractional count is cut and a negative one runs nothing', + inputs: { times: -2.7 }, + repeat: { port: 'body', index: 'index', times: 0 }, + }, + ], // --- variables ------------------------------------------------------------------------------- @@ -84,11 +99,11 @@ const CASES: Record = { ObjectByName: [ { name: 'asks the context for the name', - params: { name: 'Player' }, + inputs: { name: 'Player' }, context: { findByName: (name) => (name === 'Player' ? fakeEntity('Player') : null) }, outputs: { object: fakeEntity('Player') }, }, - { name: 'an unknown name is null', params: { name: 'Ghost' }, outputs: { object: null } }, + { name: 'an unknown name is null', inputs: { name: 'Ghost' }, outputs: { object: null } }, ], NameOf: [ { name: 'reads the name', inputs: { object: fakeEntity('Coin') }, outputs: { name: 'Coin' } }, @@ -229,6 +244,39 @@ const CASES: Record = { { name: 'text passes through unformatted', inputs: { value: 'x', decimals: 2 }, outputs: { text: 'x' } }, { name: 'decimals are clamped to 0…10', inputs: { value: 1, decimals: -5 }, outputs: { text: '1' } }, ], + ToNumber: [ + { name: 'reads a text as a number', inputs: { value: '42' }, outputs: { result: 42 } }, + { name: 'a word is 0', inputs: { value: 'twelve' }, outputs: { result: 0 } }, + ], + Substring: [ + { + name: 'takes count characters from start', + inputs: { text: 'abcdef', start: 2, count: 3 }, + outputs: { text: 'cde' }, + }, + { + name: 'a negative count reaches to the end', + inputs: { text: 'abcdef', start: 4, count: -1 }, + outputs: { text: 'ef' }, + }, + { + name: 'asking past the end gives what is there', + inputs: { text: 'ab', start: 1, count: 10 }, + outputs: { text: 'b' }, + }, + ], + FindText: [ + { + name: 'finds where the search first appears', + inputs: { text: '..#.#', search: '#' }, + outputs: { index: 2 }, + }, + { + name: 'text that does not appear is -1', + inputs: { text: '....', search: '#' }, + outputs: { index: -1 }, + }, + ], Join: [ { name: 'sticks values together as text', inputs: { a: 'Coins: ', b: 3 }, outputs: { text: 'Coins: 3' } }, { @@ -541,6 +589,7 @@ for (const [type, cases] of Object.entries(CASES)) { const { result, ctx } = run(type, c); if (c.outputs) expect(result.outputs ?? {}).toEqual(c.outputs); if (c.next !== undefined) expect(result.next ?? null).toEqual(c.next); + if (c.repeat !== undefined) expect(result.repeat ?? null).toEqual(c.repeat); c.after?.(ctx); }); } diff --git a/todo-notes/done/tetris-example-game.md b/todo-notes/done/tetris-example-game.md new file mode 100644 index 00000000..849fad36 --- /dev/null +++ b/todo-notes/done/tetris-example-game.md @@ -0,0 +1,95 @@ +## Nächstes spiel: Tetris + +Ganz simpel halten, also wirklich nur das nötigste: +- die möglichen shapes die es halt so gibt +- ein zufallspawn eines dieser shapes das langsam nach unten geht +- links/rechts pfeilstasten um zu bewegen, pfeil rauf um im uhrzeigsinn zu drehen, pfeil runter gedrückt halten um nach unten zu beschleunigen +- eine line gefüllte line solved +- obere grenze = game over, ansonsten ist es einfach ein endlosspiel +- counter ui der einfach nur die gesolved lines zählt + +was wir nicht brauchen: +- "levels", sprich dass shapes schneller werden beim runterfallen +- titlescreen oder gameover screen, wenn gameover einfach spiel stoppen +- animationen, spezielle texturen, oder sonst irgendwelchen schnick schnack +- e2e erweiterung zu dem projekt => dieses projekt braucht keine speziellen e2e tests die man immer wieder durchlaufen muss + +habe ich irgendwas vergessen? wenn ja, rückfragen, ansonsten einfach project seed anlegen und projekt generieren + +falls nodes fehlen, oder praktische nodes sinnvoll wären die noch fehlen, dürfen diese natürlich erstellt werden + +--- + +**Umgesetzt (2026-09-09).** Projekt *Tetris* (`2072b56583af`), Seed `tools/seed-tetris.mjs`, +gespielt von `tools/smoke-tetris.mjs`. Alles aus deiner Liste ist drin, nichts davon, was du +ausgeschlossen hast: keine Level, kein Titel- oder Game-Over-Screen (Game Over stoppt das Spiel +per `Stop game`), keine Animationen, keine Texturen, und **keine Erweiterung der E2E-Suite** — +der Smoke-Lauf ist wie beim Jump-and-Run ein eigenes Skript, das nur laeuft, wenn dieses Projekt +oder die Engine darunter sich aendert. + +**Das Brett ist ein Text.** 200 Zeichen, eins pro Zelle: `.` fuer leer, sonst die Ziffer der Form, +die dort liegengeblieben ist. Damit sind die beiden Operationen, auf denen Tetris steht, je ein +einziger Ausdruck statt einer Schleife: eine Zelle ist `Substring(grid, zeile·10 + spalte, 1)`, +und eine volle Reihe zu loesen ist eine leere Reihe, vor alles darueber und darunter gejoint. +Gezeichnet wird aus einem **Vorrat benannter Objekte** — `c0…c199` fuer das Brett, `p0…p3` fuer +den fallenden Stein. Waehrend des Spielens wird nichts gespawnt und nichts zerstoert. + +**Vier neue Nodes, alle allgemein und nicht Tetris-eigen** (danach gefragt, du hast (a) gewaehlt): + +| | | +|---|---| +| `Repeat` (flow) | `times` → `body` / `index` / `done`. Der Interpreter kann seitdem Schleifen: er verwirft den Datencache **pro Runde**, sonst rechnete jede Runde mit den Werten der ersten. | +| `Substring` | `count` Zeichen ab `start`; negatives `count` reicht bis zum Ende | +| `Find text` | wo ein Text zuerst vorkommt, `−1` wenn nicht — damit ist "Reihe voll" = *kein Punkt darin* | +| `To number` | die fehlende Gegenrichtung zu `To text` | + +Dazu eine Aenderung an einem vorhandenen Node: **`Object by name` nimmt den Namen jetzt als +Eingang** statt nur als Einstellung. Ohne das waeren 200 Zellen nicht adressierbar — die Zeichen- +schleife baut `"c" + index`. Alte Projekte sind unberuehrt: ein gespeicherter Name liegt in +`params.name`, und genau von dort liest ein unverbundener Eingang (eigener Test dafuer). + +**Der Kunstgriff, der den Graphen klein haelt:** jeder Frame versucht **genau einen** Zug — +Drehen schlaegt Seitwaerts schlaegt Fallen. Die Tasten bewegen nichts selbst, sie hinterlassen +einen Wunsch (`wantDx`, `wantRot`), den der naechste Frame einloest. Dadurch gibt es die +Pruefung "passen die vier Zellen dort hin?" **einmal** im ganzen Graphen statt einmal pro Taste. +Ein um einen Frame verzoegerter Seitwaertsschritt ist bei 60 fps unsichtbar; drei Kopien +derselben 30 Nodes waeren es nicht. + +**Wie das Landen ins Brett kommt:** der gelandete Stein wird nicht noch einmal aus der Formtabelle +ausgerechnet, sondern **von seinen vier Zellobjekten abgelesen** (`Get position` → Spalte/Zeile). +Sie stehen genau dort, wo der Stein ist — das Zeichnen ist die einzige Stelle, die sie anfasst. + +**Belegt** — `tools/smoke-tetris.mjs`, remote im echten Browser: **ALL PASSED, 17 Checks.** + +``` +PASS the piece falls on its own – row 1 → 4 +PASS the left arrow moves the piece one column – 4,5,4,5 → 3,4,3,4 +PASS the up arrow turns the piece – rot 0 → 1 +PASS the board draws exactly the cells the board text names – {"lit":8,"inText":8} +PASS a full line disappears and is counted – lines 1, bottom row "....44...." +PASS a piece that cannot fall at the very top stops the game – stopped +``` + +Der Brett-Check ist der wichtigste: er haelt den **Text** gegen die **Bildschirmzellen** — ohne ihn +koennte die Zeichenschleife irgendetwas anzeigen und alles andere trotzdem gruen sein. Fuer die +volle Reihe wird die unterste Reihe vorher gefuellt, damit jeder Stein sie vollmacht, egal welcher +kommt. Der Game-Over-Check wartet auf die Bedingung statt auf die Uhr — mit fester Wartezeit war +er in einem von zwei Laeufen rot, und ein Check, der wuerfelt, ist keiner. + +**Was ich selbst entschieden habe:** + +- **Kein Wall-Kick.** Eine Drehung, die nicht passt, findet nicht statt — sie wird nicht an der + Wand vorbeigeschoben. Das ist die simple Regel, und "ganz simpel" war die Vorgabe. +- **Farbe pro Form**, wie im Original. Kostet drei Nodes: die Ziffer im Bretttext sucht ihre + Farbe aus einem zweiten Text (`colors`), acht Ziffern je Farbe. +- **Drehachse** ist die Box der Form (I in 4×4, O in 2×2, Rest 3×3), nicht der Schwerpunkt. Ohne + Wall-Kick ist das das Verhalten, das am wenigsten ueberrascht. +- **Game Over = ein Stein kann ganz oben (`py ≤ 0`) nicht einmal fallen.** Kein zweiter Test beim + Erscheinen, damit die Pruefung an der einen Stelle bleibt. +- **Tempo:** eine Reihe pro halber Sekunde, gehaltene Pfeil-runter-Taste zehnmal so schnell. Beides + steht als Variable `stepTime` im Inspector. + +**Was du wissen solltest:** der Board-Container traegt **205 Objekte** (Wanne + 200 Zellen + 4 +Steinzellen). Im Editor sieht man davon nichts — die Szene zeigt acht Objekte, der Rest liegt +im Container. Wer ihn aber oeffnet, bekommt eine lange Liste. Das ist der Preis dafuer, dass +nichts gespawnt wird. diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs index 973d7f0b..19fe0ca6 100644 --- a/tools/lib/harness.mjs +++ b/tools/lib/harness.mjs @@ -32,7 +32,9 @@ const KEYS = { Escape: { key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 }, Space: { key: ' ', code: 'Space', windowsVirtualKeyCode: 32 }, ArrowLeft: { key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 }, + ArrowUp: { key: 'ArrowUp', code: 'ArrowUp', windowsVirtualKeyCode: 38 }, ArrowRight: { key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }, + ArrowDown: { key: 'ArrowDown', code: 'ArrowDown', windowsVirtualKeyCode: 40 }, Delete: { key: 'Delete', code: 'Delete', windowsVirtualKeyCode: 46 }, a: { key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65 }, g: { key: 'g', code: 'KeyG', windowsVirtualKeyCode: 71 }, diff --git a/tools/lib/projects.mjs b/tools/lib/projects.mjs index 709fe120..6e961c0a 100644 --- a/tools/lib/projects.mjs +++ b/tools/lib/projects.mjs @@ -5,6 +5,7 @@ * playground the user's own project. No tool writes it – it is maintained in the editor. * fixture rewritten by every e2e run (tools/seed-e2e.mjs + tools/e2e.mjs). * jumpRun the example game (tools/seed-jumprun.mjs, played by tools/smoke-jumprun.mjs). + * tetris the second example game (tools/seed-tetris.mjs, played by tools/smoke-tetris.mjs). * * All ids exist on the server (created via POST ?r=projects); PUT does not create projects. */ @@ -12,6 +13,7 @@ export const PROJECTS = { playground: '649188c15414', fixture: '3caa9b6a0c92', jumpRun: '3d271f3bb58b', + tetris: '2072b56583af', }; export const DEFAULT_BASE_URL = 'https://nockal.com/game-maker/'; diff --git a/tools/seed-tetris.mjs b/tools/seed-tetris.mjs new file mode 100644 index 00000000..5781a80f --- /dev/null +++ b/tools/seed-tetris.mjs @@ -0,0 +1,807 @@ +#!/usr/bin/env node +/** + * Writes the second example game: Tetris, again built only from assets and one node graph. + * + * The board is a text: 200 characters, one per cell, '.' for empty and the digit of the piece + * that came to rest there. That makes the two operations the game is built on single expressions + * instead of loops – a cell is `Substring(grid, row·10 + column, 1)`, and clearing a full line is + * an empty row joined in front of everything above and below it. + * + * What the player sees is a pool of objects the graph addresses by name: `c0…c199` for the board + * and `p0…p3` for the piece that is falling. Nothing is spawned or destroyed while playing. + * + * Usage: PATH=/opt/node22/bin:$PATH node tools/seed-tetris.mjs [projectId] [baseUrl] + */ +import { edge, node, sceneObject, variable } from './lib/builders.mjs'; +import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs'; + +const { projectId, baseUrl } = cliArgs('seed-tetris.mjs', PROJECTS.tetris); + +// --- board geometry -------------------------------------------------------------------------- + +const COLS = 10; +const ROWS = 20; +const CELL = 16; +/** Cell 0 sits at the top left of the board; these turn a column/row into the object's position. */ +const CELL_X = (col) => col * CELL - (COLS * CELL) / 2 + CELL / 2; +const CELL_Y = (row) => row * CELL - (ROWS * CELL) / 2 + CELL / 2; + +const EMPTY_CELL = '.'; +const EMPTY_ROW = EMPTY_CELL.repeat(COLS); +const EMPTY_GRID = EMPTY_CELL.repeat(COLS * ROWS); + +// --- colours --------------------------------------------------------------------------------- + +const BACKGROUND = 0x151821; +const WELL = 0x0d1017; +const WHITE = 0xf2f5f7; +const HINT = 0x9aa3b2; + +/** One per shape, in the order of `SHAPES` below. */ +const SHAPE_COLORS = [0x2ec9d6, 0xf5c542, 0xa35fd6, 0x63c74d, 0xe0553f, 0x4a7de0, 0xe08a3f]; + +// --- the seven shapes ------------------------------------------------------------------------ + +/** + * Each shape as its four cells in a box of `size` × `size`, the box being what turning rotates + * inside: (x, y) → (size − 1 − y, x) is a quarter turn clockwise. I needs a 4-box, O a 2-box. + */ +const SHAPES = [ + { + name: 'I', + size: 4, + cells: [ + [0, 1], + [1, 1], + [2, 1], + [3, 1], + ], + }, + { + name: 'O', + size: 2, + cells: [ + [0, 0], + [1, 0], + [0, 1], + [1, 1], + ], + }, + { + name: 'T', + size: 3, + cells: [ + [1, 0], + [0, 1], + [1, 1], + [2, 1], + ], + }, + { + name: 'S', + size: 3, + cells: [ + [1, 0], + [2, 0], + [0, 1], + [1, 1], + ], + }, + { + name: 'Z', + size: 3, + cells: [ + [0, 0], + [1, 0], + [1, 1], + [2, 1], + ], + }, + { + name: 'J', + size: 3, + cells: [ + [0, 0], + [0, 1], + [1, 1], + [2, 1], + ], + }, + { + name: 'L', + size: 3, + cells: [ + [2, 0], + [0, 1], + [1, 1], + [2, 1], + ], + }, +]; + +const turned = (cells, size) => cells.map(([x, y]) => [size - 1 - y, x]); + +/** + * All 28 rotations in one text, eight digits each (x and y of the four cells), so the graph reads + * a rotation as `Substring(shapes, (shape·4 + rotation)·8, 8)`. + */ +function shapeTable() { + let table = ''; + for (const shape of SHAPES) { + let cells = shape.cells; + for (let rotation = 0; rotation < 4; rotation++) { + table += cells.map(([x, y]) => `${x}${y}`).join(''); + cells = turned(cells, shape.size); + } + } + return table; +} + +/** Colours as eight digits each, so a cell's digit picks its colour out of one text. */ +const colorTable = () => SHAPE_COLORS.map((c) => String(c).padStart(8, '0')).join(''); + +/** The column a shape starts in, so every piece appears centred over the well. */ +const spawnColumns = () => SHAPES.map((s) => Math.floor((COLS - s.size) / 2)).join(''); + +// --- the board container --------------------------------------------------------------------- + +/** The well, the 200 board cells (switched off = empty) and the four cells of the falling piece. */ +const boardTemplate = [ + sceneObject('well', 'well-sprite', { name: 'Well' }), + ...Array.from({ length: COLS * ROWS }, (_, i) => + sceneObject(`cell${i}`, 'cell-sprite', { + name: `c${i}`, + x: CELL_X(i % COLS), + y: CELL_Y(Math.floor(i / COLS)), + z: 1, + overrides: { enabled: false }, + }), + ), + ...Array.from({ length: 4 }, (_, i) => + sceneObject(`piece${i}`, 'cell-sprite', { name: `p${i}`, z: 2, overrides: { enabled: false } }), + ), +]; + +// --- graph helpers ----------------------------------------------------------------------------- + +// Nodes are laid out in bands, one per strand of the graph, so the editor shows the game as a +// stack of readable blocks rather than one cloud. +const nodes = []; +const edges = []; +let band = 0; + +/** Starts the next band. */ +const strand = () => band++; + +/** Places a node; `column`/`row` are grid steps inside the current band. */ +function at(id, type, column, row, params = {}) { + nodes.push(node(id, type, column * 220, band * 1400 + row * 90, params)); + return id; +} + +function link(from, fromPort, to, toPort) { + edges.push(edge(`e${edges.length + 1}`, from, fromPort, to, toPort)); +} + +/** Chains exec outputs: `flow(a, b, c)` runs a, then b, then c. */ +function flow(...ids) { + for (let i = 0; i < ids.length - 1; i++) link(ids[i], 'exec', ids[i + 1], 'exec'); +} + +// Reading a variable is so common that it gets a shorthand. +const get = (id, key, column, row) => at(id, 'GetVariable', column, row, { variable: key }); +const set = (id, key, column, row, value) => + at(id, 'SetVariable', column, row, value === undefined ? { variable: key } : { variable: key, value }); + +/** + * The four cells of one rotation, as the graph computes them: reads `shapes` at + * (shape·4 + rotation)·8 and takes the two digits belonging to the round the loop is in. + * Returns the ids carrying the cell's column and row, offset by the piece's position. + */ +function cellOfRound(prefix, { loop, shapeVar, rotVar, xVar, yVar }, column, row) { + const shape = get(`${prefix}-shape`, shapeVar, column, row); + const rot = get(`${prefix}-rot`, rotVar, column, row + 1); + const shapes = get(`${prefix}-shapes`, 'shapes', column, row + 2); + const four = at(`${prefix}-x4`, 'Multiply', column + 1, row, { b: 4 }); + const plus = at(`${prefix}-plus`, 'Add', column + 2, row); + const eight = at(`${prefix}-x8`, 'Multiply', column + 3, row, { b: 8 }); + const entry = at(`${prefix}-entry`, 'Substring', column + 4, row, { count: 8 }); + link(shape, 'value', four, 'a'); + link(four, 'result', plus, 'a'); + link(rot, 'value', plus, 'b'); + link(plus, 'result', eight, 'a'); + link(shapes, 'value', entry, 'text'); + link(eight, 'result', entry, 'start'); + + const twice = at(`${prefix}-k2`, 'Multiply', column + 4, row + 3, { b: 2 }); + link(loop, 'index', twice, 'a'); + const digitX = at(`${prefix}-dx`, 'Substring', column + 5, row + 2, { count: 1 }); + const digitY = at(`${prefix}-dy`, 'Substring', column + 5, row + 5, { count: 1 }); + const next = at(`${prefix}-k2b`, 'Add', column + 4, row + 5, { b: 1 }); + link(twice, 'result', next, 'a'); + link(entry, 'text', digitX, 'text'); + link(twice, 'result', digitX, 'start'); + link(entry, 'text', digitY, 'text'); + link(next, 'result', digitY, 'start'); + + const numX = at(`${prefix}-nx`, 'ToNumber', column + 6, row + 2); + const numY = at(`${prefix}-ny`, 'ToNumber', column + 6, row + 5); + link(digitX, 'text', numX, 'value'); + link(digitY, 'text', numY, 'value'); + + const baseX = get(`${prefix}-basex`, xVar, column + 6, row); + const baseY = get(`${prefix}-basey`, yVar, column + 6, row + 7); + const cellX = at(`${prefix}-cx`, 'Add', column + 7, row + 1); + const cellY = at(`${prefix}-cy`, 'Add', column + 7, row + 6); + link(baseX, 'value', cellX, 'a'); + link(numX, 'result', cellX, 'b'); + link(baseY, 'value', cellY, 'a'); + link(numY, 'result', cellY, 'b'); + return { cellX, cellY }; +} + +/** `ObjectByName` with a name built at runtime: prefix + the round the loop is in. */ +function objectOfRound(prefix, loop, namePrefix, column, row) { + const text = at(`${prefix}-text`, 'ToText', column, row, { decimals: 0 }); + const name = at(`${prefix}-name`, 'Join', column + 1, row, { a: namePrefix }); + const object = at(`${prefix}-obj`, 'ObjectByName', column + 2, row); + link(loop, 'index', text, 'value'); + link(text, 'text', name, 'b'); + link(name, 'text', object, 'name'); + return object; +} + +// --- the graph --------------------------------------------------------------------------------- + +// 1) On start: an empty board, no piece, nothing pressed. The world is rebuilt for every run, so +// this only matters when the behavior was left running in the editor. +strand(); +at('s-start', 'OnStart', 0, 0); +set('s-grid', 'grid', 1, 0, EMPTY_GRID); +set('s-lines', 'lines', 2, 0, 0); +set('s-piece', 'hasPiece', 3, 0, false); +set('s-fall', 'fall', 4, 0, 0); +set('s-dx', 'wantDx', 5, 0, 0); +set('s-turn', 'wantRot', 6, 0, 0); +at('s-label', 'ObjectByName', 6, 2, { name: 'LinesLabel' }); +at('s-text', 'SetText', 7, 0, { text: 'Lines 0' }); +flow('s-start', 's-grid', 's-lines', 's-piece', 's-fall', 's-dx', 's-turn', 's-text'); +link('s-label', 'object', 's-text', 'object'); + +// 2) The keys do not move anything themselves – they leave a wish that the next frame grants. That +// way every attempt to move goes through the one test below, whatever asked for it. +strand(); +at('k-left', 'OnKeyDown', 0, 0, { key: 'ArrowLeft' }); +set('k-setleft', 'wantDx', 1, 0, -1); +at('k-right', 'OnKeyDown', 0, 3, { key: 'ArrowRight' }); +set('k-setright', 'wantDx', 1, 3, 1); +at('k-up', 'OnKeyDown', 0, 6, { key: 'ArrowUp' }); +set('k-setup', 'wantRot', 1, 6, 1); +flow('k-left', 'k-setleft'); +flow('k-right', 'k-setright'); +flow('k-up', 'k-setup'); + +// 3) Every frame does one thing: either a new piece appears, or the piece tries one move. Which +// move is decided first (turning beats moving beats falling) and then tested in one place. +strand(); +at('u-update', 'OnUpdate', 0, 0); +get('u-has', 'hasPiece', 0, 2); +at('u-br', 'Branch', 1, 0); +flow('u-update', 'u-br'); +link('u-has', 'value', 'u-br', 'condition'); + +// 3a) No piece: draw one of the seven at random, at the column that centres it. +at('n-random', 'Random', 2, 4, { min: 0, max: 7 }); +at('n-floor', 'Floor', 3, 4); +set('n-shape', 'shape', 4, 4); +set('n-rot', 'rot', 5, 4, 0); +get('n-shape2', 'shape', 5, 7); +get('n-columns', 'spawnX', 5, 9); +at('n-column', 'Substring', 6, 8, { count: 1 }); +at('n-number', 'ToNumber', 7, 8); +set('n-px', 'px', 6, 4); +set('n-py', 'py', 7, 4, 0); +set('n-fall', 'fall', 8, 4, 0); +set('n-has', 'hasPiece', 9, 4, true); +link('u-br', 'else', 'n-shape', 'exec'); +link('n-random', 'result', 'n-floor', 'value'); +link('n-floor', 'result', 'n-shape', 'value'); +link('n-shape2', 'value', 'n-column', 'start'); +link('n-columns', 'value', 'n-column', 'text'); +link('n-column', 'text', 'n-number', 'value'); +link('n-number', 'result', 'n-px', 'value'); +flow('n-shape', 'n-rot', 'n-px', 'n-py', 'n-fall', 'n-has'); + +// 3b) A piece is falling: the candidate starts out as where it already is. +strand(); +get('c-px', 'px', 0, 2); +get('c-py', 'py', 1, 2); +get('c-rot', 'rot', 2, 2); +set('c-nx', 'nx', 0, 0); +set('c-ny', 'ny', 1, 0); +set('c-nrot', 'nrot', 2, 0); +set('c-kind', 'kind', 3, 0, ''); +link('u-br', 'then', 'c-nx', 'exec'); +link('c-px', 'value', 'c-nx', 'value'); +link('c-py', 'value', 'c-ny', 'value'); +link('c-rot', 'value', 'c-nrot', 'value'); +flow('c-nx', 'c-ny', 'c-nrot', 'c-kind'); + +// Turning wins: a quarter turn clockwise is one step around the four rotations of the shape. +get('c-wantrot', 'wantRot', 4, 3); +at('c-turning', 'Compare', 5, 3, { op: '>', b: 0 }); +at('c-brturn', 'Branch', 5, 0); +get('c-rot2', 'rot', 6, 3); +at('c-plus1', 'Add', 7, 3, { b: 1 }); +at('c-wrap', 'Modulo', 8, 3, { b: 4 }); +set('c-setrot', 'nrot', 6, 0); +set('c-kindturn', 'kind', 7, 0, 'turn'); +set('c-usedrot', 'wantRot', 8, 0, 0); +flow('c-kind', 'c-brturn'); +link('c-wantrot', 'value', 'c-turning', 'a'); +link('c-turning', 'result', 'c-brturn', 'condition'); +link('c-brturn', 'then', 'c-setrot', 'exec'); +link('c-rot2', 'value', 'c-plus1', 'a'); +link('c-plus1', 'result', 'c-wrap', 'a'); +link('c-wrap', 'result', 'c-setrot', 'value'); +flow('c-setrot', 'c-kindturn', 'c-usedrot'); + +// Then sideways, one column per key press. +get('c-wantdx', 'wantDx', 4, 8); +at('c-moving', 'Equals', 5, 8, { b: 0 }); +at('c-notmoving', 'Not', 6, 8); +at('c-brmove', 'Branch', 6, 6); +get('c-px2', 'px', 7, 9); +at('c-shift', 'Add', 8, 9); +set('c-setnx', 'nx', 7, 6); +set('c-kindmove', 'kind', 8, 6, 'move'); +set('c-useddx', 'wantDx', 9, 6, 0); +link('c-brturn', 'else', 'c-brmove', 'exec'); +link('c-wantdx', 'value', 'c-moving', 'a'); +link('c-moving', 'result', 'c-notmoving', 'value'); +link('c-notmoving', 'result', 'c-brmove', 'condition'); +link('c-brmove', 'then', 'c-setnx', 'exec'); +link('c-px2', 'value', 'c-shift', 'a'); +link('c-wantdx', 'value', 'c-shift', 'b'); +link('c-shift', 'result', 'c-setnx', 'value'); +flow('c-setnx', 'c-kindmove', 'c-useddx'); + +// And otherwise the clock: holding the down key lets it run ten times as fast. +strand(); +get('f-fall', 'fall', 0, 3); +at('f-down', 'KeyIsDown', 0, 6, { key: 'ArrowDown' }); +at('f-boost', 'Multiply', 1, 6, { b: 9 }); +at('f-rate', 'Add', 2, 6, { a: 1 }); +at('f-step', 'Multiply', 3, 5); +at('f-add', 'Add', 4, 3); +set('f-setfall', 'fall', 1, 0); +link('c-brmove', 'else', 'f-setfall', 'exec'); +link('f-down', 'down', 'f-boost', 'a'); +link('f-boost', 'result', 'f-rate', 'b'); +link('u-update', 'dt', 'f-step', 'a'); +link('f-rate', 'result', 'f-step', 'b'); +link('f-fall', 'value', 'f-add', 'a'); +link('f-step', 'result', 'f-add', 'b'); +link('f-add', 'result', 'f-setfall', 'value'); + +get('f-fall2', 'fall', 2, 3); +get('f-time', 'stepTime', 2, 5); +at('f-due', 'Compare', 3, 3, { op: '>=' }); +at('f-brdue', 'Branch', 4, 0); +at('f-minus', 'Subtract', 5, 3); +set('f-rest', 'fall', 5, 0); +get('f-py', 'py', 6, 3); +at('f-down1', 'Add', 7, 3, { b: 1 }); +set('f-setny', 'ny', 6, 0); +set('f-kind', 'kind', 7, 0, 'fall'); +flow('f-setfall', 'f-brdue'); +link('f-fall2', 'value', 'f-due', 'a'); +link('f-time', 'value', 'f-due', 'b'); +link('f-due', 'result', 'f-brdue', 'condition'); +link('f-brdue', 'then', 'f-rest', 'exec'); +link('f-fall2', 'value', 'f-minus', 'a'); +link('f-time', 'value', 'f-minus', 'b'); +link('f-minus', 'result', 'f-rest', 'value'); +link('f-py', 'value', 'f-down1', 'a'); +link('f-down1', 'result', 'f-setny', 'value'); +flow('f-rest', 'f-setny', 'f-kind'); + +// 4) The one test: do the four cells of the candidate fit? Every attempt above ends here. +strand(); +set('t-ok', 'ok', 0, 0, true); +at('t-loop', 'Repeat', 1, 0, { times: 4 }); +flow('c-usedrot', 't-ok'); +flow('c-useddx', 't-ok'); +flow('f-kind', 't-ok'); +flow('t-ok', 't-loop'); + +const candidate = cellOfRound( + 't', + { loop: 't-loop', shapeVar: 'shape', rotVar: 'nrot', xVar: 'nx', yVar: 'ny' }, + 2, + 4, +); +at('t-left', 'Compare', 10, 0, { op: '<', b: 0 }); +at('t-right', 'Compare', 10, 2, { op: '>', b: COLS - 1 }); +at('t-side', 'Or', 11, 1); +at('t-bottom', 'Compare', 10, 4, { op: '>', b: ROWS - 1 }); +at('t-outside', 'Or', 12, 2); +link(candidate.cellX, 'result', 't-left', 'a'); +link(candidate.cellX, 'result', 't-right', 'a'); +link('t-left', 'result', 't-side', 'a'); +link('t-right', 'result', 't-side', 'b'); +link(candidate.cellY, 'result', 't-bottom', 'a'); +link('t-side', 'result', 't-outside', 'a'); +link('t-bottom', 'result', 't-outside', 'b'); + +// A cell outside the board reads as empty here, but the check above has already caught it. +at('t-row', 'Multiply', 10, 7, { b: COLS }); +at('t-index', 'Add', 11, 7); +get('t-grid', 'grid', 11, 9); +at('t-cell', 'Substring', 12, 8, { count: 1 }); +at('t-empty', 'Equals', 13, 8, { b: EMPTY_CELL }); +at('t-taken', 'Not', 14, 8); +at('t-blocked', 'Or', 15, 4); +at('t-br', 'Branch', 16, 0); +set('t-no', 'ok', 17, 0, false); +link(candidate.cellY, 'result', 't-row', 'a'); +link('t-row', 'result', 't-index', 'a'); +link(candidate.cellX, 'result', 't-index', 'b'); +link('t-grid', 'value', 't-cell', 'text'); +link('t-index', 'result', 't-cell', 'start'); +link('t-cell', 'text', 't-empty', 'a'); +link('t-empty', 'result', 't-taken', 'value'); +link('t-outside', 'result', 't-blocked', 'a'); +link('t-taken', 'result', 't-blocked', 'b'); +link('t-loop', 'body', 't-br', 'exec'); +link('t-blocked', 'result', 't-br', 'condition'); +link('t-br', 'then', 't-no', 'exec'); + +// 5) It fits: the candidate becomes the piece. It does not: only a fall means anything – the +// piece has landed (or, still at the very top, the well is full). +strand(); +get('m-ok', 'ok', 0, 2); +at('m-br', 'Branch', 1, 0); +get('m-nx', 'nx', 2, 3); +get('m-ny', 'ny', 3, 3); +get('m-nrot', 'nrot', 4, 3); +set('m-px', 'px', 2, 0); +set('m-py', 'py', 3, 0); +set('m-rot', 'rot', 4, 0); +link('t-loop', 'done', 'm-br', 'exec'); +link('m-ok', 'value', 'm-br', 'condition'); +link('m-br', 'then', 'm-px', 'exec'); +link('m-nx', 'value', 'm-px', 'value'); +link('m-ny', 'value', 'm-py', 'value'); +link('m-nrot', 'value', 'm-rot', 'value'); +flow('m-px', 'm-py', 'm-rot'); + +get('m-kind', 'kind', 2, 7); +at('m-landed', 'Equals', 3, 7, { b: 'fall' }); +at('m-brland', 'Branch', 3, 5); +get('m-py2', 'py', 4, 8); +at('m-attop', 'Compare', 5, 8, { op: '<=', b: 0 }); +at('m-brover', 'Branch', 5, 5); +at('m-stop', 'StopGame', 6, 5); +link('m-br', 'else', 'm-brland', 'exec'); +link('m-kind', 'value', 'm-landed', 'a'); +link('m-landed', 'result', 'm-brland', 'condition'); +link('m-brland', 'then', 'm-brover', 'exec'); +link('m-py2', 'value', 'm-attop', 'a'); +link('m-attop', 'result', 'm-brover', 'condition'); +link('m-brover', 'then', 'm-stop', 'exec'); + +// 6) Drawing the piece: the four cell objects are moved to where the piece is. This is the only +// place that touches them, so where they stand *is* where the piece is – which is what the +// landing below reads back instead of working the shape out a second time. +strand(); +at('d-loop', 'Repeat', 0, 0, { times: 4 }); +flow('m-rot', 'd-loop'); +flow('n-has', 'd-loop'); +const drawn = cellOfRound( + 'd', + { loop: 'd-loop', shapeVar: 'shape', rotVar: 'rot', xVar: 'px', yVar: 'py' }, + 1, + 4, +); +const pieceObject = objectOfRound('d-p', 'd-loop', 'p', 9, 0); +at('d-x', 'Multiply', 9, 4, { b: CELL }); +at('d-atx', 'Add', 10, 4, { b: CELL_X(0) }); +at('d-y', 'Multiply', 9, 6, { b: CELL }); +at('d-aty', 'Add', 10, 6, { b: CELL_Y(0) }); +link(drawn.cellX, 'result', 'd-x', 'a'); +link('d-x', 'result', 'd-atx', 'a'); +link(drawn.cellY, 'result', 'd-y', 'a'); +link('d-y', 'result', 'd-aty', 'a'); + +get('d-shapec', 'shape', 12, 8); +get('d-colors', 'colors', 12, 10); +at('d-offset', 'Multiply', 13, 8, { b: 8 }); +at('d-hex', 'Substring', 14, 9, { count: 8 }); +at('d-color', 'ToNumber', 15, 9); +at('d-show', 'SetEnabled', 12, 0, { enabled: true }); +at('d-place', 'SetPosition', 13, 0); +at('d-paint', 'SetProperty', 14, 0, { property: 'color' }); +link('d-loop', 'body', 'd-show', 'exec'); +link(pieceObject, 'object', 'd-show', 'object'); +link(pieceObject, 'object', 'd-place', 'object'); +link(pieceObject, 'object', 'd-paint', 'object'); +link('d-atx', 'result', 'd-place', 'x'); +link('d-aty', 'result', 'd-place', 'y'); +link('d-shapec', 'value', 'd-offset', 'a'); +link('d-colors', 'value', 'd-hex', 'text'); +link('d-offset', 'result', 'd-hex', 'start'); +link('d-hex', 'text', 'd-color', 'value'); +link('d-color', 'result', 'd-paint', 'value'); +flow('d-show', 'd-place', 'd-paint'); + +// 7) Landing: each of the four cell objects writes its digit into the board text, read back from +// where it stands. +strand(); +at('l-loop', 'Repeat', 0, 0, { times: 4 }); +link('m-brover', 'else', 'l-loop', 'exec'); +const landed = objectOfRound('l-p', 'l-loop', 'p', 1, 4); +at('l-pos', 'GetPosition', 4, 4); +at('l-offx', 'Add', 5, 3, { b: -CELL_X(0) }); +at('l-offy', 'Add', 5, 6, { b: -CELL_Y(0) }); +at('l-col', 'Divide', 6, 3, { b: CELL }); +at('l-rowof', 'Divide', 6, 6, { b: CELL }); +at('l-colr', 'Round', 7, 3); +at('l-rowr', 'Round', 7, 6); +link(landed, 'object', 'l-pos', 'object'); +link('l-pos', 'x', 'l-offx', 'a'); +link('l-pos', 'y', 'l-offy', 'a'); +link('l-offx', 'result', 'l-col', 'a'); +link('l-offy', 'result', 'l-rowof', 'a'); +link('l-col', 'result', 'l-colr', 'value'); +link('l-rowof', 'result', 'l-rowr', 'value'); + +at('l-row10', 'Multiply', 8, 6, { b: COLS }); +at('l-index', 'Add', 9, 4); +at('l-next', 'Add', 10, 8, { b: 1 }); +get('l-grid', 'grid', 9, 10); +get('l-shape', 'shape', 9, 12); +at('l-digit', 'ToText', 10, 12, { decimals: 0 }); +at('l-before', 'Substring', 10, 6); +at('l-after', 'Substring', 11, 8, { count: -1 }); +at('l-joined', 'Join', 11, 10); +at('l-whole', 'Join', 12, 10); +set('l-setgrid', 'grid', 13, 0); +link('l-rowr', 'result', 'l-row10', 'a'); +link('l-row10', 'result', 'l-index', 'a'); +link('l-colr', 'result', 'l-index', 'b'); +link('l-index', 'result', 'l-next', 'a'); +link('l-grid', 'value', 'l-before', 'text'); +link('l-index', 'result', 'l-before', 'count'); +link('l-grid', 'value', 'l-after', 'text'); +link('l-next', 'result', 'l-after', 'start'); +link('l-shape', 'value', 'l-digit', 'value'); +link('l-before', 'text', 'l-joined', 'a'); +link('l-digit', 'text', 'l-joined', 'b'); +link('l-joined', 'text', 'l-whole', 'a'); +link('l-after', 'text', 'l-whole', 'b'); +link('l-whole', 'text', 'l-setgrid', 'value'); +link('l-loop', 'body', 'l-setgrid', 'exec'); + +// 8) Full lines: walk the board from the bottom up. A cleared row does not move the walk on, +// because everything above has dropped into it – so the same row is looked at again. +strand(); +set('r-row', 'row', 0, 0, ROWS - 1); +at('r-loop', 'Repeat', 1, 0, { times: ROWS + 4 }); +link('l-loop', 'done', 'r-row', 'exec'); +flow('r-row', 'r-loop'); + +get('r-rowvar', 'row', 2, 3); +at('r-left', 'Compare', 3, 3, { op: '>=', b: 0 }); +at('r-brleft', 'Branch', 3, 0); +at('r-start', 'Multiply', 4, 3, { b: COLS }); +get('r-grid', 'grid', 4, 5); +at('r-text', 'Substring', 5, 4, { count: COLS }); +at('r-gap', 'FindText', 6, 4, { search: EMPTY_CELL }); +at('r-full', 'Compare', 7, 4, { op: '<', b: 0 }); +at('r-brfull', 'Branch', 5, 0); +link('r-loop', 'body', 'r-brleft', 'exec'); +link('r-rowvar', 'value', 'r-left', 'a'); +link('r-left', 'result', 'r-brleft', 'condition'); +link('r-rowvar', 'value', 'r-start', 'a'); +link('r-grid', 'value', 'r-text', 'text'); +link('r-start', 'result', 'r-text', 'start'); +link('r-text', 'text', 'r-gap', 'text'); +link('r-gap', 'index', 'r-full', 'a'); +link('r-brleft', 'then', 'r-brfull', 'exec'); +link('r-full', 'result', 'r-brfull', 'condition'); + +at('r-above', 'Substring', 6, 7); +at('r-below', 'Substring', 6, 9, { count: -1 }); +at('r-past', 'Add', 5, 9, { b: COLS }); +at('r-top', 'Join', 7, 7, { a: EMPTY_ROW }); +at('r-rest', 'Join', 8, 8); +set('r-setgrid', 'grid', 7, 0); +get('r-lines', 'lines', 8, 3); +at('r-onemore', 'Add', 9, 3, { b: 1 }); +set('r-setlines', 'lines', 9, 0); +link('r-brfull', 'then', 'r-setgrid', 'exec'); +link('r-grid', 'value', 'r-above', 'text'); +link('r-start', 'result', 'r-above', 'count'); +link('r-start', 'result', 'r-past', 'a'); +link('r-grid', 'value', 'r-below', 'text'); +link('r-past', 'result', 'r-below', 'start'); +link('r-above', 'text', 'r-top', 'b'); +link('r-top', 'text', 'r-rest', 'a'); +link('r-below', 'text', 'r-rest', 'b'); +link('r-rest', 'text', 'r-setgrid', 'value'); +link('r-lines', 'value', 'r-onemore', 'a'); +link('r-onemore', 'result', 'r-setlines', 'value'); +flow('r-setgrid', 'r-setlines'); + +at('r-back', 'Subtract', 6, 12, { b: 1 }); +set('r-setrow', 'row', 7, 11); +link('r-brfull', 'else', 'r-setrow', 'exec'); +link('r-rowvar', 'value', 'r-back', 'a'); +link('r-back', 'result', 'r-setrow', 'value'); + +// 9) The board as it now stands, cell by cell: switched off where the text says empty, and in the +// colour of the shape that came to rest there otherwise. +strand(); +at('b-count', 'SetText', 0, 0); +at('b-label', 'ObjectByName', 0, 3, { name: 'LinesLabel' }); +get('b-lines', 'lines', 0, 5); +at('b-number', 'ToText', 1, 5, { decimals: 0 }); +at('b-caption', 'Join', 2, 5, { a: 'Lines ' }); +at('b-loop', 'Repeat', 1, 0, { times: COLS * ROWS }); +link('r-loop', 'done', 'b-count', 'exec'); +link('b-label', 'object', 'b-count', 'object'); +link('b-lines', 'value', 'b-number', 'value'); +link('b-number', 'text', 'b-caption', 'b'); +link('b-caption', 'text', 'b-count', 'text'); +flow('b-count', 'b-loop'); + +get('b-grid', 'grid', 2, 4); +at('b-cell', 'Substring', 3, 3, { count: 1 }); +at('b-empty', 'Equals', 4, 3, { b: EMPTY_CELL }); +at('b-filled', 'Not', 5, 3); +const boardObject = objectOfRound('b-c', 'b-loop', 'c', 2, 0); +at('b-show', 'SetEnabled', 6, 0); +at('b-br', 'Branch', 7, 0); +at('b-paint', 'SetProperty', 8, 0, { property: 'color' }); +link('b-loop', 'index', 'b-cell', 'start'); +link('b-grid', 'value', 'b-cell', 'text'); +link('b-cell', 'text', 'b-empty', 'a'); +link('b-empty', 'result', 'b-filled', 'value'); +link('b-loop', 'body', 'b-show', 'exec'); +link(boardObject, 'object', 'b-show', 'object'); +link('b-filled', 'result', 'b-show', 'enabled'); +link('b-filled', 'result', 'b-br', 'condition'); +link(boardObject, 'object', 'b-paint', 'object'); +flow('b-show', 'b-br'); +link('b-br', 'then', 'b-paint', 'exec'); + +at('b-digit', 'ToNumber', 8, 4); +get('b-colors', 'colors', 8, 8); +at('b-offset', 'Multiply', 9, 4, { b: 8 }); +at('b-hex', 'Substring', 9, 6, { count: 8 }); +at('b-color', 'ToNumber', 10, 6); +link('b-cell', 'text', 'b-digit', 'value'); +link('b-digit', 'result', 'b-offset', 'a'); +link('b-colors', 'value', 'b-hex', 'text'); +link('b-offset', 'result', 'b-hex', 'start'); +link('b-hex', 'text', 'b-color', 'value'); +link('b-color', 'result', 'b-paint', 'value'); + +// The board is drawn and the piece is part of it now: the next frame draws a new one. +set('b-nopiece', 'hasPiece', 12, 0, false); +link('b-loop', 'done', 'b-nopiece', 'exec'); + +/** A mistyped id would silently leave an input unconnected, so the seed refuses to write one. */ +function checkGraph() { + const ids = new Set(); + for (const n of nodes) { + if (ids.has(n.id)) throw new Error(`duplicate node id "${n.id}"`); + ids.add(n.id); + } + for (const e of edges) { + for (const end of [e.from.node, e.to.node]) { + if (!ids.has(end)) throw new Error(`edge ${e.id} points at unknown node "${end}"`); + } + } + return { nodes, edges }; +} + +const tetrisGraph = checkGraph(); + +// --- project ----------------------------------------------------------------------------------- + +const label = (id, name, x, y, text, size, color, align = 'center') => + sceneObject(id, 'label', { name, x, y, overrides: { text, fontSize: size, color, align } }); + +const project = { + formatVersion: 2, + id: projectId, + name: 'Tetris', + activeSceneId: 'scene1', + assets: [ + { + id: 'scene1', + type: 'scene', + name: 'Tetris', + props: { backgroundColor: BACKGROUND }, + objects: [ + sceneObject('oboard', 'board', { name: 'Board', x: 150 }), + label('otitle', 'Title', -110, -110, 'TETRIS', 44, WHITE), + label('olines', 'LinesLabel', -110, -40, 'Lines 0', 26, WHITE), + label('ohint1', 'Hint', -110, 60, 'Left / Right · move', 16, HINT), + label('ohint2', 'Hint 2', -110, 84, 'Up · turn', 16, HINT), + label('ohint3', 'Hint 3', -110, 108, 'Down · drop faster', 16, HINT), + sceneObject('ocam', 'cam', { name: 'Main Camera' }), + sceneObject('ogame', 'tetris', { name: 'Tetris', x: -260, y: 150 }), + ], + }, + { id: 'cam', type: 'camera', name: 'Main Camera', props: { width: 640, height: 360, zoom: 1 } }, + { + id: 'well-sprite', + type: 'sprite', + name: 'Well', + props: { width: COLS * CELL, height: ROWS * CELL, color: WELL }, + }, + { id: 'cell-sprite', type: 'sprite', name: 'Cell', props: { width: CELL - 1, height: CELL - 1 } }, + { + id: 'label', + type: 'text', + name: 'Label', + props: { text: 'Text', fontSize: 26, color: WHITE, align: 'center' }, + }, + { id: 'board', type: 'container', name: 'Board', props: {}, objects: boardTemplate }, + { + id: 'tetris', + type: 'behavior', + name: 'Tetris', + props: { + grid: EMPTY_GRID, + shapes: shapeTable(), + colors: colorTable(), + spawnX: spawnColumns(), + shape: 0, + rot: 0, + px: 3, + py: 0, + nx: 0, + ny: 0, + nrot: 0, + kind: '', + ok: true, + hasPiece: false, + fall: 0, + stepTime: 0.5, + lines: 0, + wantDx: 0, + wantRot: 0, + row: 0, + }, + variables: [ + variable('grid', 'text', EMPTY_GRID), + variable('shapes', 'text', shapeTable()), + variable('colors', 'text', colorTable()), + variable('spawnX', 'text', spawnColumns()), + variable('shape', 'number', 0), + variable('rot', 'number', 0), + variable('px', 'number', 3), + variable('py', 'number', 0), + variable('nx', 'number', 0), + variable('ny', 'number', 0), + variable('nrot', 'number', 0), + variable('kind', 'text', ''), + variable('ok', 'boolean', true), + variable('hasPiece', 'boolean', false), + variable('fall', 'number', 0), + variable('stepTime', 'number', 0.5), + variable('lines', 'number', 0), + variable('wantDx', 'number', 0), + variable('wantRot', 'number', 0), + variable('row', 'number', 0), + ], + graph: tetrisGraph, + }, + ], +}; + +await writeProject(projectId, baseUrl, project); diff --git a/tools/smoke-tetris.mjs b/tools/smoke-tetris.mjs new file mode 100644 index 00000000..284ee165 --- /dev/null +++ b/tools/smoke-tetris.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node +/** + * Plays the Tetris example (tools/seed-tetris.mjs) in a headless browser: a piece appears and + * falls, the arrow keys move and turn it, holding down drops it, a landing writes itself into the + * board, a full line disappears and counts, and a full well ends the run. + * + * 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 + * Screenshots land in /tmp/gm-tetris-*.png. + */ +import { instanceChild } from './lib/builders.mjs'; +import { runChecks } from './lib/harness.mjs'; +import { PROJECTS, cliArgs } from './lib/projects.mjs'; + +const { projectId, baseUrl } = cliArgs('smoke-tetris.mjs', PROJECTS.tetris); + +const COLS = 10; +const ROWS = 20; +const CELL = 16; + +/** The board container, the five texts, the camera and the behavior. */ +const STORED_COUNT = 8; +/** Those eight, the board instance keeping its place, plus the well, 200 cells and 4 piece cells. */ +const OBJECT_COUNT = STORED_COUNT + 1 + COLS * ROWS + 4; + +const GRAPH = { nodes: 219, edges: 248 }; + +const PIECE = (i) => instanceChild('oboard', `piece${i}`); + +const EMPTY_ROW = '.'.repeat(COLS); +const FULL_ROW = '0'.repeat(COLS); + +await runChecks({ projectId, baseUrl, port: 9337, shotPrefix: 'gm-tetris' }, async (t) => { + const { + check, + sleep, + evaluate, + json, + shot, + press, + holdKey, + entity, + world, + mutate, + play, + navigate, + exceptions, + } = t; + + /** The variables of the Tetris behavior, live from the running world. */ + const vars = async () => (await entity('ogame')).props; + const filledCells = (grid) => grid.split('').filter((c) => c !== '.').length; + const pieceCells = async () => { + const w = await world(); + return [0, 1, 2, 3].map((i) => w.entities.find((e) => e.objectId === PIECE(i))); + }; + /** Where the four cells of the falling piece stand, as board columns and rows. */ + const piecePosition = async () => { + const cells = await pieceCells(); + return cells.map((c) => ({ + column: Math.round((c.x + (COLS * CELL) / 2 - CELL / 2) / CELL), + row: Math.round((c.y + (ROWS * CELL) / 2 - CELL / 2) / CELL), + })); + }; + const litCells = async () => + (await world()).entities.filter((e) => /^c\d+$/.test(e.name) && e.props.enabled !== false).length; + /** Waits for a condition instead of for the clock: a fixed sleep makes the check a coin toss. */ + const waitFor = async (condition, ms = 6000) => { + for (let waited = 0; waited < ms; waited += 200) { + if (await condition()) return true; + await sleep(200); + } + return false; + }; + /** Writes a behavior variable of the play copy and pushes it into the running world. */ + const setVariable = (key, value) => + mutate( + `__gm.state.activeScene.objects.find((x) => x.id === 'ogame').overrides[${JSON.stringify(key)}] = ${JSON.stringify(value)}`, + ); + + await navigate(); + + // 1. The document survived the loader: 219 nodes and 248 edges is the whole game. + const graph = await json( + `(() => { const a = __gm.state.project.assets.find((x) => x.id === 'tetris'); return { nodes: a.graph.nodes.length, edges: a.graph.edges.length }; })()`, + ); + check( + 'the whole graph loads – no unknown node type, no dropped edge', + graph.nodes === GRAPH.nodes && graph.edges === GRAPH.edges, + JSON.stringify(graph), + ); + const stored = await evaluate('__gm.state.activeScene.objects.length'); + const listed = await evaluate('__gm.sceneObjects().length'); + check( + 'the scene stores eight objects and resolves the board out of one of them', + stored === STORED_COUNT && listed === OBJECT_COUNT, + JSON.stringify({ stored, listed }), + ); + await shot('01-editor'); + + // 2. Play: a piece appears, all four of its cells switched on and inside the well. + await play(); + await sleep(400); + const spawned = await vars(); + const cells = await pieceCells(); + 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 }), + ); + const start = await piecePosition(); + check( + 'its four cells stand inside the board', + start.every((c) => c.column >= 0 && c.column < COLS && c.row >= 0 && c.row < ROWS), + JSON.stringify(start), + ); + await shot('02-playing'); + + // 3. It falls by itself. + await sleep(1300); + const fallen = await piecePosition(); + check('the piece falls on its own', fallen[0].row > start[0].row, `row ${start[0].row} → ${fallen[0].row}`); + + // 4. Left and right move it by exactly one column per press, up turns it. + const beforeMove = await piecePosition(); + await press('ArrowLeft'); + await sleep(150); + const moved = await piecePosition(); + check( + 'the left arrow moves the piece one column', + moved.every((c, i) => c.column === beforeMove[i].column - 1), + `${beforeMove.map((c) => c.column).join(',')} → ${moved.map((c) => c.column).join(',')}`, + ); + await press('ArrowRight'); + await sleep(150); + const back = await piecePosition(); + check( + 'the right arrow moves it back', + back.every((c, i) => c.column === beforeMove[i].column), + back.map((c) => c.column).join(','), + ); + const beforeTurn = (await vars()).rot; + await press('ArrowUp'); + await sleep(150); + const afterTurn = (await vars()).rot; + check( + 'the up arrow turns the piece', + afterTurn === (beforeTurn + 1) % 4, + `rot ${beforeTurn} → ${afterTurn}`, + ); + await shot('03-moved'); + + // 5. Holding down drops it, and where it lands is written into the board text. + await setVariable('grid', '.'.repeat(COLS * ROWS)); + await holdKey('ArrowDown', 1600); + await sleep(300); + const landedVars = await vars(); + check( + 'holding the down arrow drops the piece, and the landing is written into the board', + filledCells(landedVars.grid) >= 4, + `${filledCells(landedVars.grid)} cells taken`, + ); + check( + 'the board draws exactly the cells the board text names', + (await litCells()) === filledCells((await vars()).grid), + JSON.stringify({ lit: await litCells(), inText: filledCells((await vars()).grid) }), + ); + await shot('04-landed'); + + // 6. A full line disappears and counts. The bottom row is filled in beforehand, so whatever the + // piece is and wherever it comes down, landing on top of it completes that line. + await setVariable('lines', 0); + await setVariable('grid', EMPTY_ROW.repeat(ROWS - 1) + FULL_ROW); + await holdKey('ArrowDown', 2200); + await sleep(400); + const cleared = await vars(); + check( + 'a full line disappears and is counted', + cleared.lines === 1, + `lines ${cleared.lines}, bottom row "${cleared.grid.slice((ROWS - 1) * COLS)}"`, + ); + check( + 'the counter on screen says so too', + Boolean(await evaluate(`Boolean(__gm.locate('Lines 1'))`)), + 'Lines 1', + ); + const bottom = cleared.grid.slice((ROWS - 1) * COLS); + check( + 'the row that was full is gone, and what stood on it dropped into its place', + bottom.includes('.') && bottom !== EMPTY_ROW, + `bottom row "${bottom}", ${filledCells(cleared.grid)} cells taken in all`, + ); + await shot('05-line-cleared'); + + // 7. A well filled to the top ends the run: the new piece cannot come down even once. + await setVariable('grid', EMPTY_ROW + FULL_ROW.repeat(ROWS - 1)); + await setVariable('hasPiece', false); + await waitFor(async () => (await evaluate('__gm.state.mode')) === 'edit'); + const mode = await evaluate('__gm.state.mode'); + const over = mode === 'play' ? await vars() : null; + check( + 'a piece that cannot fall at the very top stops the game', + mode === 'edit', + over + ? JSON.stringify({ + mode, + py: over.py, + hasPiece: over.hasPiece, + kind: over.kind, + ok: over.ok, + lines: over.lines, + top: over.grid.slice(0, 30), + }) + : 'stopped', + ); + await shot('06-game-over'); + + check('no exceptions during the run', exceptions().length === 0, exceptions().slice(0, 3).join(' | ')); +});