blockyard 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +929 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +191 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +41 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1577 -0
  9. package/docs/ARCHITECTURE.md +1394 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +847 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +205 -0
  15. package/docs/INSTALL.md +547 -0
  16. package/docs/MEASUREMENTS.md +1401 -0
  17. package/docs/RULES.md +681 -0
  18. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  19. package/docs/SECURITY-AUDIT.md +258 -0
  20. package/docs/SECURITY.md +212 -0
  21. package/docs/TROUBLESHOOTING.md +332 -0
  22. package/docs/USER-GUIDE.md +1262 -0
  23. package/package.json +53 -5
  24. package/public/404.html +9 -0
  25. package/public/css/app.css +2009 -0
  26. package/public/donate-qr.png +0 -0
  27. package/public/index.html +1085 -0
  28. package/public/js/about.js +112 -0
  29. package/public/js/agents.js +1141 -0
  30. package/public/js/app.js +1386 -0
  31. package/public/js/arkanoid.js +806 -0
  32. package/public/js/blockanoid.js +347 -0
  33. package/public/js/blockout.js +347 -0
  34. package/public/js/blockpack.js +428 -0
  35. package/public/js/blockscene3d.js +2830 -0
  36. package/public/js/breakout.js +224 -0
  37. package/public/js/charts.js +635 -0
  38. package/public/js/depthchart.js +315 -0
  39. package/public/js/details3d.js +4342 -0
  40. package/public/js/doom.js +31 -0
  41. package/public/js/dosaudio.js +48 -0
  42. package/public/js/dosgame.js +389 -0
  43. package/public/js/dosio.js +186 -0
  44. package/public/js/dospc.js +1353 -0
  45. package/public/js/dosworker.js +196 -0
  46. package/public/js/explorer.js +405 -0
  47. package/public/js/feepalette.js +149 -0
  48. package/public/js/fmt.js +162 -0
  49. package/public/js/goggles.js +886 -0
  50. package/public/js/kiosk.js +41 -0
  51. package/public/js/login.js +88 -0
  52. package/public/js/markets.js +395 -0
  53. package/public/js/mining.js +1416 -0
  54. package/public/js/panels.js +970 -0
  55. package/public/js/pricechart.js +189 -0
  56. package/public/js/quake.js +20 -0
  57. package/public/js/settings.js +1096 -0
  58. package/public/js/soundcard.js +459 -0
  59. package/public/js/tetris.js +226 -0
  60. package/public/js/tetrust.js +356 -0
  61. package/public/js/tetsound.js +175 -0
  62. package/public/js/theme.js +235 -0
  63. package/public/js/wolf3d.js +22 -0
  64. package/public/js/x86.js +1978 -0
  65. package/public/login.html +33 -0
  66. package/scripts/blockfile-measure.js +156 -0
  67. package/scripts/browser-check.mjs +286 -0
  68. package/scripts/check.js +173 -0
  69. package/scripts/decode-check.js +81 -0
  70. package/scripts/doc-counts.js +109 -0
  71. package/scripts/donate-qr.py +23 -0
  72. package/scripts/dos-bench.js +56 -0
  73. package/scripts/fake-node.js +534 -0
  74. package/scripts/index-bench.js +216 -0
  75. package/scripts/index-benchmark.js +117 -0
  76. package/scripts/index-build.js +40 -0
  77. package/scripts/live-render-check.mjs +89 -0
  78. package/scripts/manage-users.js +132 -0
  79. package/scripts/motion-check.mjs +138 -0
  80. package/scripts/pool-map.js +157 -0
  81. package/scripts/setup.js +432 -0
  82. package/scripts/shots.mjs +278 -0
  83. package/scripts/smoke.sh +327 -0
  84. package/scripts/tls.js +31 -0
  85. package/scripts/ui.js +174 -0
  86. package/server/auth/sessions.js +221 -0
  87. package/server/auth/users.js +243 -0
  88. package/server/chain/blockfile.js +234 -0
  89. package/server/chain/index/build.js +210 -0
  90. package/server/chain/index/heights.js +36 -0
  91. package/server/chain/index/live.js +276 -0
  92. package/server/chain/index/rows.js +145 -0
  93. package/server/chain/index/store.js +154 -0
  94. package/server/chain/index/worker.js +109 -0
  95. package/server/chain/tx.js +310 -0
  96. package/server/collect/gbt.js +229 -0
  97. package/server/collect/logparse.js +765 -0
  98. package/server/collect/logtail.js +189 -0
  99. package/server/collect/markets.js +333 -0
  100. package/server/collect/mining.js +333 -0
  101. package/server/collect/monitor.js +2545 -0
  102. package/server/collect/network.js +295 -0
  103. package/server/collect/nextblock.js +275 -0
  104. package/server/collect/sync.js +386 -0
  105. package/server/config.js +644 -0
  106. package/server/http/api.js +1319 -0
  107. package/server/http/explorer.js +418 -0
  108. package/server/http/games.js +77 -0
  109. package/server/http/server.js +420 -0
  110. package/server/http/sse.js +176 -0
  111. package/server/http/static.js +212 -0
  112. package/server/main.js +673 -0
  113. package/server/netinfo.js +253 -0
  114. package/server/rpc/allowlist.js +130 -0
  115. package/server/rpc/client.js +414 -0
  116. package/server/store/audit.js +148 -0
  117. package/server/store/history.js +220 -0
  118. package/server/store/ledger.js +290 -0
  119. package/server/store/ring.js +173 -0
  120. package/server/tls/selfsigned.js +160 -0
  121. package/server/util/fmt.js +29 -0
  122. package/systemd/blockyard.service +102 -0
