blockyard 0.0.1 → 0.0.9

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 (107) hide show
  1. package/CHANGELOG.md +679 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +172 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +40 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1575 -0
  9. package/docs/ARCHITECTURE.md +1307 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +840 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +202 -0
  15. package/docs/INSTALL.md +490 -0
  16. package/docs/MEASUREMENTS.md +1254 -0
  17. package/docs/PRIVATE-LEADERBOARD.md +230 -0
  18. package/docs/RULES.md +681 -0
  19. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  20. package/docs/SECURITY-AUDIT.md +258 -0
  21. package/docs/SECURITY.md +195 -0
  22. package/docs/STATE-2026-09-09.md +200 -0
  23. package/docs/TROUBLESHOOTING.md +298 -0
  24. package/docs/USER-GUIDE.md +1022 -0
  25. package/package.json +53 -5
  26. package/public/404.html +9 -0
  27. package/public/css/app.css +1785 -0
  28. package/public/index.html +893 -0
  29. package/public/js/about.js +112 -0
  30. package/public/js/agents.js +964 -0
  31. package/public/js/app.js +1312 -0
  32. package/public/js/arkanoid.js +806 -0
  33. package/public/js/blockanoid.js +347 -0
  34. package/public/js/blockout.js +347 -0
  35. package/public/js/blockpack.js +428 -0
  36. package/public/js/blockscene3d.js +2678 -0
  37. package/public/js/breakout.js +224 -0
  38. package/public/js/charts.js +635 -0
  39. package/public/js/depthchart.js +311 -0
  40. package/public/js/details3d.js +2957 -0
  41. package/public/js/explorer.js +405 -0
  42. package/public/js/feepalette.js +149 -0
  43. package/public/js/fmt.js +162 -0
  44. package/public/js/goggles.js +886 -0
  45. package/public/js/kiosk.js +41 -0
  46. package/public/js/login.js +83 -0
  47. package/public/js/markets.js +357 -0
  48. package/public/js/mining.js +1138 -0
  49. package/public/js/panels.js +966 -0
  50. package/public/js/pricechart.js +188 -0
  51. package/public/js/settings.js +1014 -0
  52. package/public/js/tetris.js +226 -0
  53. package/public/js/tetrust.js +356 -0
  54. package/public/js/tetsound.js +175 -0
  55. package/public/login.html +33 -0
  56. package/scripts/blockfile-measure.js +156 -0
  57. package/scripts/browser-check.mjs +286 -0
  58. package/scripts/check.js +173 -0
  59. package/scripts/decode-check.js +81 -0
  60. package/scripts/doc-counts.js +109 -0
  61. package/scripts/donate-qr.py +20 -0
  62. package/scripts/fake-node.js +534 -0
  63. package/scripts/index-bench.js +216 -0
  64. package/scripts/index-benchmark.js +117 -0
  65. package/scripts/index-build.js +40 -0
  66. package/scripts/live-render-check.mjs +89 -0
  67. package/scripts/manage-users.js +132 -0
  68. package/scripts/motion-check.mjs +138 -0
  69. package/scripts/pool-map.js +157 -0
  70. package/scripts/setup.js +410 -0
  71. package/scripts/shots.mjs +272 -0
  72. package/scripts/smoke.sh +327 -0
  73. package/scripts/ui.js +174 -0
  74. package/server/auth/sessions.js +221 -0
  75. package/server/auth/users.js +243 -0
  76. package/server/chain/blockfile.js +234 -0
  77. package/server/chain/index/build.js +193 -0
  78. package/server/chain/index/heights.js +36 -0
  79. package/server/chain/index/live.js +276 -0
  80. package/server/chain/index/rows.js +145 -0
  81. package/server/chain/index/store.js +154 -0
  82. package/server/chain/index/worker.js +109 -0
  83. package/server/chain/tx.js +310 -0
  84. package/server/collect/gbt.js +229 -0
  85. package/server/collect/logparse.js +765 -0
  86. package/server/collect/logtail.js +189 -0
  87. package/server/collect/markets.js +333 -0
  88. package/server/collect/mining.js +333 -0
  89. package/server/collect/monitor.js +2516 -0
  90. package/server/collect/nextblock.js +275 -0
  91. package/server/collect/sync.js +386 -0
  92. package/server/config.js +620 -0
  93. package/server/http/api.js +1275 -0
  94. package/server/http/explorer.js +418 -0
  95. package/server/http/server.js +412 -0
  96. package/server/http/sse.js +176 -0
  97. package/server/http/static.js +212 -0
  98. package/server/main.js +628 -0
  99. package/server/netinfo.js +253 -0
  100. package/server/rpc/allowlist.js +130 -0
  101. package/server/rpc/client.js +414 -0
  102. package/server/store/audit.js +148 -0
  103. package/server/store/history.js +220 -0
  104. package/server/store/ledger.js +290 -0
  105. package/server/store/ring.js +173 -0
  106. package/server/util/fmt.js +29 -0
  107. package/systemd/blockyard.service +100 -0
@@ -0,0 +1,347 @@
1
+ // BLOCKANOID, the tab (operator, 2026-09-12: "Take blockout, and make rip off of Arkanoid using our
2
+ // engine, and make it a new Diversion called 'Blockanoid'").
3
+ //
4
+ // The rules live in arkanoid.js and know nothing of the screen. This file is the screen, and it is
5
+ // deliberately Blockout's shape: the court is the same board3d the Block space, Tetrust and Blockout
6
+ // boards use, and the panel behind it is the same sky. What differs is what there is to draw --
7
+ // capsules falling, bolts climbing, minions drifting -- and the fact that a brick can survive.
8
+ import { board3d } from './details3d.js';
9
+ import {
10
+ newGame, advance, step, movePaddle, nudge, launch, fire, tiles, remaining, powers, setOptions,
11
+ COLS, ROWS, LIVES, CAPSULE_LETTER, layoutFor,
12
+ } from './arkanoid.js';
13
+ import { loadSettings, setSetting, blockanoidOptions } from './settings.js';
14
+ import * as sound from './tetsound.js';
15
+
16
+ const SCORES_KEY = 'blockyard.blockanoid.scores';
17
+ const KEEP = 10;
18
+
19
+ const COURT = {
20
+ gridW: COLS, gridH: ROWS,
21
+ oblique: { ox: 0.10, oy: 0.28, headroom: 3, flight: 0 },
22
+ dome: 4,
23
+ light: 'overhead',
24
+ gridStep: 1,
25
+ space: true,
26
+ background: 'rgba(0,0,0,0)',
27
+ spaceFloor: 'rgba(0,0,0,0.22)',
28
+ neonCell: 'rgba(60,200,140,0.18)',
29
+ };
30
+ const SKY = { gridW: COLS, gridH: ROWS, oblique: COURT.oblique, dome: 0, space: false, grid: false, background: 'rgba(0,0,0,1)', idleFx: false, shadows: false, still: true, transition: { rise: 0, travel: 1, drop: 0 }, maxDpr: 1 };
31
+
32
+ const KEY_NUDGE = 1.15;
33
+
34
+ // A BROKEN BRICK LEAVES, as in Blockout: the same launch Tetrust's cleared lines take.
35
+ const DEBRIS_MS = 900;
36
+ const DEBRIS_RISE = 70;
37
+
38
+ const G = {
39
+ game: null, running: false, paused: false, why: '', raf: null, last: 0,
40
+ bound: false, state: null, h: null, held: { left: false, right: false },
41
+ debris: [], nextDebris: 1,
42
+ };
43
+
44
+ export function debrisTiles(debris, now, ms = DEBRIS_MS) {
45
+ const out = [];
46
+ for (const d of debris) {
47
+ const t = Math.min(1, Math.max(0, (now - d.t0) / ms));
48
+ if (t >= 1) continue;
49
+ const e = t * t * (3 - 2 * t) * 0.35 + t * t * 0.65;
50
+ const k = t < 0.82 ? 1 : 1 - (t - 0.82) / 0.18;
51
+ out.push({ txid: `k${d.id}`, x: d.x + d.dx * e, y: d.y, s: 1, tall: 1,
52
+ floor: DEBRIS_RISE * e, color: fadeHex(d.color, k) });
53
+ }
54
+ return out;
55
+ }
56
+ function fadeHex(hex, k) {
57
+ const h = hex.replace('#', '');
58
+ const ch = (i) => Math.max(0, Math.min(255, Math.round(parseInt(h.slice(i, i + 2), 16) * k)));
59
+ return `#${[ch(0), ch(2), ch(4)].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
60
+ }
61
+
62
+ function opts(base) {
63
+ const b = blockanoidOptions(loadSettings());
64
+ return {
65
+ ...base,
66
+ space: true, stars: false, galaxy: false, idleFx: false, shadows: false,
67
+ hover: false, // the pointer is holding Vaus, not picking out a stone
68
+ // the grid is the operator's now; spread after `base` so it overrides COURT's own neonCell
69
+ ...b.gridOpts, grid: b.grid,
70
+ edges: true, facetPx: Infinity, crownPx: Infinity, sheen: false,
71
+ neon: b.neon, neonSource: b.neonSource, neonColour: b.neonColour, neonBrightness: b.neonBrightness,
72
+ still: true,
73
+ transition: { rise: 0, travel: 1, drop: 0 },
74
+ };
75
+ }
76
+
77
+ // ------------------------------------------------------------------ scores, this browser's
78
+ export function loadScores(storage = globalThis.localStorage) {
79
+ try {
80
+ const raw = storage?.getItem(SCORES_KEY);
81
+ const list = raw ? JSON.parse(raw) : [];
82
+ return Array.isArray(list) ? list.filter((r) => r && Number.isFinite(r.score)).slice(0, KEEP) : [];
83
+ } catch { return []; }
84
+ }
85
+ export function recordScore(entry, storage = globalThis.localStorage) {
86
+ const list = [...loadScores(storage), { score: entry.score, level: entry.level, at: entry.at ?? Date.now() }]
87
+ .sort((a, b) => b.score - a.score || b.level - a.level)
88
+ .slice(0, KEEP);
89
+ try { storage?.setItem(SCORES_KEY, JSON.stringify(list)); } catch { /* private mode, quota */ }
90
+ return list;
91
+ }
92
+ export function rankOf(score, list) {
93
+ const i = list.findIndex((r) => score > r.score);
94
+ if (i >= 0) return i + 1;
95
+ return list.length < KEEP ? list.length + 1 : null;
96
+ }
97
+
98
+ // ------------------------------------------------------------------ the screen
99
+ const el = (id) => document.getElementById(id);
100
+
101
+ function overlay(msg, sub, button, dim = false) {
102
+ const ov = el('baOver'), m = el('baMsg'), s = el('baSub'), b = el('baResume');
103
+ if (!ov) return;
104
+ ov.classList.toggle('hidden', !msg);
105
+ ov.classList.toggle('dim', dim);
106
+ if (m) m.textContent = msg ?? '';
107
+ if (s) s.textContent = sub ?? '';
108
+ if (b) b.textContent = button ?? 'play';
109
+ }
110
+
111
+ function drawStats() {
112
+ const g = G.game, box = el('baStats');
113
+ if (!box) return;
114
+ const rows = g
115
+ ? [['score', g.score.toLocaleString()], ['lives', '●'.repeat(Math.max(0, g.lives))], ['level', String(g.level)],
116
+ ['wall left', `${Math.round(remaining(g) * 100)}%`], ['vaus', powers(g).join(', ') || 'stock']]
117
+ : [['score', '–'], ['lives', '●'.repeat(LIVES)], ['level', '–'], ['vaus', 'stock']];
118
+ const html = rows.map(([k, v]) => `<i>${k}</i><b>${v}</b>`).join('');
119
+ if (box.innerHTML !== html) box.innerHTML = html;
120
+ }
121
+
122
+ function drawScores(highlightAt = null) {
123
+ const t = el('baScores');
124
+ if (!t) return;
125
+ const list = loadScores();
126
+ const html = list.length
127
+ ? `<tr><th>#</th><th>score</th><th>lvl</th><th>when</th></tr>` + list.map((r, i) =>
128
+ `<tr${r.at === highlightAt ? ' class="now"' : ''}><td>${i + 1}</td><td>${r.score.toLocaleString()}</td><td>${r.level}</td><td>${new Date(r.at).toISOString().slice(0, 10)}</td></tr>`).join('')
129
+ : `<tr><td class="faint">no walls broken yet</td></tr>`;
130
+ if (t.innerHTML !== html) t.innerHTML = html;
131
+ }
132
+
133
+ const SWITCHES = [['baStars', 'stars'], ['baGalaxy', 'galaxy'], ['baNeon', 'neon'], ['baSfx', 'sfx']];
134
+ function drawSwitches() {
135
+ const b = blockanoidOptions(loadSettings());
136
+ for (const [id, key] of SWITCHES) {
137
+ const btn = el(id);
138
+ if (!btn) continue;
139
+ btn.classList.toggle('on', !!b[key]);
140
+ btn.setAttribute('aria-pressed', b[key] ? 'true' : 'false');
141
+ }
142
+ sound.setSfx(b.sfx);
143
+ }
144
+ function flip(key) {
145
+ const cur = blockanoidOptions(loadSettings())[key];
146
+ setSetting(loadSettings(), `blockanoid.${key}`, !cur);
147
+ sound.unlock();
148
+ drawSwitches();
149
+ if (key === 'stars' || key === 'galaxy') drawSky();
150
+ // capsules and minions change the RULES, so a flip must reach the game IN PLAY, not just the
151
+ // next one -- otherwise the switch does nothing until you lose, which reads as a broken control
152
+ if (G.game) { const b = blockanoidOptions(loadSettings()); setOptions(G.game, { capsules: b.capsules, enemies: b.enemies }); }
153
+ draw();
154
+ }
155
+
156
+ function drawSky() {
157
+ const sky = el('baSky');
158
+ if (!sky) return;
159
+ const b = blockanoidOptions(loadSettings());
160
+ board3d(sky, [], {
161
+ ...SKY,
162
+ stars: b.stars, galaxy: b.stars && b.galaxy, galaxyAt: b.galaxyAt,
163
+ starDensity: b.starDensity, starBrightness: b.starBrightness,
164
+ nebulae: b.nebulae, galaxies: b.galaxies, dust: b.dust, clusters: b.clusters,
165
+ starColours: b.starColours, starGlints: b.starGlints,
166
+ });
167
+ }
168
+
169
+ function draw(now = performance.now()) {
170
+ const g = G.game;
171
+ const court = el('baWell');
172
+ el('baWellWrap')?.classList.toggle('idle', !g);
173
+ // Only with a game on: `.tetwell.idle` is `visibility: hidden`, so an idle court is not on screen
174
+ // and painting it would be a full board3d pass for a canvas nobody can see. (Tried the other way
175
+ // to make a grid-colour change previewable before pressing play; it cannot be seen either way.)
176
+ if (court && g) board3d(court, [...tiles(g), ...debrisTiles(G.debris, now)], opts(COURT));
177
+ drawStats();
178
+ }
179
+
180
+ // ------------------------------------------------------------------ the loop
181
+ function frame(t) {
182
+ G.raf = null;
183
+ if (!G.running) return;
184
+ if (document.hidden || G.state?.page !== 'blockanoid') { pause(document.hidden ? 'paused — you looked away' : 'paused — you changed tabs'); return; }
185
+ const dt = G.last ? t - G.last : 0;
186
+ G.last = t;
187
+ const g = G.game;
188
+
189
+ if (G.held.left) nudge(g, -KEY_NUDGE * Math.min(3, dt / 16));
190
+ if (G.held.right) nudge(g, KEY_NUDGE * Math.min(3, dt / 16));
191
+ // IT FIRES ITSELF while armed (operator, 2026-09-12: "laser doesn't auto fire when in laser
192
+ // mode"). Holding a key to use a power-up you already caught is friction: the capsule IS the
193
+ // decision, and `fire()` has its own cooldown, so this is a rate, not a stream. The keys still
194
+ // work and simply arrive inside the same cooldown.
195
+ if (g.laser && fire(g).length) sound.play('laser');
196
+
197
+ const r = step(g, dt);
198
+ for (const h of r.hits) {
199
+ if (h.kind === 'brick') {
200
+ sound.play(h.points >= 100 ? 'brickhard' : 'brick');
201
+ G.debris.push({ id: G.nextDebris++, x: h.x, y: h.y, color: h.color,
202
+ dx: (h.x - (COLS - 1) / 2) * 0.10, t0: t });
203
+ } else if (h.kind === 'silver') sound.play('silver');
204
+ else if (h.kind === 'gold') sound.play('gold');
205
+ else if (h.kind === 'capsule') { sound.play('capsule'); G.h?.toast?.(`${CAPSULE_LETTER[h.capsule]} — ${h.capsule}`); }
206
+ else if (h.kind === 'enemy') sound.play('enemy');
207
+ else if (h.kind === 'paddle') sound.play('paddle');
208
+ else if (h.kind === 'wall') sound.play('wall');
209
+ else if (h.kind === 'life') sound.play('life');
210
+ }
211
+ if (G.debris.length) G.debris = G.debris.filter((d) => t - d.t0 < DEBRIS_MS);
212
+ if (g.over) { gameOver(); return; }
213
+ if (r.cleared) { levelDone(); return; }
214
+ draw(t);
215
+ G.raf = requestAnimationFrame(frame);
216
+ }
217
+
218
+ function start() {
219
+ const b = blockanoidOptions(loadSettings());
220
+ G.game = newGame(1, { capsules: b.capsules, enemies: b.enemies });
221
+ G.debris = [];
222
+ G.running = true; G.paused = false; G.last = 0;
223
+ overlay(null);
224
+ drawScores();
225
+ sound.unlock();
226
+ drawSwitches();
227
+ if (!G.raf) G.raf = requestAnimationFrame(frame);
228
+ }
229
+
230
+ function pause(why) {
231
+ if (!G.running || G.paused) return;
232
+ G.paused = true; G.why = why;
233
+ overlay(why, 'Vaus is holding', 'resume', true);
234
+ G.h?.toast?.(why);
235
+ }
236
+
237
+ function resume() {
238
+ if (!G.running) { start(); return; }
239
+ if (!G.paused) return;
240
+ G.paused = false; G.last = 0;
241
+ overlay(null);
242
+ sound.unlock();
243
+ if (!G.raf) G.raf = requestAnimationFrame(frame);
244
+ }
245
+
246
+ function levelDone() {
247
+ const g = G.game;
248
+ sound.play('levelup');
249
+ advance(g);
250
+ draw();
251
+ overlay(`wall cleared — level ${g.level}`,
252
+ `${g.score.toLocaleString()} points. A new wall, and a faster ball. Click the court or press space to serve.`, 'serve', true);
253
+ G.paused = true;
254
+ G.h?.toast?.(`level ${g.level}`);
255
+ }
256
+
257
+ function gameOver() {
258
+ const g = G.game;
259
+ G.running = false; G.paused = false;
260
+ sound.play('over');
261
+ draw();
262
+ const list = loadScores();
263
+ const rank = rankOf(g.score, list);
264
+ const at = Date.now();
265
+ if (rank) recordScore({ score: g.score, level: g.level, at });
266
+ drawScores(rank ? at : null);
267
+ overlay('out of balls',
268
+ `${g.score.toLocaleString()} points at level ${g.level}${rank ? ` — #${rank} on this browser` : ''}.`,
269
+ 'play again', true);
270
+ }
271
+
272
+ // ------------------------------------------------------------------ input
273
+ function serve() {
274
+ const g = G.game;
275
+ if (!g || g.over) return;
276
+ if (G.paused) { G.paused = false; overlay(null); G.last = 0; if (!G.raf) G.raf = requestAnimationFrame(frame); }
277
+ launch(g, (Math.random() - 0.5) * 0.7);
278
+ }
279
+
280
+ function onKey(e) {
281
+ if (G.state?.page !== 'blockanoid') return;
282
+ const tag = e.target?.tagName;
283
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
284
+ if (e.key === 'Enter') { e.preventDefault(); resume(); return; }
285
+ if (e.key === 'p' || e.key === 'P' || e.key === 'Escape') { if (G.running) { e.preventDefault(); G.paused ? resume() : pause('paused'); } return; }
286
+ if (!G.running || !G.game || G.game.over) return;
287
+ switch (e.key) {
288
+ case 'ArrowLeft': case 'a': case 'A': G.held.left = true; nudge(G.game, -KEY_NUDGE); break;
289
+ case 'ArrowRight': case 'd': case 'D': G.held.right = true; nudge(G.game, KEY_NUDGE); break;
290
+ case 'ArrowUp': case 'w': case 'W': if (fire(G.game).length) sound.play('laser'); break;
291
+ case ' ': serve(); break;
292
+ default: return;
293
+ }
294
+ e.preventDefault();
295
+ if (G.paused) draw();
296
+ }
297
+ function onKeyUp(e) {
298
+ if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') G.held.left = false;
299
+ if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') G.held.right = false;
300
+ }
301
+
302
+ function onPointer(e) {
303
+ const g = G.game;
304
+ const court = el('baWell');
305
+ if (!g || !court || g.over) return;
306
+ const r = court.getBoundingClientRect();
307
+ if (!r.width) return;
308
+ const f = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
309
+ movePaddle(g, f * COLS);
310
+ if (!G.running || G.paused) draw();
311
+ }
312
+
313
+ function bind() {
314
+ if (G.bound) return;
315
+ G.bound = true;
316
+ document.addEventListener('keydown', onKey);
317
+ document.addEventListener('keyup', onKeyUp);
318
+ document.addEventListener('visibilitychange', () => { if (document.hidden && G.running && !G.paused) pause('paused — you looked away'); });
319
+ el('baResume')?.addEventListener('click', () => resume());
320
+ const court = el('baWell');
321
+ court?.addEventListener('pointermove', onPointer);
322
+ court?.addEventListener('pointerdown', (e) => {
323
+ onPointer(e);
324
+ // the right button fires, the left serves -- so a laser run needs no second hand
325
+ if (e.button === 2 || e.shiftKey) { if (fire(G.game).length) sound.play('laser'); }
326
+ else serve();
327
+ });
328
+ court?.addEventListener('contextmenu', (e) => { if (G.running) e.preventDefault(); });
329
+ for (const [id, key] of SWITCHES) el(id)?.addEventListener('click', () => flip(key));
330
+ }
331
+
332
+ export function renderBlockanoid(s, state, h) {
333
+ G.state = state; G.h = h;
334
+ bind();
335
+ drawSwitches();
336
+ drawSky();
337
+ if (!G.game) {
338
+ overlay('Blockanoid',
339
+ `${layoutFor(1).length} rows of wall, silver that takes two hits and gold that never breaks. Catch a capsule: L laser, E wide, C catch, S slow, D three balls, P a life, B skip. Mouse or ← →, space serves, ↑ fires.`,
340
+ 'play');
341
+ drawStats(); drawScores(); draw();
342
+ return;
343
+ }
344
+ if (G.running && G.paused) overlay(G.why, 'Vaus is holding', 'resume', true);
345
+ if (G.running && !G.paused && !G.raf) { G.last = 0; G.raf = requestAnimationFrame(frame); }
346
+ else draw();
347
+ }
@@ -0,0 +1,347 @@
1
+ // BLOCKOUT, the tab (operator, 2026-09-12: "take the classic Atari Breakout game, and make a clone
2
+ // of it, in another tab, using our engine, with mouse control for moving the pad left and right.
3
+ // Call it 'Blockout' and put it next to the Tetrust Tab. Also support keys for moving left and
4
+ // right").
5
+ //
6
+ // The rules live in breakout.js and know nothing of the screen. This file is the screen: the court
7
+ // is the same board3d the Block space and Tetrust boards use -- same oblique camera, same curved
8
+ // surface, same stones -- and the panel behind it is the same sky. The bricks are one grid cell
9
+ // each, so a brick is literally an engine tile.
10
+ import { board3d } from './details3d.js';
11
+ import {
12
+ newGame, advance, step, movePaddle, nudge, launch, tiles, remaining,
13
+ COLS, ROWS, LIVES,
14
+ } from './breakout.js';
15
+ import { loadSettings, setSetting, blockoutOptions } from './settings.js';
16
+ import * as sound from './tetsound.js';
17
+
18
+ const SCORES_KEY = 'blockyard.blockout.scores';
19
+ const KEEP = 10;
20
+
21
+ // the court: the Block space look on a board as wide as it is tall enough to fall through.
22
+ // `still` so the ball is drawn exactly where the rules put it, every frame.
23
+ const COURT = {
24
+ gridW: COLS, gridH: ROWS,
25
+ oblique: { ox: 0.10, oy: 0.28, headroom: 3, flight: 0 },
26
+ dome: 4,
27
+ light: 'overhead', // the floor rows are where the paddle lives: no shade there
28
+ gridStep: 1,
29
+ space: true,
30
+ background: 'rgba(0,0,0,0)', // the panel's sky shows through
31
+ spaceFloor: 'rgba(0,0,0,0.22)',
32
+ neonCell: 'rgba(60,200,140,0.18)',
33
+ };
34
+ const SKY = { gridW: COLS, gridH: ROWS, oblique: COURT.oblique, dome: 0, space: false, grid: false, background: 'rgba(0,0,0,1)', idleFx: false, shadows: false, still: true, transition: { rise: 0, travel: 1, drop: 0 }, maxDpr: 1 };
35
+
36
+ const KEY_NUDGE = 1.15; // grid units per key press, before the repeat takes over
37
+
38
+ // A BROKEN BRICK LEAVES (operator, 2026-09-12: "have the blocks fly up and off the screen when
39
+ // they are hit, instead of disappearing"). The same launch Tetrust's cleared lines take: slow off
40
+ // the wall, then away, and gone past the top of the canvas rather than fading out over the court.
41
+ const DEBRIS_MS = 900;
42
+ const DEBRIS_RISE = 70; // units of height at the end: well past the top from any row
43
+
44
+ const G = {
45
+ game: null, running: false, paused: false, why: '', raf: null, last: 0, dirty: true,
46
+ bound: false, state: null, h: null, held: { left: false, right: false },
47
+ debris: [], nextDebris: 1, // { id, x, y, color, dx, t0 } bricks on their way out
48
+ };
49
+
50
+ /**
51
+ * The bricks in flight, at `now`. Pure, so the launch can be asserted without a canvas: each rises
52
+ * on its `floor`, drifts to its own side of the court, and holds its colour until it is off screen
53
+ * -- it leaves, it does not dissolve in place.
54
+ */
55
+ export function debrisTiles(debris, now, ms = DEBRIS_MS) {
56
+ const out = [];
57
+ for (const d of debris) {
58
+ const t = Math.min(1, Math.max(0, (now - d.t0) / ms));
59
+ if (t >= 1) continue;
60
+ const e = t * t * (3 - 2 * t) * 0.35 + t * t * 0.65; // eases off the wall, then accelerates
61
+ const k = t < 0.82 ? 1 : 1 - (t - 0.82) / 0.18; // full colour until the last stretch
62
+ out.push({ txid: `k${d.id}`, x: d.x + d.dx * e, y: d.y, s: 1, tall: 1,
63
+ floor: DEBRIS_RISE * e, color: fadeHex(d.color, k) });
64
+ }
65
+ return out;
66
+ }
67
+ function fadeHex(hex, k) {
68
+ const h = hex.replace('#', '');
69
+ const ch = (i) => Math.max(0, Math.min(255, Math.round(parseInt(h.slice(i, i + 2), 16) * k)));
70
+ return `#${[ch(0), ch(2), ch(4)].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
71
+ }
72
+
73
+ function opts(base) {
74
+ const b = blockoutOptions(loadSettings());
75
+ return {
76
+ ...base,
77
+ // the court draws the court: no sky on this canvas (the panel behind it has one), no idle
78
+ // effects, no shadows, and every frame drawn exactly as laid -- a ball that lags the rules is
79
+ // a ball you cannot aim
80
+ space: true, stars: false, galaxy: false, idleFx: false, shadows: false,
81
+ hover: false, // the pointer is holding the bat, not picking out a stone
82
+ // the grid is the operator's now; spread after `base` so it overrides COURT's own neonCell
83
+ ...b.gridOpts, grid: b.grid,
84
+ edges: true, facetPx: Infinity, crownPx: Infinity, sheen: false,
85
+ neon: b.neon, neonSource: b.neonSource, neonColour: b.neonColour, neonBrightness: b.neonBrightness,
86
+ still: true,
87
+ transition: { rise: 0, travel: 1, drop: 0 },
88
+ };
89
+ }
90
+
91
+ // ------------------------------------------------------------------ scores, this browser's
92
+ export function loadScores(storage = globalThis.localStorage) {
93
+ try {
94
+ const raw = storage?.getItem(SCORES_KEY);
95
+ const list = raw ? JSON.parse(raw) : [];
96
+ return Array.isArray(list) ? list.filter((r) => r && Number.isFinite(r.score)).slice(0, KEEP) : [];
97
+ } catch { return []; }
98
+ }
99
+ export function recordScore(entry, storage = globalThis.localStorage) {
100
+ const list = [...loadScores(storage), { score: entry.score, level: entry.level, at: entry.at ?? Date.now() }]
101
+ .sort((a, b) => b.score - a.score || b.level - a.level)
102
+ .slice(0, KEEP);
103
+ try { storage?.setItem(SCORES_KEY, JSON.stringify(list)); } catch { /* private mode, quota */ }
104
+ return list;
105
+ }
106
+ /** Where a score would land, 1-based, or null if it would not make the table. */
107
+ export function rankOf(score, list) {
108
+ const i = list.findIndex((r) => score > r.score);
109
+ if (i >= 0) return i + 1;
110
+ return list.length < KEEP ? list.length + 1 : null;
111
+ }
112
+
113
+ // ------------------------------------------------------------------ the screen
114
+ const el = (id) => document.getElementById(id);
115
+
116
+ function overlay(msg, sub, button, dim = false) {
117
+ const ov = el('boOver'), m = el('boMsg'), s = el('boSub'), b = el('boResume');
118
+ if (!ov) return;
119
+ ov.classList.toggle('hidden', !msg);
120
+ ov.classList.toggle('dim', dim);
121
+ if (m) m.textContent = msg ?? '';
122
+ if (s) s.textContent = sub ?? '';
123
+ if (b) b.textContent = button ?? 'play';
124
+ }
125
+
126
+ function drawStats() {
127
+ const g = G.game, box = el('boStats');
128
+ if (!box) return;
129
+ const rows = g
130
+ ? [['score', g.score.toLocaleString()], ['lives', '●'.repeat(Math.max(0, g.lives))], ['level', String(g.level)],
131
+ ['bricks', String(g.bricks.length)], ['wall left', `${Math.round(remaining(g) * 100)}%`]]
132
+ : [['score', '–'], ['lives', '●'.repeat(LIVES)], ['level', '–']];
133
+ const html = rows.map(([k, v]) => `<i>${k}</i><b>${v}</b>`).join('');
134
+ if (box.innerHTML !== html) box.innerHTML = html;
135
+ }
136
+
137
+ function drawScores(highlightAt = null) {
138
+ const t = el('boScores');
139
+ if (!t) return;
140
+ const list = loadScores();
141
+ const html = list.length
142
+ ? `<tr><th>#</th><th>score</th><th>lvl</th><th>when</th></tr>` + list.map((r, i) =>
143
+ `<tr${r.at === highlightAt ? ' class="now"' : ''}><td>${i + 1}</td><td>${r.score.toLocaleString()}</td><td>${r.level}</td><td>${new Date(r.at).toISOString().slice(0, 10)}</td></tr>`).join('')
144
+ : `<tr><td class="faint">no bricks broken yet</td></tr>`;
145
+ if (t.innerHTML !== html) t.innerHTML = html;
146
+ }
147
+
148
+ const SWITCHES = [['boStars', 'stars'], ['boGalaxy', 'galaxy'], ['boNeon', 'neon'], ['boSfx', 'sfx']];
149
+ function drawSwitches() {
150
+ const b = blockoutOptions(loadSettings());
151
+ for (const [id, key] of SWITCHES) {
152
+ const btn = el(id);
153
+ if (!btn) continue;
154
+ btn.classList.toggle('on', !!b[key]);
155
+ btn.setAttribute('aria-pressed', b[key] ? 'true' : 'false');
156
+ }
157
+ sound.setSfx(b.sfx);
158
+ }
159
+ function flip(key) {
160
+ const cur = blockoutOptions(loadSettings())[key];
161
+ setSetting(loadSettings(), `blockout.${key}`, !cur);
162
+ sound.unlock();
163
+ drawSwitches();
164
+ if (key === 'stars' || key === 'galaxy') drawSky();
165
+ G.dirty = true;
166
+ draw();
167
+ }
168
+
169
+ function drawSky() {
170
+ const sky = el('boSky');
171
+ if (!sky) return;
172
+ const b = blockoutOptions(loadSettings());
173
+ board3d(sky, [], {
174
+ ...SKY,
175
+ stars: b.stars, galaxy: b.stars && b.galaxy, galaxyAt: b.galaxyAt,
176
+ starDensity: b.starDensity, starBrightness: b.starBrightness,
177
+ nebulae: b.nebulae, galaxies: b.galaxies, dust: b.dust, clusters: b.clusters,
178
+ starColours: b.starColours, starGlints: b.starGlints,
179
+ });
180
+ }
181
+
182
+ function draw(now = performance.now()) {
183
+ const g = G.game;
184
+ const court = el('boWell');
185
+ el('boWellWrap')?.classList.toggle('idle', !g);
186
+ // only with a game on: `.tetwell.idle` is visibility:hidden, so an idle court cannot be seen and
187
+ // painting it would be a board3d pass thrown away
188
+ if (court && g) board3d(court, [...tiles(g), ...debrisTiles(G.debris, now)], opts(COURT));
189
+ drawStats();
190
+ }
191
+
192
+ // ------------------------------------------------------------------ the loop
193
+ function frame(t) {
194
+ G.raf = null;
195
+ if (!G.running) return;
196
+ if (document.hidden || G.state?.page !== 'blockout') { pause(document.hidden ? 'paused — you looked away' : 'paused — you changed tabs'); return; }
197
+ const dt = G.last ? t - G.last : 0;
198
+ G.last = t;
199
+ const g = G.game;
200
+
201
+ if (G.held.left) nudge(g, -KEY_NUDGE * Math.min(3, dt / 16));
202
+ if (G.held.right) nudge(g, KEY_NUDGE * Math.min(3, dt / 16));
203
+
204
+ const r = step(g, dt);
205
+ for (const h of r.hits) {
206
+ if (h.kind === 'brick') {
207
+ sound.play(h.points >= 5 ? 'brickhard' : 'brick');
208
+ // it leaves the court rather than vanishing: outward from the middle, and up
209
+ G.debris.push({ id: G.nextDebris++, x: h.x, y: h.y, color: h.color,
210
+ dx: (h.x - (COLS - 1) / 2) * 0.10, t0: t });
211
+ } else if (h.kind === 'paddle') sound.play('paddle');
212
+ else if (h.kind === 'wall') sound.play('wall');
213
+ else if (h.kind === 'life') sound.play('life');
214
+ }
215
+ if (G.debris.length) G.debris = G.debris.filter((d) => t - d.t0 < DEBRIS_MS);
216
+ if (g.over) { gameOver(); return; }
217
+ if (r.cleared) { levelDone(); return; }
218
+ draw(t);
219
+ G.raf = requestAnimationFrame(frame);
220
+ }
221
+
222
+ function start() {
223
+ G.game = newGame();
224
+ G.debris = [];
225
+ G.running = true; G.paused = false; G.last = 0; G.dirty = true;
226
+ overlay(null);
227
+ drawScores();
228
+ sound.unlock();
229
+ drawSwitches();
230
+ if (!G.raf) G.raf = requestAnimationFrame(frame);
231
+ }
232
+
233
+ function pause(why) {
234
+ if (!G.running || G.paused) return;
235
+ G.paused = true; G.why = why;
236
+ overlay(why, 'the ball is holding', 'resume', true);
237
+ G.h?.toast?.(why);
238
+ }
239
+
240
+ function resume() {
241
+ if (!G.running) { start(); return; }
242
+ if (!G.paused) return;
243
+ G.paused = false; G.last = 0;
244
+ overlay(null);
245
+ sound.unlock();
246
+ if (!G.raf) G.raf = requestAnimationFrame(frame);
247
+ }
248
+
249
+ function levelDone() {
250
+ const g = G.game;
251
+ sound.play('levelup');
252
+ advance(g);
253
+ draw();
254
+ overlay(`wall cleared — level ${g.level}`, `${g.score.toLocaleString()} points. The next wall is faster. Click the court or press space to serve.`, 'serve', true);
255
+ G.paused = true;
256
+ G.h?.toast?.(`level ${g.level}`);
257
+ }
258
+
259
+ function gameOver() {
260
+ const g = G.game;
261
+ G.running = false; G.paused = false;
262
+ sound.play('over');
263
+ draw();
264
+ const list = loadScores();
265
+ const rank = rankOf(g.score, list);
266
+ const at = Date.now();
267
+ if (rank) recordScore({ score: g.score, level: g.level, at });
268
+ drawScores(rank ? at : null);
269
+ overlay('out of balls',
270
+ `${g.score.toLocaleString()} points at level ${g.level}${rank ? ` — #${rank} on this browser` : ''}.`,
271
+ 'play again', true);
272
+ }
273
+
274
+ // ------------------------------------------------------------------ input
275
+ function serve() {
276
+ const g = G.game;
277
+ if (!g || g.over) return;
278
+ if (G.paused) { G.paused = false; overlay(null); G.last = 0; if (!G.raf) G.raf = requestAnimationFrame(frame); }
279
+ // a little spread so a served ball is never perfectly predictable; the rules stay pure and take
280
+ // the angle as an argument
281
+ launch(g, (Math.random() - 0.5) * 0.7);
282
+ }
283
+
284
+ function onKey(e) {
285
+ if (G.state?.page !== 'blockout') return;
286
+ const tag = e.target?.tagName;
287
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
288
+ if (e.key === 'Enter') { e.preventDefault(); resume(); return; }
289
+ if (e.key === 'p' || e.key === 'P' || e.key === 'Escape') { if (G.running) { e.preventDefault(); G.paused ? resume() : pause('paused'); } return; }
290
+ if (!G.running || !G.game || G.game.over) return;
291
+ switch (e.key) {
292
+ case 'ArrowLeft': case 'a': case 'A': G.held.left = true; nudge(G.game, -KEY_NUDGE); break;
293
+ case 'ArrowRight': case 'd': case 'D': G.held.right = true; nudge(G.game, KEY_NUDGE); break;
294
+ case ' ': serve(); break;
295
+ default: return;
296
+ }
297
+ e.preventDefault();
298
+ if (G.paused) draw();
299
+ }
300
+ function onKeyUp(e) {
301
+ if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') G.held.left = false;
302
+ if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') G.held.right = false;
303
+ }
304
+
305
+ // THE MOUSE (operator: "mouse control for moving the pad left and right"). The pointer's position
306
+ // across the canvas maps straight to the paddle's centre across the board. The board is drawn under
307
+ // an oblique camera, so a pixel is not exactly a grid unit -- but a paddle wants to be WHERE THE
308
+ // POINTER IS, not where a projection says the pointer would be on the floor, and this is the
309
+ // mapping that feels direct.
310
+ function onPointer(e) {
311
+ const g = G.game;
312
+ const court = el('boWell');
313
+ if (!g || !court || g.over) return;
314
+ const r = court.getBoundingClientRect();
315
+ if (!r.width) return;
316
+ const f = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
317
+ movePaddle(g, f * COLS);
318
+ if (!G.running || G.paused) draw();
319
+ }
320
+
321
+ function bind() {
322
+ if (G.bound) return;
323
+ G.bound = true;
324
+ document.addEventListener('keydown', onKey);
325
+ document.addEventListener('keyup', onKeyUp);
326
+ document.addEventListener('visibilitychange', () => { if (document.hidden && G.running && !G.paused) pause('paused — you looked away'); });
327
+ el('boResume')?.addEventListener('click', () => resume());
328
+ const court = el('boWell');
329
+ court?.addEventListener('pointermove', onPointer);
330
+ court?.addEventListener('pointerdown', (e) => { onPointer(e); serve(); });
331
+ for (const [id, key] of SWITCHES) el(id)?.addEventListener('click', () => flip(key));
332
+ }
333
+
334
+ export function renderBlockout(s, state, h) {
335
+ G.state = state; G.h = h;
336
+ bind();
337
+ drawSwitches();
338
+ drawSky();
339
+ if (!G.game) {
340
+ overlay('Blockout', 'the wall is the block space, and you are holding the bat. Move with the mouse or ← →, serve with the court or space.', 'play');
341
+ drawStats(); drawScores(); draw();
342
+ return;
343
+ }
344
+ if (G.running && G.paused) overlay(G.why, 'the ball is holding', 'resume', true);
345
+ if (G.running && !G.paused && !G.raf) { G.last = 0; G.raf = requestAnimationFrame(frame); }
346
+ else draw();
347
+ }