@@ -0,0 +1,806 @@
1
+ // BLOCKANOID, the rules (operator, 2026-09-12: "Take blockout, and make rip off of Arkanoid using
2
+ // our engine, and make it a new Diversion called 'Blockanoid'").
3
+ //
4
+ // Like breakout.js and tetris.js, this file is the RULES and nothing else: no DOM, no canvas, no
5
+ // clock, and NO Math.random. Arkanoid lives or dies on its capsules, and a capsule that fell at
6
+ // random could not be tested at all -- so which brick carries one is a pure hash of that brick's
7
+ // id and the level. The same wall always drops the same capsules, and a test can say which.
8
+ //
9
+ // Coordinates follow the engine's board: x runs left to right, y runs UP from row 0 on the floor.
10
+ // Vaus sits near the floor, the wall is at the top, so the ball travels toward +y to break
11
+ // something -- the renderer's language, as in breakout.js.
12
+ //
13
+ // What this takes from Arkanoid that Breakout has not got:
14
+ // - a different WALL every level, not the same six rows again
15
+ // - SILVER bricks that take two hits (and more as the levels climb) and GOLD that never breaks
16
+ // - CAPSULES that fall out of broken bricks: laser, enlarge, catch, slow, disrupt, player, break
17
+ // - three balls at once, a bat that shoots, and a bat that catches
18
+ // - the DOH minions drifting down the court to spoil your aim
19
+
20
+ export const COLS = 13;
21
+ export const ROWS = 26;
22
+
23
+ export const PADDLE_Y = 1;
24
+ export const PADDLE_D = 1; // the bat's DEPTH across the board (see breakout.js: not its height)
25
+ export const PADDLE_H = 0.55; // how tall the bat's stones STAND
26
+ export const PADDLE_W = 3; // Vaus is narrower than a Breakout bat
27
+ export const PADDLE_W_WIDE = 5; // ... until the E capsule
28
+ export const BALL_S = 0.8;
29
+ export const BALL_R = BALL_S / 2;
30
+ export const LIVES = 3;
31
+
32
+ export const BRICK_TOP = ROWS - 4;
33
+
34
+ // ------------------------------------------------------------------ the bricks
35
+ // A colour is worth what it is worth, as in the arcade: the dearer the colour the higher it sits.
36
+ export const BRICK = Object.freeze({
37
+ w: Object.freeze({ color: '#e8eefc', points: 50 }),
38
+ o: Object.freeze({ color: '#f7931a', points: 60 }),
39
+ c: Object.freeze({ color: '#3ec9ff', points: 70 }),
40
+ g: Object.freeze({ color: '#2ecc8f', points: 80 }),
41
+ r: Object.freeze({ color: '#ef5a5a', points: 90 }),
42
+ b: Object.freeze({ color: '#4d7cff', points: 100 }),
43
+ p: Object.freeze({ color: '#b07cff', points: 110 }),
44
+ y: Object.freeze({ color: '#f5d142', points: 120 }),
45
+ });
46
+ export const SILVER = Object.freeze({ color: '#b9c4d4', points: 50 });
47
+ export const GOLD = Object.freeze({ color: '#d9a441', points: 0 });
48
+
49
+ // Six walls. '.' is empty, a letter is that colour, S is silver, G is gold. Written TOP ROW FIRST,
50
+ // the way you look at it; layout() flips them onto the engine's y-up board.
51
+ export const LEVELS = Object.freeze([
52
+ Object.freeze(['wwwwwwwwwwwww', 'ooooooooooooo', 'ccccccccccccc', 'ggggggggggggg', 'rrrrrrrrrrrrr', 'bbbbbbbbbbbbb']),
53
+ Object.freeze(['.SSSSSSSSSSS.', '.G.........G.', '.rrrrrrrrrrr.', '.ooooooooooo.', '.yyyyyyyyyyy.', '.ccccccccccc.']),
54
+ Object.freeze(['r.r.r.r.r.r.r', '.o.o.o.o.o.o.', 'y.y.y.y.y.y.y', '.g.g.g.g.g.g.', 'S.S.S.S.S.S.S', '.b.b.b.b.b.b.']),
55
+ Object.freeze(['......G......', '.....yyy.....', '....ooooo....', '...rrrrrrr...', '..bbbbbbbbb..', '.SSSSSSSSSSS.']),
56
+ Object.freeze(['GGGGGGGGGGGGG', 'G...........G', 'G.SSSSSSSSS.G', 'G.S.ppppp.S.G', 'G.S.pyyyp.S.G', 'G.SSSSSSSSS.G']),
57
+ Object.freeze(['S.G.S.G.S.G.S', '.wwwwwwwwwww.', '.w.c.c.c.c.w.', '.w.ccccccc.w.', '.wwwwwwwwwww.', 'S.G.S.G.S.G.S']),
58
+ ]);
59
+
60
+ // A BRICK THAT SURVIVES A HIT SHOWS IT (operator, 2026-09-12: "The blocks that take more than 1
61
+ // hit to break, need to start darker in color. Every successive hit ... makes it lighter colored,
62
+ // signalling it's closer to breaking. When it's the lightest color it can be, it will signal the
63
+ // next impact will break that block").
64
+ //
65
+ // The ramp is anchored at both ends so the LAST step is unmistakable: fresh is DAMAGE_DARK, and
66
+ // one-hit-from-breaking is DAMAGE_LIGHT, whatever the durability happens to be. A brick with three
67
+ // lives and a brick with five both end on the same bright colour, so "next one does it" is a
68
+ // colour you learn once rather than a shade you have to compare against its neighbours.
69
+ export const DAMAGE_DARK = '#4a5361';
70
+ export const DAMAGE_LIGHT = '#f2f7ff';
71
+
72
+ const hex2 = (h, i) => parseInt(h.slice(i, i + 2), 16);
73
+ /** Mix two six-digit hexes, t = 0 gives `a`, t = 1 gives `b`. Pure, so the ramp is assertable. */
74
+ export function mixHex(a, b, t) {
75
+ const k = Math.max(0, Math.min(1, t));
76
+ const ch = (i) => Math.round(hex2(a.replace('#', ''), i) + (hex2(b.replace('#', ''), i) - hex2(a.replace('#', ''), i)) * k);
77
+ return `#${[ch(0), ch(2), ch(4)].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
78
+ }
79
+
80
+ /**
81
+ * The colour of a brick with `left` hits remaining out of `max`. Dark when fresh, lightest when one
82
+ * hit remains. A single-hit brick keeps its own colour -- there is no wear to show.
83
+ */
84
+ export function damageShade(left, max, base) {
85
+ if (!Number.isFinite(max) || max <= 1) return base;
86
+ const p = (max - Math.max(1, left)) / (max - 1); // 0 fresh .. 1 about to break
87
+ return mixHex(DAMAGE_DARK, DAMAGE_LIGHT, p);
88
+ }
89
+
90
+ /** How many hits a silver brick takes at this level: two, and one more every four levels. */
91
+ export function silverHits(level) { return 2 + Math.floor(Math.max(0, level - 1) / 4); }
92
+
93
+ // ------------------------------------------------------------------ the capsules
94
+ // One capsule at a time is the arcade's rule and it matters: it is what makes "do I take it?" a
95
+ // decision rather than a reflex.
96
+ // `break` (skip the wall) was removed on the operator's word, 2026-09-12. It was the one capsule
97
+ // that took the game away from you: clearing a wall you had not cleared is a reward for catching a
98
+ // pill rather than for playing, and it made the level you were in the middle of pointless.
99
+ export const CAPSULES = Object.freeze(['laser', 'enlarge', 'catch', 'slow', 'disrupt', 'player']);
100
+ export const CAPSULE_COLOR = Object.freeze({
101
+ laser: '#ef5a5a', enlarge: '#4d7cff', catch: '#2ecc8f', slow: '#f7931a',
102
+ disrupt: '#3ec9ff', player: '#b07cff',
103
+ });
104
+ // EACH CAPSULE HAS ITS OWN SILHOUETTE, not just its own colour (operator, 2026-09-12: "Blockanoid
105
+ // needs unique block graphics and styling for each of the different powerups that drop down").
106
+ // Colour alone is a poor signal here: the capsule is small, it is moving, and roughly one player in
107
+ // twelve cannot separate the red from the green at all. So SHAPE carries the meaning and colour
108
+ // only reinforces it -- the seven differ in part count, height and profile, and stay readable in
109
+ // monochrome. `trim` is the glyph sitting on the body.
110
+ export const CAPSULE_TRIM = Object.freeze({
111
+ laser: '#ffd0c4', enlarge: '#cfe0ff', catch: '#d8ffe9', slow: '#ffe0b0',
112
+ disrupt: '#d6f4ff', player: '#ecdcff',
113
+ });
114
+ export const CAPSULE_LETTER = Object.freeze({
115
+ laser: 'L', enlarge: 'E', catch: 'C', slow: 'S', disrupt: 'D', player: 'P',
116
+ });
117
+ export const CAPSULE_POINTS = 1000;
118
+ const CAPSULE_FALL = 5; // grid units a second
119
+ // DRAWN SIZE AND HIT BOX ARE TWO DIFFERENT MEASUREMENTS (operator, 2026-09-12: "the blockanoid
120
+ // minions and pills need to be AT LEAST 4 times larger ... shrink the bounding box if necessary to
121
+ // compensate for visual"). They used to be one number, which is why they could not both be right: a
122
+ // capsule big enough to read across a room is a capsule you cannot miss, and a minion that size
123
+ // could never thread a gap in the wall. So the sprite is large and the box it collides with is not.
124
+ export const CAPSULE_S = 2.16; // DRAWN: 2.4x the old 0.9 (4x was a bit large -- operator)
125
+ export const CAPSULE_BOX = 1.9; // CAUGHT: generous, but you can still miss it
126
+ const CAPSULE_ODDS = 0.28; // of a broken brick
127
+
128
+ const BASE_SPEED = 12;
129
+ const LEVEL_SPEEDUP = 1.06;
130
+ const SLOW_FACTOR = 0.68;
131
+ const MAX_BOUNCE = 1.05;
132
+ const MAX_STEP = 0.3;
133
+ const LASER_SPEED = 26;
134
+ const LASER_COOLDOWN = 260; // ms between shots
135
+ // CATCH RUNS OUT (operator, 2026-09-12: "The catch shouldnt last forever. Just 30 seconds max").
136
+ // Held for ever it stops being a power-up and becomes a different game -- you can park the ball and
137
+ // aim every single shot, which removes the rally. Thirty seconds is long enough to set up the shots
138
+ // you caught it for. Counted in ms off `step`'s own dtMs, like the laser cooldown and the minion
139
+ // timer: the rules still have no clock of their own, so a run stays reproducible.
140
+ // NOTHING LASTS FOR EVER (operator, 2026-09-12: "All the powerups need 30 second timers max for
141
+ // each. they are not infinite"). Four of the six are STATES and so can expire; disrupt and the
142
+ // extra life are one-shot and have nothing to run down. One table and one expiry loop, because
143
+ // four hand-written countdowns are four chances to drift apart -- the same reason loseLife() exists.
144
+ const POWER_MS = 30_000;
145
+ export const TIMED_POWERS = Object.freeze(['laser', 'catch', 'slow', 'wide']);
146
+ // Slower than before: a minion is now looking for a way through rather than falling past you.
147
+ const ENEMY_SPEED = 1.25; // descent, grid units a second
148
+ const ENEMY_SIDE = 2.4; // sideways, while feeling along the wall for a gap
149
+ const ENEMY_EVERY = 9000; // ms between arrivals while a level runs
150
+ const ENEMY_POINTS = 200;
151
+ export const ENEMY_S = 2.06; // DRAWN: 2.4x the old 0.86 (4x was a bit large -- operator, 2026-09-12)
152
+ // The hit box must fit the gaps it has to navigate. Measured on the shipped walls: levels 3 and 6
153
+ // leave one-unit gaps, so anything wider than a unit could never pass and would sit at the top
154
+ // for ever. This is also the box the ball and the laser must hit, and the box that costs a life.
155
+ export const ENEMY_BOX = 0.92;
156
+
157
+ // ---------------------------------------------------------------- the minions
158
+ // Arkanoid's minions were solids that tumbled down the court -- a cone, a cube, a sphere, a
159
+ // molecule -- so these are shapes first and behaviours second. Each is an outline in UNIT SPACE
160
+ // (-0.5..0.5, scaled by the tile), a colour, a pair of eye positions, and a DRIFT: how it moves
161
+ // sideways as it falls. They differ in silhouette as well as colour, for the same reason the
162
+ // capsules do -- a small moving object is read by its shape.
163
+ export const MINIONS = Object.freeze({
164
+ // a squat cone, swinging widely: the one that gets in the way
165
+ cone: Object.freeze({
166
+ color: '#7de3c8', spin: 1.7, drift: 'swing',
167
+ poly: Object.freeze([[0, -0.46], [0.40, 0.34], [0.22, 0.46], [-0.22, 0.46], [-0.40, 0.34]]),
168
+ eyes: Object.freeze([[-0.13, 0.02, 0.075], [0.13, 0.02, 0.075]]),
169
+ }),
170
+ // a tumbling cube, turning fast and falling nearly straight
171
+ cube: Object.freeze({
172
+ color: '#ffb066', spin: 3.1, drift: 'straight',
173
+ poly: Object.freeze([[-0.40, -0.40], [0.40, -0.40], [0.40, 0.40], [-0.40, 0.40]]),
174
+ eyes: Object.freeze([[-0.14, -0.04, 0.07], [0.14, -0.04, 0.07]]),
175
+ }),
176
+ // a many-sided ball that wobbles as it sinks
177
+ orb: Object.freeze({
178
+ color: '#c79bff', spin: 0.9, drift: 'wobble',
179
+ poly: Object.freeze([[-0.20, -0.44], [0.20, -0.44], [0.44, -0.20], [0.44, 0.20],
180
+ [0.20, 0.44], [-0.20, 0.44], [-0.44, 0.20], [-0.44, -0.20]]),
181
+ eyes: Object.freeze([[-0.15, -0.05, 0.08], [0.15, -0.05, 0.08]]),
182
+ }),
183
+ // a four-armed molecule, drifting to one side and back
184
+ molecule: Object.freeze({
185
+ color: '#ff8fa3', spin: 2.2, drift: 'zigzag',
186
+ poly: Object.freeze([[0, -0.48], [0.17, -0.17], [0.48, 0], [0.17, 0.17],
187
+ [0, 0.48], [-0.17, 0.17], [-0.48, 0], [-0.17, -0.17]]),
188
+ eyes: Object.freeze([[-0.11, 0, 0.065], [0.11, 0, 0.065]]),
189
+ }),
190
+ });
191
+ export const MINION_KINDS = Object.freeze(Object.keys(MINIONS));
192
+
193
+ /** How far sideways a minion of this kind has drifted, in grid units a second. Pure. */
194
+ export function minionDrift(kind, phase, t) {
195
+ switch (MINIONS[kind]?.drift) {
196
+ case 'swing': return Math.sin(phase + t * 2.2) * 3.0;
197
+ case 'zigzag': return (((phase + t * 0.9) % 1) < 0.5 ? 1 : -1) * 1.8;
198
+ case 'wobble': return Math.sin(phase + t * 5.0) * 1.1;
199
+ default: return Math.sin(phase + t * 0.7) * 0.35;
200
+ }
201
+ }
202
+
203
+ export const PADDLE_COLOR = '#3ec9ff';
204
+ export const PADDLE_LASER_COLOR = '#ef5a5a';
205
+ export const BALL_COLOR = '#f2f7ff';
206
+ // A LASER EMITS; IT DOES NOT REFLECT (operator, 2026-09-12: "The lasers need to be much brighter.
207
+ // They are too dark to be visible"). A bolt used to be an ordinary cube, so the engine lit it like
208
+ // masonry: measured, its darkest side came out at luminance 70 and its unlit walls at 84, from a
209
+ // declared colour of 167. And a bolt is a sliver -- under the oblique camera it is almost entirely
210
+ // SIDE face, which is the darkest surface a cube has. It was being shaded into the background.
211
+ //
212
+ // Drawn the way the neon finish draws a lit tube instead: a wide translucent halo and a hot thin
213
+ // core, both `always: true` so they survive the edges switch, with the colour lifted toward white
214
+ // rather than shaded away from it.
215
+ export const BOLT_COLOR = '#fff0a8'; // the beam: hot, near-white yellow
216
+ export const BOLT_GLOW = '#ff5a3c'; // the halo around it
217
+
218
+ /**
219
+ * A stable 0..1 from a string. The same brick on the same level always yields the same number, so
220
+ * "which bricks carry capsules" is a property of the wall rather than of the day you played it.
221
+ * (The mix is the same shape as blockscene3d's fxHash: cheap, and well spread for short keys.)
222
+ */
223
+ export function hash01(key) {
224
+ let h = 2166136261;
225
+ const s = String(key);
226
+ for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
227
+ h ^= h >>> 13; h = Math.imul(h, 1274126177); h ^= h >>> 16;
228
+ return (h >>> 0) / 4294967296;
229
+ }
230
+
231
+ /** The capsule this brick carries, or null. Pure: the wall decides, not the moment. */
232
+ export function capsuleFor(brick, level) {
233
+ const roll = hash01(`cap:${level}:${brick.id}:${brick.x},${brick.y}`);
234
+ if (roll >= CAPSULE_ODDS) return null;
235
+ const pick = hash01(`kind:${level}:${brick.id}`);
236
+ return CAPSULES[Math.min(CAPSULES.length - 1, Math.floor(pick * CAPSULES.length))];
237
+ }
238
+
239
+ export function speed(level) { return BASE_SPEED * Math.pow(LEVEL_SPEEDUP, Math.max(0, level - 1)); }
240
+
241
+ /** The wall for a level; levels past the last one cycle round, harder silver each time. */
242
+ export function layoutFor(level) { return LEVELS[(Math.max(1, level) - 1) % LEVELS.length]; }
243
+
244
+ function layout(g) {
245
+ g.bricks = [];
246
+ const rows = layoutFor(g.level);
247
+ const hits = silverHits(g.level);
248
+ for (let r = 0; r < rows.length; r++) {
249
+ const line = rows[r];
250
+ const y = BRICK_TOP - r; // row 0 of the layout is the TOP row on screen
251
+ for (let x = 0; x < Math.min(COLS, line.length); x++) {
252
+ const ch = line[x];
253
+ if (ch === '.') continue;
254
+ if (ch === 'G') g.bricks.push({ id: g.nextId++, x, y, kind: 'gold', color: GOLD.color, points: 0, hits: Infinity, maxHits: Infinity });
255
+ else if (ch === 'S') g.bricks.push({ id: g.nextId++, x, y, kind: 'silver', color: SILVER.color, points: SILVER.points * g.level, hits, maxHits: hits });
256
+ else {
257
+ const spec = BRICK[ch];
258
+ if (!spec) continue;
259
+ g.bricks.push({ id: g.nextId++, x, y, kind: 'plain', color: spec.color, points: spec.points, hits: 1, maxHits: 1 });
260
+ }
261
+ }
262
+ }
263
+ g.breakableAtStart = breakable(g).length;
264
+ }
265
+
266
+ /** The bricks that can actually be destroyed -- gold is scenery, and never blocks a level. */
267
+ export function breakable(g) { return g.bricks.filter((b) => b.kind !== 'gold'); }
268
+
269
+ export function resetBall(g) {
270
+ // `hold` is the grip: how far along the bat the ball sits. A stuck ball is placed FROM it.
271
+ g.balls = [{ x: g.paddle.x + g.paddle.w / 2, y: PADDLE_Y + PADDLE_D + BALL_R, vx: 0, vy: 0, r: BALL_R, stuck: true, hold: g.paddle.w / 2 }];
272
+ return g.balls[0];
273
+ }
274
+
275
+ /** Vaus back to stock: normal width, no laser, no catch, no slow. */
276
+ export function resetVaus(g) {
277
+ g.paddle.w = PADDLE_W;
278
+ g.laser = false;
279
+ g.catch = false;
280
+ g.slow = false;
281
+ g.cooldown = 0;
282
+ // a stale countdown must not survive into the next ball
283
+ g.timers = { laser: 0, catch: 0, slow: 0, wide: 0 };
284
+ g.paddle.x = Math.max(0, Math.min(COLS - g.paddle.w, g.paddle.x));
285
+ }
286
+
287
+ /**
288
+ * The two switches that change the RULES rather than the look, so they live on the game and not in
289
+ * the renderer's options. A setting nothing reads is a dead control; these are read in capsuleFor's
290
+ * caller and in the minions' arrival, which is the only place they can bite.
291
+ */
292
+ export function setOptions(g, opts = {}) {
293
+ g.opts = { capsules: opts.capsules !== false, enemies: opts.enemies !== false };
294
+ if (!g.opts.capsules) g.capsules = [];
295
+ if (!g.opts.enemies) g.enemies = [];
296
+ return g.opts;
297
+ }
298
+
299
+ export function newGame(level = 1, opts = {}) {
300
+ const g = {
301
+ cols: COLS, rows: ROWS,
302
+ level, score: 0, lives: LIVES, over: false, cleared: false,
303
+ paddle: { x: (COLS - PADDLE_W) / 2, w: PADDLE_W },
304
+ balls: [], bricks: [], capsules: [], bolts: [], enemies: [],
305
+ laser: false, catch: false, slow: false, cooldown: 0,
306
+ timers: { laser: 0, catch: 0, slow: 0, wide: 0 },
307
+ sinceEnemy: 0, nextId: 1, breakableAtStart: 0,
308
+ opts: { capsules: true, enemies: true },
309
+ };
310
+ setOptions(g, opts);
311
+ layout(g);
312
+ resetBall(g);
313
+ return g;
314
+ }
315
+
316
+ /** The next wall: a new layout, a faster ball, Vaus back to stock, the score and lives carried. */
317
+ export function advance(g) {
318
+ g.level++;
319
+ g.cleared = false;
320
+ g.capsules = []; g.bolts = []; g.enemies = []; g.sinceEnemy = 0;
321
+ layout(g);
322
+ resetVaus(g);
323
+ resetBall(g);
324
+ return g;
325
+ }
326
+
327
+ export function movePaddle(g, centreX) {
328
+ if (g.over) return g.paddle.x;
329
+ const half = g.paddle.w / 2;
330
+ g.paddle.x = Math.max(0, Math.min(COLS - g.paddle.w, centreX - half));
331
+ // LOCKED TO THE BAT, not merely kept inside it (operator, 2026-09-12: "the ball needs to stick to
332
+ // the paddle location when it lands ... It should not shift around while the paddle moves beneath
333
+ // it"). This used to CLAMP a stuck ball into the bat's span, so the ball held its absolute place
334
+ // while the bat slid underneath and only moved when an edge caught up with it -- which is exactly
335
+ // the drift that was reported. It is placed from its grip instead.
336
+ for (const b of g.balls) {
337
+ if (!b.stuck) continue;
338
+ if (b.hold == null) b.hold = b.x - g.paddle.x;
339
+ b.hold = Math.max(0, Math.min(g.paddle.w, b.hold));
340
+ b.x = g.paddle.x + b.hold;
341
+ }
342
+ return g.paddle.x;
343
+ }
344
+ export function nudge(g, dx) { return movePaddle(g, g.paddle.x + g.paddle.w / 2 + dx); }
345
+
346
+ /** Send every waiting ball on its way. The angle is the caller's, never a roll. */
347
+ export function launch(g, angle = 0.3) {
348
+ if (g.over) return false;
349
+ const waiting = g.balls.filter((b) => b.stuck);
350
+ if (!waiting.length) return false;
351
+ const s = speed(g.level) * (g.slow ? SLOW_FACTOR : 1);
352
+ const a = Math.max(-MAX_BOUNCE, Math.min(MAX_BOUNCE, angle));
353
+ for (const b of waiting) { b.vx = Math.sin(a) * s; b.vy = Math.cos(a) * s; b.stuck = false; }
354
+ return true;
355
+ }
356
+
357
+ /** Fire, if the laser is up and it has cooled. Returns the bolts created. */
358
+ export function fire(g) {
359
+ if (g.over || !g.laser || g.cooldown > 0) return [];
360
+ const p = g.paddle;
361
+ const made = [
362
+ { id: g.nextId++, x: p.x + 0.3, y: PADDLE_Y + PADDLE_D },
363
+ { id: g.nextId++, x: p.x + p.w - 0.3, y: PADDLE_Y + PADDLE_D },
364
+ ];
365
+ g.bolts.push(...made);
366
+ g.cooldown = LASER_COOLDOWN;
367
+ return made;
368
+ }
369
+
370
+ function offPaddle(g, b) {
371
+ const p = g.paddle;
372
+ const off = Math.max(-1, Math.min(1, (b.x - (p.x + p.w / 2)) / (p.w / 2)));
373
+ const s = Math.hypot(b.vx, b.vy) || speed(g.level);
374
+ const a = off * MAX_BOUNCE;
375
+ b.vx = Math.sin(a) * s;
376
+ b.vy = Math.abs(Math.cos(a) * s);
377
+ b.y = PADDLE_Y + PADDLE_D + b.r;
378
+ // caught: remember WHERE on the bat, so it rides along rather than being shoved by an edge
379
+ if (g.catch) { b.stuck = true; b.vx = 0; b.vy = 0; b.hold = Math.max(0, Math.min(p.w, b.x - p.x)); }
380
+ }
381
+
382
+ /** Take a capsule. Each one is a different promise, so each is written out rather than table-driven. */
383
+ export function applyCapsule(g, kind) {
384
+ g.score += CAPSULE_POINTS;
385
+ switch (kind) {
386
+ case 'laser': g.laser = true; g.timers.laser = POWER_MS; g.catch = false; g.timers.catch = 0; break;
387
+ case 'enlarge': {
388
+ const mid = g.paddle.x + g.paddle.w / 2;
389
+ g.paddle.w = PADDLE_W_WIDE;
390
+ g.paddle.x = Math.max(0, Math.min(COLS - g.paddle.w, mid - g.paddle.w / 2));
391
+ g.timers.wide = POWER_MS;
392
+ break;
393
+ }
394
+ case 'catch': g.catch = true; g.timers.catch = POWER_MS; g.laser = false; g.timers.laser = 0; break;
395
+ case 'slow': {
396
+ // only slow what is not already slowed, or two capsules would compound into a crawl
397
+ if (!g.slow) for (const b of g.balls) if (!b.stuck) { b.vx *= SLOW_FACTOR; b.vy *= SLOW_FACTOR; }
398
+ g.slow = true;
399
+ g.timers.slow = POWER_MS;
400
+ break;
401
+ }
402
+ case 'disrupt': {
403
+ // three on the court. The extra two leave at a fixed spread off the first, so a disrupted
404
+ // rally is still a rally a test can follow.
405
+ const live = g.balls.filter((b) => !b.stuck);
406
+ const from = live[0] ?? g.balls[0];
407
+ if (from && !from.stuck) {
408
+ for (const turn of [0.45, -0.45]) {
409
+ const c = Math.cos(turn), s = Math.sin(turn);
410
+ g.balls.push({ x: from.x, y: from.y, vx: from.vx * c - from.vy * s, vy: from.vx * s + from.vy * c, r: BALL_R, stuck: false });
411
+ }
412
+ }
413
+ break;
414
+ }
415
+ case 'player': g.lives += 1; break;
416
+ default: break;
417
+ }
418
+ return g;
419
+ }
420
+
421
+ function hitBrick(g, b, hits) {
422
+ for (let i = 0; i < g.bricks.length; i++) {
423
+ const k = g.bricks[i];
424
+ const ox = Math.min(b.x + b.r, k.x + 1) - Math.max(b.x - b.r, k.x);
425
+ const oy = Math.min(b.y + b.r, k.y + 1) - Math.max(b.y - b.r, k.y);
426
+ if (ox <= 0 || oy <= 0) continue;
427
+ if (ox < oy) { b.vx = b.x < k.x + 0.5 ? -Math.abs(b.vx) : Math.abs(b.vx); b.x += b.vx > 0 ? ox : -ox; }
428
+ else { b.vy = b.y < k.y + 0.5 ? -Math.abs(b.vy) : Math.abs(b.vy); b.y += b.vy > 0 ? oy : -oy; }
429
+ damage(g, k, i, hits);
430
+ return true;
431
+ }
432
+ return false;
433
+ }
434
+
435
+ /** One hit on a brick. Gold rings and stands; silver counts down; anything else goes. */
436
+ function damage(g, k, i, hits) {
437
+ if (k.kind === 'gold') { hits.push({ kind: 'gold', x: k.x, y: k.y, color: k.color }); return false; }
438
+ k.hits -= 1;
439
+ if (k.hits > 0) { hits.push({ kind: 'silver', x: k.x, y: k.y, color: k.color }); return false; }
440
+ g.bricks.splice(i, 1);
441
+ g.score += k.points;
442
+ hits.push({ kind: 'brick', x: k.x, y: k.y, color: k.color, points: k.points, brickKind: k.kind });
443
+ // one capsule on the court at a time, exactly as the arcade does it
444
+ const cap = g.opts?.capsules === false ? null : capsuleFor(k, g.level);
445
+ if (cap && !g.capsules.length) {
446
+ g.capsules.push({ id: g.nextId++, kind: cap, x: k.x, y: k.y });
447
+ hits.push({ kind: 'capsuleout', capsule: cap, x: k.x, y: k.y });
448
+ }
449
+ return true;
450
+ }
451
+
452
+ function moveCapsules(g, dt, hits) {
453
+ const p = g.paddle;
454
+ for (let i = g.capsules.length - 1; i >= 0; i--) {
455
+ const c = g.capsules[i];
456
+ c.y -= CAPSULE_FALL * dt;
457
+ const caught = c.y <= PADDLE_Y + PADDLE_D && c.y + CAPSULE_BOX >= PADDLE_Y
458
+ && c.x + CAPSULE_BOX >= p.x && c.x <= p.x + p.w;
459
+ if (caught) {
460
+ g.capsules.splice(i, 1);
461
+ applyCapsule(g, c.kind);
462
+ hits.push({ kind: 'capsule', capsule: c.kind });
463
+ } else if (c.y + CAPSULE_BOX < 0) g.capsules.splice(i, 1);
464
+ }
465
+ }
466
+
467
+ function moveBolts(g, dt, hits) {
468
+ for (let i = g.bolts.length - 1; i >= 0; i--) {
469
+ const z = g.bolts[i];
470
+ z.y += LASER_SPEED * dt;
471
+ if (z.y > ROWS) { g.bolts.splice(i, 1); continue; }
472
+ let spent = false;
473
+ for (let e = g.enemies.length - 1; e >= 0 && !spent; e--) {
474
+ const m = g.enemies[e];
475
+ if (z.x >= m.x && z.x <= m.x + ENEMY_BOX && z.y >= m.y && z.y <= m.y + ENEMY_BOX) {
476
+ g.enemies.splice(e, 1); g.score += ENEMY_POINTS;
477
+ hits.push({ kind: 'enemy', x: m.x, y: m.y });
478
+ spent = true;
479
+ }
480
+ }
481
+ for (let bi = 0; bi < g.bricks.length && !spent; bi++) {
482
+ const k = g.bricks[bi];
483
+ if (z.x >= k.x && z.x <= k.x + 1 && z.y >= k.y && z.y <= k.y + 1) {
484
+ damage(g, k, bi, hits);
485
+ spent = true;
486
+ }
487
+ }
488
+ if (spent) g.bolts.splice(i, 1);
489
+ }
490
+ }
491
+
492
+ /** Does a box of side `s` at (x,y) overlap any brick? A brick is exactly one grid cell. */
493
+ export function boxHitsBrick(g, x, y, s) {
494
+ for (const k of g.bricks) {
495
+ if (x + s > k.x && x < k.x + 1 && y + s > k.y && y < k.y + 1) return k;
496
+ }
497
+ return null;
498
+ }
499
+
500
+ /**
501
+ * A life goes. TWO things cost one now -- the last ball through the floor, and a minion reaching
502
+ * Vaus -- so the consequences live in one place rather than being written twice and drifting apart.
503
+ */
504
+ export function loseLife(g, hits, kind) {
505
+ g.lives -= 1;
506
+ hits.push({ kind });
507
+ resetVaus(g);
508
+ g.capsules = []; g.bolts = []; g.enemies = []; g.sinceEnemy = 0;
509
+ if (g.lives <= 0) { g.over = true; resetBall(g); g.balls[0].stuck = true; g.balls[0].vx = 0; g.balls[0].vy = 0; }
510
+ else resetBall(g);
511
+ }
512
+
513
+ function moveEnemies(g, dt, dtMs, hits) {
514
+ if (g.opts?.enemies === false) { g.sinceEnemy = 0; return; }
515
+ g.sinceEnemy += dtMs;
516
+ if (g.sinceEnemy >= ENEMY_EVERY && g.enemies.length < 3 && breakable(g).length) {
517
+ g.sinceEnemy = 0;
518
+ const n = g.nextId++;
519
+ // deterministic arrival: where it enters is a property of the level and the count, not a roll
520
+ const lane = Math.floor(hash01(`enemy:${g.level}:${n}`) * (COLS - 1));
521
+ // which minion turns up is a property of the level and the arrival, not a roll
522
+ const kind = MINION_KINDS[Math.floor(hash01(`mk:${g.level}:${n}`) * MINION_KINDS.length)];
523
+ g.enemies.push({ id: n, kind, x: lane, y: ROWS - 1, phase: hash01(`ph:${n}`) * Math.PI * 2, t: 0 });
524
+ hits.push({ kind: 'enemyin', x: lane, minion: kind });
525
+ }
526
+ const p = g.paddle;
527
+ const lim = COLS - ENEMY_BOX;
528
+ for (let i = g.enemies.length - 1; i >= 0; i--) {
529
+ const m = g.enemies[i];
530
+ m.t += dt;
531
+
532
+ // IT LOOKS FOR A WAY THROUGH (operator: "the minions should slowly try to find a path out").
533
+ // A wall-follower, not a pathfinder: descend when the space below is clear; when it is not,
534
+ // feel sideways in one direction for a gap; at a dead end, turn round. On a solid wall that
535
+ // reads as a minion pacing the top until the player opens a hole -- the arcade behaviour, and
536
+ // what makes breaking the wall feel like relief rather than bookkeeping.
537
+ const down = ENEMY_SPEED * dt;
538
+ if (!boxHitsBrick(g, m.x, m.y - down, ENEMY_BOX)) {
539
+ m.y -= down;
540
+ const nx = Math.max(0, Math.min(lim, m.x + minionDrift(m.kind, m.phase, m.t) * dt));
541
+ if (!boxHitsBrick(g, nx, m.y, ENEMY_BOX)) m.x = nx;
542
+ } else {
543
+ if (m.dir == null) m.dir = hash01(`dir:${m.id}`) < 0.5 ? -1 : 1;
544
+ const nx = m.x + m.dir * ENEMY_SIDE * dt;
545
+ if (nx >= 0 && nx <= lim && !boxHitsBrick(g, nx, m.y, ENEMY_BOX)) m.x = nx;
546
+ else m.dir = -m.dir;
547
+ }
548
+
549
+ // AND IT IS FATAL (operator: "kill the player if the player hits it"). It used to PAY 200 for
550
+ // touching Vaus, which had it backwards: the ball and the laser destroy a minion for points,
551
+ // but a minion that reaches the bat destroys the bat.
552
+ const onBat = m.y <= PADDLE_Y + PADDLE_D && m.y + ENEMY_BOX >= PADDLE_Y
553
+ && m.x + ENEMY_BOX >= p.x && m.x <= p.x + p.w;
554
+ if (onBat) { hits.push({ kind: 'vaus', x: m.x, y: m.y }); loseLife(g, hits, 'life'); return; }
555
+ if (m.y + ENEMY_BOX < 0) g.enemies.splice(i, 1);
556
+ }
557
+ }
558
+
559
+ function ballEnemy(g, b, hits) {
560
+ for (let i = g.enemies.length - 1; i >= 0; i--) {
561
+ const m = g.enemies[i];
562
+ const ox = Math.min(b.x + b.r, m.x + ENEMY_BOX) - Math.max(b.x - b.r, m.x);
563
+ const oy = Math.min(b.y + b.r, m.y + ENEMY_BOX) - Math.max(b.y - b.r, m.y);
564
+ if (ox <= 0 || oy <= 0) continue;
565
+ g.enemies.splice(i, 1);
566
+ g.score += ENEMY_POINTS;
567
+ // BOUNCE OFF IT (operator: "If a ball hits a minion from above, it should bounce off it when it
568
+ // dies instead of passing through"). This forced the ball DOWNWARD whatever direction it had
569
+ // arrived from, so a ball descending onto a minion was pushed further down and read as passing
570
+ // straight through. Reflected on the axis it is least buried in, exactly as a brick does it --
571
+ // which gives the side bounces for free too.
572
+ if (ox < oy) b.vx = b.x < m.x + ENEMY_BOX / 2 ? -Math.abs(b.vx) : Math.abs(b.vx);
573
+ else b.vy = b.y < m.y + ENEMY_BOX / 2 ? -Math.abs(b.vy) : Math.abs(b.vy);
574
+ hits.push({ kind: 'enemy', x: m.x, y: m.y });
575
+ return true;
576
+ }
577
+ return false;
578
+ }
579
+
580
+ function substepBall(g, b, h, hits) {
581
+ b.x += b.vx * h;
582
+ b.y += b.vy * h;
583
+ if (b.x - b.r < 0) { b.x = b.r; b.vx = Math.abs(b.vx); hits.push({ kind: 'wall' }); }
584
+ else if (b.x + b.r > COLS) { b.x = COLS - b.r; b.vx = -Math.abs(b.vx); hits.push({ kind: 'wall' }); }
585
+ if (b.y + b.r > ROWS) { b.y = ROWS - b.r; b.vy = -Math.abs(b.vy); hits.push({ kind: 'wall' }); }
586
+
587
+ const p = g.paddle;
588
+ if (b.vy < 0 && b.y - b.r <= PADDLE_Y + PADDLE_D && b.y + b.r >= PADDLE_Y
589
+ && b.x + b.r >= p.x && b.x - b.r <= p.x + p.w) {
590
+ offPaddle(g, b);
591
+ hits.push({ kind: 'paddle' });
592
+ }
593
+ ballEnemy(g, b, hits);
594
+ hitBrick(g, b, hits);
595
+ return b.y + b.r < 0; // through the floor
596
+ }
597
+
598
+ /**
599
+ * Advance the court by dtMs. Substepped for the same reason breakout.js is: a ball at twelve units
600
+ * a second crosses a one-unit brick in 83 ms and would tunnel through the wall on a slow frame.
601
+ */
602
+ export function step(g, dtMs) {
603
+ const hits = [];
604
+ if (g.over) return { hits, lost: false, cleared: g.cleared };
605
+ const standing = breakable(g).length > 0;
606
+ const dt = Math.min(60, Math.max(0, dtMs)) / 1000;
607
+ const ms = Math.min(60, Math.max(0, dtMs));
608
+ g.cooldown = Math.max(0, g.cooldown - ms);
609
+ for (const power of TIMED_POWERS) {
610
+ if (!(g.timers?.[power] > 0)) continue;
611
+ g.timers[power] = Math.max(0, g.timers[power] - ms);
612
+ if (g.timers[power] > 0) continue;
613
+ hits.push({ kind: 'powerover', power });
614
+ if (power === 'laser') g.laser = false;
615
+ if (power === 'wide') {
616
+ // back to stock about its OWN centre, and a held ball re-gripped into the narrower bat
617
+ const mid = g.paddle.x + g.paddle.w / 2;
618
+ g.paddle.w = PADDLE_W;
619
+ g.paddle.x = Math.max(0, Math.min(COLS - g.paddle.w, mid - g.paddle.w / 2));
620
+ for (const b of g.balls) if (b.stuck) { b.hold = Math.max(0, Math.min(g.paddle.w, b.hold ?? g.paddle.w / 2)); b.x = g.paddle.x + b.hold; }
621
+ }
622
+ if (power === 'slow') {
623
+ // put the pace BACK, or "slow" is permanent by omission
624
+ g.slow = false;
625
+ for (const b of g.balls) if (!b.stuck) { b.vx /= SLOW_FACTOR; b.vy /= SLOW_FACTOR; }
626
+ }
627
+ if (power === 'catch') {
628
+ g.catch = false;
629
+ hits.push({ kind: 'catchover' });
630
+ // A ball still held has to GO, or it sits on the bat with nothing left to explain why.
631
+ // Released at the angle its own position on the bat implies -- the same rule a bounce uses,
632
+ // so this is deterministic and the run stays reproducible.
633
+ for (const b of g.balls) {
634
+ if (!b.stuck) continue;
635
+ b.stuck = false;
636
+ const off = Math.max(-1, Math.min(1, (b.x - (g.paddle.x + g.paddle.w / 2)) / (g.paddle.w / 2)));
637
+ const sp = speed(g.level) * (g.slow ? SLOW_FACTOR : 1);
638
+ b.vx = Math.sin(off * MAX_BOUNCE) * sp;
639
+ b.vy = Math.abs(Math.cos(off * MAX_BOUNCE) * sp);
640
+ }
641
+ }
642
+ }
643
+
644
+ moveCapsules(g, dt, hits);
645
+ moveBolts(g, dt, hits);
646
+ moveEnemies(g, dt, Math.min(60, Math.max(0, dtMs)), hits);
647
+
648
+ const moving = g.balls.filter((b) => !b.stuck);
649
+ for (const b of moving) {
650
+ const sp = Math.hypot(b.vx, b.vy) || 1;
651
+ const n = Math.max(1, Math.ceil((sp * dt) / MAX_STEP));
652
+ const h = dt / n;
653
+ let gone = false;
654
+ for (let i = 0; i < n && !gone; i++) gone = substepBall(g, b, h, hits);
655
+ if (gone) b.dead = true;
656
+ }
657
+ if (g.balls.some((b) => b.dead)) g.balls = g.balls.filter((b) => !b.dead);
658
+
659
+ let lost = false;
660
+ if (!g.balls.length) { // every ball gone: a life, and Vaus back to stock
661
+ lost = true;
662
+ // ONE implementation, not two. This was a line-for-line copy of loseLife() until a minion
663
+ // reaching Vaus became the second way to lose one -- and two copies of a rule drift.
664
+ loseLife(g, hits, 'life');
665
+ }
666
+
667
+ if (standing && !breakable(g).length) g.cleared = true;
668
+ if (g.cleared) for (const b of g.balls) { b.stuck = true; b.vx = 0; b.vy = 0; }
669
+ return { hits, lost, cleared: g.cleared };
670
+ }
671
+
672
+ /**
673
+ * ONE CAPSULE, AS SEVERAL TILES -- its own little machine rather than a coloured slab.
674
+ *
675
+ * Pure, and separate from tiles(), so the seven silhouettes can be asserted without a canvas. Each
676
+ * is built from parts offset inside the capsule's own footprint (CAPSULE_S wide), with `floor`
677
+ * lifting a glyph to stand ON the body. Deliberately different part counts and heights:
678
+ *
679
+ * laser two cannons standing tall slow the only HOLLOW one (a wire box)
680
+ * enlarge the flattest, with end caps disrupt three small spheres in a row
681
+ * catch a ball cradled between posts player the tallest: a tower with a bead on top
682
+ * break a doorway: two posts and a lintel, and the only one with no full-width base
683
+ */
684
+ export function capsuleTiles(c, now = 0) {
685
+ const k = c.kind;
686
+ const body = CAPSULE_COLOR[k] ?? '#e8eefc';
687
+ const trim = CAPSULE_TRIM[k] ?? '#ffffff';
688
+ const spec = CAPSULE_SHAPE[k] ?? CAPSULE_SHAPE.laser;
689
+ const id = (n) => `cap${c.id}${n}`;
690
+ // IT TUMBLES AS IT FALLS. The angle comes from the capsule's own height, not from a clock, so
691
+ // two capsules at the same height look the same and a test can say what a frame contains --
692
+ // the rules stay free of time, exactly as the launch angle is an argument rather than a roll.
693
+ const rot = (c.y * 1.5 + (c.id % 7) * 0.9) % (Math.PI * 2);
694
+ // drawn large, CENTRED on the catch box, so what you aim at is under what you see
695
+ const off = (CAPSULE_S - CAPSULE_BOX) / 2;
696
+ const x = c.x - off, y = c.y - off;
697
+ return [
698
+ // the pill: an elongated stadium, wider than it is tall, turning end over end
699
+ { txid: id('p'), x, y, s: CAPSULE_S, tall: 0.44, color: body, capsule: true, poly: PILL, rot },
700
+ // the band across it, in the capsule's trim colour, so the letter-colour still reads
701
+ { txid: id('b'), x, y, s: CAPSULE_S, tall: 0.46, color: trim, capsule: true, poly: spec.band, rot },
702
+ ];
703
+ }
704
+
705
+ // A STADIUM, drawn once. Unit space (-0.5..0.5), so it scales with the tile: a flat-ended
706
+ // rectangle with a fan of points rounding each cap. Long axis across, which is what makes it read
707
+ // as a pill rather than a lozenge.
708
+ const PILL = Object.freeze((() => {
709
+ const pts = [];
710
+ const hw = 0.46, hh = 0.2, r = hh;
711
+ for (let i = 0; i <= 6; i++) { const a = -Math.PI / 2 + (i / 6) * Math.PI; pts.push([hw - r + Math.cos(a) * r, Math.sin(a) * r]); }
712
+ for (let i = 0; i <= 6; i++) { const a = Math.PI / 2 + (i / 6) * Math.PI; pts.push([-hw + r + Math.cos(a) * r, Math.sin(a) * r]); }
713
+ return pts.map(([x, y]) => Object.freeze([Math.round(x * 1000) / 1000, Math.round(y * 1000) / 1000]));
714
+ })());
715
+
716
+ // EACH PILL STILL CARRIES ITS OWN MARK. The band is a different figure per capsule, so the seven
717
+ // remain distinguishable in monochrome -- the property the capsule test asserts -- even though
718
+ // they now share one silhouette. Colour is the third cue, not the only one.
719
+ const CAPSULE_SHAPE = Object.freeze({
720
+ laser: { band: Object.freeze([[-0.30, -0.12], [-0.18, -0.12], [-0.18, 0.12], [-0.30, 0.12]]) }, // one bar, left
721
+ enlarge: { band: Object.freeze([[-0.34, -0.10], [0.34, -0.10], [0.34, 0.10], [-0.34, 0.10]]) }, // a long bar
722
+ catch: { band: Object.freeze([[-0.10, -0.16], [0.14, -0.16], [0.14, 0.16], [-0.10, 0.16]]) }, // a block, right of centre
723
+ slow: { band: Object.freeze([[-0.05, -0.17], [0.05, -0.17], [0.05, 0.17], [-0.05, 0.17]]) }, // a thin upright
724
+ disrupt: { band: Object.freeze([[-0.28, -0.07], [-0.10, -0.07], [-0.10, 0.07], [-0.28, 0.07]]) }, // small, far left
725
+ player: { band: Object.freeze([[-0.07, -0.20], [0.07, -0.20], [0.07, 0.20], [-0.07, 0.20]]) }, // a tall bar
726
+ });
727
+
728
+ /**
729
+ * The board as tiles for the engine. A brick is exactly one grid cell, so a brick IS a tile -- the
730
+ * same reason Blockout needed no translation layer. Vaus turns red while the laser is up, so the
731
+ * bat itself says what it can do rather than only the HUD saying it.
732
+ */
733
+ export function tiles(g) {
734
+ const out = [];
735
+ for (const k of g.bricks) {
736
+ // Wear is shown TWICE, on purpose: the brick lightens toward white as it nears breaking, and
737
+ // it also sinks. Colour is the signal the operator asked for; height carries it for anyone who
738
+ // cannot separate these shades, and survives a monochrome screenshot.
739
+ const max = k.maxHits ?? (k.kind === 'silver' ? silverHits(g.level) : 1);
740
+ const multi = Number.isFinite(max) && max > 1;
741
+ const wear = multi ? (max - Math.max(1, k.hits)) / (max - 1) : 0; // 0 fresh .. 1 about to break
742
+ out.push({ txid: `b${k.id}`, x: k.x, y: k.y, s: 1,
743
+ tall: multi ? 1 - 0.28 * wear : 1,
744
+ color: damageShade(k.hits, max, k.color) });
745
+ }
746
+ // VAUS MORPHS WHEN IT IS ARMED. Stock it is a row of flat stones. With the laser it grows a
747
+ // cannon at each end and a raised housing between them -- the bat itself says what it can do,
748
+ // rather than only its colour changing.
749
+ const p = g.paddle;
750
+ const padColor = g.laser ? PADDLE_LASER_COLOR : PADDLE_COLOR;
751
+ const wide = Math.round(p.w);
752
+ for (let i = 0; i < wide; i++) {
753
+ const end = g.laser && (i === 0 || i === wide - 1);
754
+ out.push({ txid: `pad${i}`, x: p.x + i, y: PADDLE_Y, s: 1,
755
+ tall: g.laser ? (end ? 0.34 : 0.62) : PADDLE_H, color: padColor, paddle: true });
756
+ }
757
+ if (g.laser) {
758
+ for (const [n, dx] of [['l', 0.12], ['r', wide - 0.52]]) {
759
+ // NOT `paddle: true`. The cannons sit ON the bat; they are not segments OF it, and the flag
760
+ // means "this tile is a unit of Vaus's width" -- which the collision box and the tests both
761
+ // rely on. Flagging them made a 3-wide bat report 5 tiles wide.
762
+ out.push({ txid: `cannon${n}`, x: p.x + dx, y: PADDLE_Y + 0.05, s: 0.4, tall: 0.5,
763
+ color: '#ffd9cf', cannon: true,
764
+ poly: [[-0.16, -0.5], [0.16, -0.5], [0.22, 0.2], [0, 0.5], [-0.22, 0.2]], rot: 0 });
765
+ }
766
+ }
767
+ for (const c of g.capsules) out.push(...capsuleTiles(c));
768
+ for (const z of g.bolts) {
769
+ // the halo: a wider, softer tube around the beam, drawn as a glowing outline
770
+ out.push({ txid: `bolt${z.id}h`, x: z.x - 0.17, y: z.y - 0.05, s: 0.34, tall: 0.9,
771
+ color: BOLT_GLOW, wire: BOLT_GLOW, bolt: true });
772
+ // the beam itself: a hot core, also an outline so the engine never shades it down
773
+ out.push({ txid: `bolt${z.id}`, x: z.x - 0.07, y: z.y, s: 0.14, tall: 0.8,
774
+ color: BOLT_COLOR, wire: BOLT_COLOR, bolt: true });
775
+ }
776
+ for (const m of g.enemies) {
777
+ const spec = MINIONS[m.kind] ?? MINIONS.cone;
778
+ // drawn large, CENTRED on the small box it actually collides with
779
+ const off = (ENEMY_S - ENEMY_BOX) / 2;
780
+ out.push({ txid: `foe${m.id}`, x: m.x - off, y: m.y - off, s: ENEMY_S, tall: ENEMY_S,
781
+ color: spec.color, enemy: true, minion: m.kind,
782
+ poly: spec.poly, eyes: spec.eyes, rot: (m.phase + m.t * spec.spin) % (Math.PI * 2) });
783
+ }
784
+ for (let i = 0; i < g.balls.length; i++) {
785
+ const b = g.balls[i];
786
+ out.push({ txid: `ball${i}`, x: b.x - b.r, y: b.y - b.r, s: BALL_S, tall: BALL_S, color: BALL_COLOR, ball: true, sphere: true });
787
+ }
788
+ return out;
789
+ }
790
+
791
+ /** How much of the breakable wall is left, 0..1. */
792
+ export function remaining(g) {
793
+ return g.breakableAtStart ? breakable(g).length / g.breakableAtStart : 0;
794
+ }
795
+
796
+ /** What Vaus is carrying, for the HUD. */
797
+ export function powers(g) {
798
+ const on = [];
799
+ const left = (k) => Math.ceil((g.timers?.[k] ?? 0) / 1000);
800
+ if (g.laser) on.push(`laser ${left('laser')}s`);
801
+ if (g.catch) on.push(`catch ${left('catch')}s`);
802
+ if (g.slow) on.push(`slow ${left('slow')}s`);
803
+ if (g.paddle.w > PADDLE_W) on.push(`wide ${left('wide')}s`);
804
+ if (g.balls.length > 1) on.push(`${g.balls.length} balls`);
805
+ return on;
806
+ }