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.
- package/CHANGELOG.md +929 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +191 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +41 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1577 -0
- package/docs/ARCHITECTURE.md +1394 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +847 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +205 -0
- package/docs/INSTALL.md +547 -0
- package/docs/MEASUREMENTS.md +1401 -0
- package/docs/RULES.md +681 -0
- package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
- package/docs/SECURITY-AUDIT.md +258 -0
- package/docs/SECURITY.md +212 -0
- package/docs/TROUBLESHOOTING.md +332 -0
- package/docs/USER-GUIDE.md +1262 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +2009 -0
- package/public/donate-qr.png +0 -0
- package/public/index.html +1085 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +1141 -0
- package/public/js/app.js +1386 -0
- package/public/js/arkanoid.js +806 -0
- package/public/js/blockanoid.js +347 -0
- package/public/js/blockout.js +347 -0
- package/public/js/blockpack.js +428 -0
- package/public/js/blockscene3d.js +2830 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +315 -0
- package/public/js/details3d.js +4342 -0
- package/public/js/doom.js +31 -0
- package/public/js/dosaudio.js +48 -0
- package/public/js/dosgame.js +389 -0
- package/public/js/dosio.js +186 -0
- package/public/js/dospc.js +1353 -0
- package/public/js/dosworker.js +196 -0
- package/public/js/explorer.js +405 -0
- package/public/js/feepalette.js +149 -0
- package/public/js/fmt.js +162 -0
- package/public/js/goggles.js +886 -0
- package/public/js/kiosk.js +41 -0
- package/public/js/login.js +88 -0
- package/public/js/markets.js +395 -0
- package/public/js/mining.js +1416 -0
- package/public/js/panels.js +970 -0
- package/public/js/pricechart.js +189 -0
- package/public/js/quake.js +20 -0
- package/public/js/settings.js +1096 -0
- package/public/js/soundcard.js +459 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -0
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/public/login.html +33 -0
- package/scripts/blockfile-measure.js +156 -0
- package/scripts/browser-check.mjs +286 -0
- package/scripts/check.js +173 -0
- package/scripts/decode-check.js +81 -0
- package/scripts/doc-counts.js +109 -0
- package/scripts/donate-qr.py +23 -0
- package/scripts/dos-bench.js +56 -0
- package/scripts/fake-node.js +534 -0
- package/scripts/index-bench.js +216 -0
- package/scripts/index-benchmark.js +117 -0
- package/scripts/index-build.js +40 -0
- package/scripts/live-render-check.mjs +89 -0
- package/scripts/manage-users.js +132 -0
- package/scripts/motion-check.mjs +138 -0
- package/scripts/pool-map.js +157 -0
- package/scripts/setup.js +432 -0
- package/scripts/shots.mjs +278 -0
- package/scripts/smoke.sh +327 -0
- package/scripts/tls.js +31 -0
- package/scripts/ui.js +174 -0
- package/server/auth/sessions.js +221 -0
- package/server/auth/users.js +243 -0
- package/server/chain/blockfile.js +234 -0
- package/server/chain/index/build.js +210 -0
- package/server/chain/index/heights.js +36 -0
- package/server/chain/index/live.js +276 -0
- package/server/chain/index/rows.js +145 -0
- package/server/chain/index/store.js +154 -0
- package/server/chain/index/worker.js +109 -0
- package/server/chain/tx.js +310 -0
- package/server/collect/gbt.js +229 -0
- package/server/collect/logparse.js +765 -0
- package/server/collect/logtail.js +189 -0
- package/server/collect/markets.js +333 -0
- package/server/collect/mining.js +333 -0
- package/server/collect/monitor.js +2545 -0
- package/server/collect/network.js +295 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +644 -0
- package/server/http/api.js +1319 -0
- package/server/http/explorer.js +418 -0
- package/server/http/games.js +77 -0
- package/server/http/server.js +420 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +673 -0
- package/server/netinfo.js +253 -0
- package/server/rpc/allowlist.js +130 -0
- package/server/rpc/client.js +414 -0
- package/server/store/audit.js +148 -0
- package/server/store/history.js +220 -0
- package/server/store/ledger.js +290 -0
- package/server/store/ring.js +173 -0
- package/server/tls/selfsigned.js +160 -0
- package/server/util/fmt.js +29 -0
- package/systemd/blockyard.service +102 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// TETRUST, the tab (operator, 2026-09-12: "Add a new tab called 'Tetrust' - I want it to be a
|
|
2
|
+
// playable tetris game using our 3d Engine. Pauses when you tab away, and has a Resume play
|
|
3
|
+
// button. Make the Tetris game absolutely beautiful leveraging our framework ... Add score. Add a
|
|
4
|
+
// high score table").
|
|
5
|
+
//
|
|
6
|
+
// The rules live in tetris.js and know nothing of the screen. This file is the screen: the well
|
|
7
|
+
// is the same board3d the Markets and Block space boards use, with the same oblique camera and
|
|
8
|
+
// the same domed board ("Make it spherical like our default display"), and the panel behind it is
|
|
9
|
+
// the same sky (drawSky). The game inherits the framework rather than imitating it.
|
|
10
|
+
import { board3d } from './details3d.js';
|
|
11
|
+
import { newGame, tick, move, rotate, softDrop, hardDrop, tiles, previewTiles, peekNext, gravityMs, PIECES, COLS, ROWS } from './tetris.js';
|
|
12
|
+
import { loadSettings, setSetting, tetrustOptions } from './settings.js';
|
|
13
|
+
import * as sound from './tetsound.js';
|
|
14
|
+
|
|
15
|
+
const SCORES_KEY = 'blockyard.tetrust.scores';
|
|
16
|
+
const KEEP = 10;
|
|
17
|
+
|
|
18
|
+
// the well's camera: the Block space look (an oblique board, cubes with height, the sphere) on a
|
|
19
|
+
// tall well. `still` so a key press lands where you pressed it; no idle effects while playing.
|
|
20
|
+
const WELL = {
|
|
21
|
+
gridW: COLS, gridH: ROWS,
|
|
22
|
+
oblique: { ox: 0.10, oy: 0.30, headroom: 3, flight: 0 },
|
|
23
|
+
dome: 5, // the default board's sphere (operator: "spherical like our default display")
|
|
24
|
+
light: 'overhead', // the lamp straight above, so the bottom rows are not in the dome's shade
|
|
25
|
+
gridStep: 1,
|
|
26
|
+
space: true,
|
|
27
|
+
background: 'rgba(0,0,0,0)', // clear: the panel's sky shows through ("Fill the entire panel with black")
|
|
28
|
+
spaceFloor: 'rgba(0,0,0,0.22)', // and through the board itself ("The playfield needs to be transparent to show the stars and galaxy behind it")
|
|
29
|
+
neonCell: 'rgba(60,200,140,0.18)',
|
|
30
|
+
};
|
|
31
|
+
const PREVIEW = { gridW: 4, gridH: 4, oblique: { ox: 0.10, oy: 0.30, headroom: 2, flight: 0 }, dome: 0, gridStep: 1, space: false, background: 'rgba(0,0,0,1)', grid: false };
|
|
32
|
+
|
|
33
|
+
// THE SKY (operator: "Have this entire panel filled black and rendering the spiral galaxy for this
|
|
34
|
+
// display. Have the text floating over the spiral galaxy, and then the playboard gets drawn when
|
|
35
|
+
// you hit play"): the panel's own canvas behind the title and the well, an empty board with no
|
|
36
|
+
// floor and no grid -- so nothing but black and the star field -- carrying the game's switches
|
|
37
|
+
// for the sky and the galaxy and the display's choices for what the sky is made of. Its own
|
|
38
|
+
// canvas, so the game's every-key-press repaint of the well never touches a star.
|
|
39
|
+
// `maxDpr: 1`: the sky is drawn at one device pixel per CSS pixel whatever the screen has. The
|
|
40
|
+
// star count follows the pixel count, so on a 2x screen that is a quarter of the stars a frame --
|
|
41
|
+
// and a panel-sized galaxy at 2x made the game stutter ("The galaxy rendering is really slow in
|
|
42
|
+
// teh game display. smooth that shit out"). Stars are points; nobody can tell.
|
|
43
|
+
// `still` here means only that the (nonexistent) tiles never fly: render3d keeps the loop alive
|
|
44
|
+
// for the stars themselves, which is what turns the galaxy.
|
|
45
|
+
const SKY = { gridW: COLS, gridH: ROWS, oblique: { ox: 0.10, oy: 0.30, headroom: 3, flight: 0 }, 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 };
|
|
46
|
+
|
|
47
|
+
// THE DRIFT (operator: "have the block pieces drift up and away and completing lines", then
|
|
48
|
+
// "have teh blocks fly up off the screen entirely. Not dissapear just above the playfield"): a
|
|
49
|
+
// cleared line's cells launch off the board -- gathering speed, full colour all the way -- and
|
|
50
|
+
// leave through the top of the canvas
|
|
51
|
+
const DRIFT_MS = 1100;
|
|
52
|
+
const DRIFT_RISE = 80; // units of height at the end: well past the canvas top from any row
|
|
53
|
+
|
|
54
|
+
const G = {
|
|
55
|
+
game: null, running: false, paused: false, why: '', raf: null, last: 0, acc: 0, dirty: true, bound: false,
|
|
56
|
+
state: null, h: null, nextKind: undefined,
|
|
57
|
+
drift: [], // { id, x, y, color, dx, t0 } cells on their way up
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function opts(base) {
|
|
61
|
+
// FAST AND PLAIN (operator, 2026-09-12: "the movement is way too slow. We need to stop all
|
|
62
|
+
// effects, and just treat the blocks differently ... Smaller, faster, playable! No effects for
|
|
63
|
+
// in-motion blocks"). The first cut inherited every display setting, and a forty-thousand-star
|
|
64
|
+
// galaxy behind a 2.4-megapixel well is not a game. The well draws the well and -- only when
|
|
65
|
+
// the game's own switch says so -- the sky behind it: no idle effects, no shadows, flat cubes
|
|
66
|
+
// with their edges, no finishes on the pieces, and every move lands at once.
|
|
67
|
+
const t = tetrustOptions(loadSettings());
|
|
68
|
+
return {
|
|
69
|
+
...base,
|
|
70
|
+
// `space: true` is the plain translucent floor (no deck texture, no dots). The first cut had
|
|
71
|
+
// space off and got the textured deck back, and its per-cell neon glow turned the well into a
|
|
72
|
+
// loud green lattice: the glow and the halo are silenced here, and the grid is a quiet line.
|
|
73
|
+
space: true, idleFx: false, shadows: false,
|
|
74
|
+
hover: false, // a playfield does not light up under the pointer
|
|
75
|
+
// no sky on the well itself: the sky is the panel's canvas behind it (drawSky)
|
|
76
|
+
stars: false, galaxy: false,
|
|
77
|
+
// the grid is the operator's now (settings tetrust.grid and its colour and intensity rows).
|
|
78
|
+
// The glow and the halo stay silenced: a lit lattice under the stack is noise, not a board.
|
|
79
|
+
...t.gridOpts, grid: t.grid,
|
|
80
|
+
edges: true, facetPx: Infinity, crownPx: Infinity, sheen: false,
|
|
81
|
+
// neon on the pieces is the game's own switch (settings tetrust.neon and its colour rows)
|
|
82
|
+
neon: t.neon, neonSource: t.neonSource, neonColour: t.neonColour, neonBrightness: t.neonBrightness,
|
|
83
|
+
wireWidth: t.ghostWidth,
|
|
84
|
+
// STILL: drawn as laid, no choreography at all. A transition of zero was not enough -- the
|
|
85
|
+
// planner's per-tile stagger (seconds between one cube's drop and the next) still applied,
|
|
86
|
+
// and a piece's four cells came down one after another instead of as one shape.
|
|
87
|
+
still: true,
|
|
88
|
+
transition: { rise: 0, travel: 1, drop: 0 },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ------------------------------------------------------------------ scores, this browser's
|
|
93
|
+
export function loadScores(storage = globalThis.localStorage) {
|
|
94
|
+
try {
|
|
95
|
+
const raw = storage?.getItem(SCORES_KEY);
|
|
96
|
+
const list = raw ? JSON.parse(raw) : [];
|
|
97
|
+
return Array.isArray(list) ? list.filter((r) => r && Number.isFinite(r.score)).slice(0, KEEP) : [];
|
|
98
|
+
} catch { return []; }
|
|
99
|
+
}
|
|
100
|
+
export function recordScore(entry, storage = globalThis.localStorage) {
|
|
101
|
+
const list = [...loadScores(storage), { score: entry.score, lines: entry.lines, level: entry.level, at: entry.at ?? Date.now() }]
|
|
102
|
+
.sort((a, b) => b.score - a.score || b.lines - a.lines)
|
|
103
|
+
.slice(0, KEEP);
|
|
104
|
+
try { storage?.setItem(SCORES_KEY, JSON.stringify(list)); } catch { /* private mode, quota */ }
|
|
105
|
+
return list;
|
|
106
|
+
}
|
|
107
|
+
/** Where a score would land in the table, 1-based, or null if it would not make it. */
|
|
108
|
+
export function rankOf(score, list) {
|
|
109
|
+
const i = list.findIndex((r) => score > r.score);
|
|
110
|
+
if (i >= 0) return i + 1;
|
|
111
|
+
return list.length < KEEP ? list.length + 1 : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ------------------------------------------------------------------ the drift
|
|
115
|
+
/**
|
|
116
|
+
* The tiles of the cells on their way up, at `now`: each rises on an ease-out, slides a little
|
|
117
|
+
* to its own side, and darkens to the black behind it. The engine's still plan pins z and alpha
|
|
118
|
+
* (a still board is a still board), so the rise is the tile's FLOOR and the fade is its colour.
|
|
119
|
+
*/
|
|
120
|
+
export function driftTiles(drift, now, ms = DRIFT_MS) {
|
|
121
|
+
const out = [];
|
|
122
|
+
for (const d of drift) {
|
|
123
|
+
const t = Math.min(1, Math.max(0, (now - d.t0) / ms));
|
|
124
|
+
if (t >= 1) continue;
|
|
125
|
+
const e = t * t * (3 - 2 * t) * 0.35 + t * t * 0.65; // a launch: slow off the board, then away
|
|
126
|
+
const k = t < 0.85 ? 1 : 1 - (t - 0.85) / 0.15; // full colour until the last stretch, off screen by then
|
|
127
|
+
out.push({ txid: `d${d.id}`, x: d.x + d.dx * e, y: d.y, s: 1, tall: 1, floor: DRIFT_RISE * e, color: fade(d.color, k) });
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
function fade(hex, k) {
|
|
132
|
+
const h = hex.replace('#', '');
|
|
133
|
+
const ch = (i) => Math.round(parseInt(h.slice(i, i + 2), 16) * k);
|
|
134
|
+
return `#${[ch(0), ch(2), ch(4)].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join('')}`;
|
|
135
|
+
}
|
|
136
|
+
function startDrift(cells, now) {
|
|
137
|
+
for (const c of cells) G.drift.push({ id: c.id, x: c.x, y: c.y, color: PIECES[c.k].color, dx: (c.x - (COLS - 1) / 2) * 0.12, t0: now });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ------------------------------------------------------------------ the screen
|
|
141
|
+
const el = (id) => document.getElementById(id);
|
|
142
|
+
|
|
143
|
+
function overlay(msg, sub, button, dim = false) {
|
|
144
|
+
const ov = el('tetOver'), m = el('tetMsg'), s = el('tetSub'), b = el('tetResume');
|
|
145
|
+
if (!ov) return;
|
|
146
|
+
ov.classList.toggle('hidden', !msg);
|
|
147
|
+
ov.classList.toggle('dim', dim); // clear over the sky; dimmed over a held or finished board
|
|
148
|
+
if (m) m.textContent = msg ?? '';
|
|
149
|
+
if (s) s.textContent = sub ?? '';
|
|
150
|
+
if (b) b.textContent = button ?? 'play';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function drawStats() {
|
|
154
|
+
const g = G.game, box = el('tetStats');
|
|
155
|
+
if (!box) return;
|
|
156
|
+
const rows = g
|
|
157
|
+
? [['score', g.score.toLocaleString()], ['lines', String(g.lines)], ['level', String(g.level)], ['pieces', String(g.pieces)], ['gravity', `${gravityMs(g.level)} ms`]]
|
|
158
|
+
: [['score', '–'], ['lines', '–'], ['level', '–']];
|
|
159
|
+
const html = rows.map(([k, v]) => `<i>${k}</i><b>${v}</b>`).join('');
|
|
160
|
+
if (box.innerHTML !== html) box.innerHTML = html;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function drawScores(highlightAt = null) {
|
|
164
|
+
const t = el('tetScores');
|
|
165
|
+
if (!t) return;
|
|
166
|
+
const list = loadScores();
|
|
167
|
+
const html = list.length
|
|
168
|
+
? `<tr><th>#</th><th>score</th><th>lines</th><th>lvl</th><th>when</th></tr>` + list.map((r, i) =>
|
|
169
|
+
`<tr${r.at === highlightAt ? ' class="now"' : ''}><td>${i + 1}</td><td>${r.score.toLocaleString()}</td><td>${r.lines}</td><td>${r.level}</td><td>${new Date(r.at).toISOString().slice(0, 10)}</td></tr>`).join('')
|
|
170
|
+
: `<tr><td class="faint">no verified blocks yet — clear a line</td></tr>`;
|
|
171
|
+
if (t.innerHTML !== html) t.innerHTML = html;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// THE GAME'S OWN SWITCHES (operator: "Toggle for each in the game display"): three buttons on the
|
|
175
|
+
// HUD, the same settings the Display panel shows, so a flip in either place is a flip in both
|
|
176
|
+
const SWITCHES = [['tetStars', 'stars'], ['tetGalaxy', 'galaxy'], ['tetMusic', 'music'], ['tetSfx', 'sfx']];
|
|
177
|
+
function drawSwitches() {
|
|
178
|
+
const t = tetrustOptions(loadSettings());
|
|
179
|
+
for (const [id, key] of SWITCHES) {
|
|
180
|
+
const b = el(id);
|
|
181
|
+
if (!b) continue;
|
|
182
|
+
b.classList.toggle('on', !!t[key]);
|
|
183
|
+
b.setAttribute('aria-pressed', t[key] ? 'true' : 'false');
|
|
184
|
+
}
|
|
185
|
+
applySound(t);
|
|
186
|
+
}
|
|
187
|
+
function applySound(t) {
|
|
188
|
+
sound.setSfx(t.sfx);
|
|
189
|
+
// the tune plays while the game runs; a pause holds it; the switch off stops it
|
|
190
|
+
sound.setMusic(t.music && G.running && !G.paused);
|
|
191
|
+
}
|
|
192
|
+
function flip(key) {
|
|
193
|
+
const cur = tetrustOptions(loadSettings())[key];
|
|
194
|
+
setSetting(loadSettings(), `tetrust.${key}`, !cur);
|
|
195
|
+
sound.unlock();
|
|
196
|
+
drawSwitches();
|
|
197
|
+
if (key === 'stars' || key === 'galaxy') drawSky();
|
|
198
|
+
G.dirty = true;
|
|
199
|
+
if (!G.running || G.paused) draw();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function drawSky() {
|
|
203
|
+
const sky = el('tetSky');
|
|
204
|
+
if (!sky) return;
|
|
205
|
+
const t = tetrustOptions(loadSettings());
|
|
206
|
+
board3d(sky, [], {
|
|
207
|
+
...SKY,
|
|
208
|
+
stars: t.stars, galaxy: t.stars && t.galaxy, galaxyAt: t.galaxyAt,
|
|
209
|
+
starDensity: t.starDensity, starBrightness: t.starBrightness,
|
|
210
|
+
nebulae: t.nebulae, galaxies: t.galaxies, dust: t.dust, clusters: t.clusters, starColours: t.starColours, starGlints: t.starGlints,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function draw(now = performance.now()) {
|
|
215
|
+
const g = G.game;
|
|
216
|
+
const well = el('tetWell');
|
|
217
|
+
el('tetWellWrap')?.classList.toggle('idle', !g);
|
|
218
|
+
// the board is drawn when you hit play: `.tetwell.idle` is visibility:hidden, so an idle well is
|
|
219
|
+
// off screen and painting it would be a board3d pass for nothing
|
|
220
|
+
if (well && g) board3d(well, [...tiles(g, tetrustOptions(loadSettings()).ghostColour), ...driftTiles(G.drift, now)], opts(WELL));
|
|
221
|
+
// the preview only when the next piece changes: it is a second board, and redrawing it on
|
|
222
|
+
// every key press was paying for two scenes per move
|
|
223
|
+
const nk = g ? peekNext(g) : null;
|
|
224
|
+
if (nk !== G.nextKind) {
|
|
225
|
+
G.nextKind = nk;
|
|
226
|
+
const nx = el('tetNext');
|
|
227
|
+
if (nx) board3d(nx, previewTiles(nk), opts({ ...PREVIEW, stars: false }));
|
|
228
|
+
}
|
|
229
|
+
drawStats();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ------------------------------------------------------------------ the loop
|
|
233
|
+
function frame(t) {
|
|
234
|
+
G.raf = null;
|
|
235
|
+
if (!G.running) return;
|
|
236
|
+
// PAUSE WHEN YOU LOOK AWAY: another browser tab, or another tab of this app. A game that keeps
|
|
237
|
+
// falling while you read the Mempool page is a game you come back to lost.
|
|
238
|
+
if (document.hidden || G.state?.page !== 'tetrust') { pause(document.hidden ? 'paused — you looked away' : 'paused — you changed tabs'); return; }
|
|
239
|
+
const dt = G.last ? Math.min(250, t - G.last) : 0;
|
|
240
|
+
G.last = t;
|
|
241
|
+
G.acc += dt;
|
|
242
|
+
const g = G.game;
|
|
243
|
+
const ms = gravityMs(g.level);
|
|
244
|
+
const levelBefore = g.level;
|
|
245
|
+
while (G.acc >= ms && !g.over) { G.acc -= ms; if (tick(g)) { G.dirty = true; afterLock(g, t); } else G.dirty = true; }
|
|
246
|
+
if (g.level > levelBefore) sound.play('level');
|
|
247
|
+
if (g.over) { gameOver(); return; }
|
|
248
|
+
// the drift keeps the loop drawing until the last cell has gone
|
|
249
|
+
if (G.drift.length) {
|
|
250
|
+
G.drift = G.drift.filter((d) => t - d.t0 < DRIFT_MS);
|
|
251
|
+
G.dirty = true;
|
|
252
|
+
}
|
|
253
|
+
if (G.dirty) { draw(t); G.dirty = false; }
|
|
254
|
+
G.raf = requestAnimationFrame(frame);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// what happens when a piece locks: the sound of it, and the lines it made leaving
|
|
258
|
+
function afterLock(g, now) {
|
|
259
|
+
if (g.cleared?.length) {
|
|
260
|
+
startDrift(g.clearedCells ?? [], now);
|
|
261
|
+
sound.play(g.cleared.length >= 4 ? 'tetris' : 'clear');
|
|
262
|
+
g.cleared = []; g.clearedCells = [];
|
|
263
|
+
} else sound.play('lock');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function start() {
|
|
267
|
+
G.game = newGame();
|
|
268
|
+
G.drift = [];
|
|
269
|
+
G.running = true; G.paused = false; G.acc = 0; G.last = 0; G.dirty = true;
|
|
270
|
+
overlay(null);
|
|
271
|
+
drawScores();
|
|
272
|
+
sound.unlock();
|
|
273
|
+
drawSwitches();
|
|
274
|
+
if (!G.raf) G.raf = requestAnimationFrame(frame);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function pause(why) {
|
|
278
|
+
if (!G.running || G.paused) return;
|
|
279
|
+
G.paused = true; G.why = why;
|
|
280
|
+
overlay(why, 'the stack is holding', 'resume', true);
|
|
281
|
+
sound.holdMusic(true);
|
|
282
|
+
G.h?.toast?.(why);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function resume() {
|
|
286
|
+
if (!G.running) { start(); return; }
|
|
287
|
+
if (!G.paused) return;
|
|
288
|
+
G.paused = false; G.last = 0; G.dirty = true;
|
|
289
|
+
overlay(null);
|
|
290
|
+
sound.unlock();
|
|
291
|
+
sound.holdMusic(false);
|
|
292
|
+
if (!G.raf) G.raf = requestAnimationFrame(frame);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function gameOver() {
|
|
296
|
+
const g = G.game;
|
|
297
|
+
G.running = false; G.paused = false;
|
|
298
|
+
sound.setMusic(false);
|
|
299
|
+
sound.play('over');
|
|
300
|
+
draw();
|
|
301
|
+
const list = loadScores();
|
|
302
|
+
const rank = rankOf(g.score, list);
|
|
303
|
+
const at = Date.now();
|
|
304
|
+
if (rank) recordScore({ score: g.score, lines: g.lines, level: g.level, at });
|
|
305
|
+
drawScores(rank ? at : null);
|
|
306
|
+
overlay(
|
|
307
|
+
`verified ${g.lines} line${g.lines === 1 ? '' : 's'}`,
|
|
308
|
+
`${g.score.toLocaleString()} points at level ${g.level}${rank ? ` — #${rank} on this browser` : ''}. Trust, but verify.`,
|
|
309
|
+
'play again',
|
|
310
|
+
true,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ------------------------------------------------------------------ input
|
|
315
|
+
function onKey(e) {
|
|
316
|
+
if (G.state?.page !== 'tetrust') return;
|
|
317
|
+
const tag = e.target?.tagName;
|
|
318
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
|
319
|
+
if (e.key === 'Enter') { e.preventDefault(); resume(); return; }
|
|
320
|
+
if (e.key === 'p' || e.key === 'P' || e.key === 'Escape') { if (G.running) { e.preventDefault(); G.paused ? resume() : pause('paused'); } return; }
|
|
321
|
+
if (!G.running || G.paused || !G.game || G.game.over) return;
|
|
322
|
+
const g = G.game;
|
|
323
|
+
let used = true;
|
|
324
|
+
// arrows or WASD (operator: "Add WASD for tetris controls as well")
|
|
325
|
+
switch (e.key) {
|
|
326
|
+
case 'ArrowLeft': case 'a': case 'A': if (move(g, -1)) sound.play('move'); break;
|
|
327
|
+
case 'ArrowRight': case 'd': case 'D': if (move(g, 1)) sound.play('move'); break;
|
|
328
|
+
case 'ArrowUp': case 'w': case 'W': case 'x': case 'X': if (rotate(g, 1)) sound.play('rotate'); break;
|
|
329
|
+
case 'z': case 'Z': case 'q': case 'Q': if (rotate(g, -1)) sound.play('rotate'); break;
|
|
330
|
+
case 'ArrowDown': case 's': case 'S': if (softDrop(g)) afterLock(g, performance.now()); else sound.play('soft'); break;
|
|
331
|
+
case ' ': hardDrop(g); sound.play('drop'); afterLock(g, performance.now()); break;
|
|
332
|
+
default: used = false;
|
|
333
|
+
}
|
|
334
|
+
if (used) { e.preventDefault(); G.dirty = true; if (g.over) gameOver(); }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function bind() {
|
|
338
|
+
if (G.bound) return;
|
|
339
|
+
G.bound = true;
|
|
340
|
+
document.addEventListener('keydown', onKey);
|
|
341
|
+
document.addEventListener('visibilitychange', () => { if (document.hidden && G.running && !G.paused) pause('paused — you looked away'); });
|
|
342
|
+
el('tetResume')?.addEventListener('click', () => resume());
|
|
343
|
+
for (const [id, key] of SWITCHES) el(id)?.addEventListener('click', () => flip(key));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function renderTetrust(s, state, h) {
|
|
347
|
+
G.state = state; G.h = h;
|
|
348
|
+
bind();
|
|
349
|
+
drawSwitches();
|
|
350
|
+
drawSky();
|
|
351
|
+
if (!G.game) { overlay('Tetrust', 'trust, but verify — every line you clear is a block you verified. Enter or the button to play; arrows move, ↑ rotates, space drops.', 'play'); drawStats(); drawScores(); draw(); return; }
|
|
352
|
+
if (G.running && G.paused) overlay(G.why, 'the stack is holding', 'resume', true);
|
|
353
|
+
// a render while we are the page and not paused: keep the loop alive
|
|
354
|
+
if (G.running && !G.paused && !G.raf) { G.last = 0; G.raf = requestAnimationFrame(frame); }
|
|
355
|
+
else G.dirty = true, draw();
|
|
356
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// TETRUST SOUND (operator, 2026-09-12: "We need to add Tetris music and sound effects to teh
|
|
2
|
+
// gameplay. Toggle for each in the game display").
|
|
3
|
+
//
|
|
4
|
+
// No files and no dependencies, so everything here is SYNTHESISED with the Web Audio API: the
|
|
5
|
+
// music is a note table played on oscillators, the effects are short shaped tones. The tune is
|
|
6
|
+
// Korobeiniki, the Russian folk song everyone knows as the Tetris theme -- public domain, older
|
|
7
|
+
// than any game. A browser lets audio start only on a gesture, so the context is made lazily on
|
|
8
|
+
// the first play or key press, and nothing here throws where there is no AudioContext at all
|
|
9
|
+
// (tests, or a browser with audio blocked): every call is a no-op then.
|
|
10
|
+
|
|
11
|
+
const HZ = { A4: 440, B4: 493.88, C5: 523.25, D5: 587.33, E5: 659.25, F5: 698.46, G5: 783.99, A5: 880, GS4: 415.3, E4: 329.63, R: 0 };
|
|
12
|
+
|
|
13
|
+
// Korobeiniki, A minor, one verse: [note, beats]
|
|
14
|
+
export const THEME = Object.freeze([
|
|
15
|
+
['E5', 1], ['B4', 0.5], ['C5', 0.5], ['D5', 1], ['C5', 0.5], ['B4', 0.5],
|
|
16
|
+
['A4', 1], ['A4', 0.5], ['C5', 0.5], ['E5', 1], ['D5', 0.5], ['C5', 0.5],
|
|
17
|
+
['B4', 1.5], ['C5', 0.5], ['D5', 1], ['E5', 1],
|
|
18
|
+
['C5', 1], ['A4', 1], ['A4', 1], ['R', 1],
|
|
19
|
+
['D5', 1.5], ['F5', 0.5], ['A5', 1], ['G5', 0.5], ['F5', 0.5],
|
|
20
|
+
['E5', 1.5], ['C5', 0.5], ['E5', 1], ['D5', 0.5], ['C5', 0.5],
|
|
21
|
+
['B4', 1], ['B4', 0.5], ['C5', 0.5], ['D5', 1], ['E5', 1],
|
|
22
|
+
['C5', 1], ['A4', 1], ['A4', 1], ['R', 1],
|
|
23
|
+
]);
|
|
24
|
+
export const BPM = 148;
|
|
25
|
+
|
|
26
|
+
// the effects: [frequency from, frequency to, seconds, wave, gain]
|
|
27
|
+
export const SFX = Object.freeze({
|
|
28
|
+
move: [520, 520, 0.045, 'square', 0.05],
|
|
29
|
+
rotate: [780, 980, 0.06, 'square', 0.05],
|
|
30
|
+
soft: [300, 300, 0.03, 'triangle', 0.04],
|
|
31
|
+
drop: [160, 70, 0.11, 'sawtooth', 0.09],
|
|
32
|
+
lock: [220, 180, 0.07, 'triangle', 0.06],
|
|
33
|
+
clear: [420, 1320, 0.28, 'square', 0.08],
|
|
34
|
+
tetris: [330, 1760, 0.45, 'square', 0.1],
|
|
35
|
+
over: [440, 90, 0.7, 'sawtooth', 0.09],
|
|
36
|
+
level: [660, 1320, 0.22, 'triangle', 0.07],
|
|
37
|
+
// BLOCKOUT (breakout.js): the bat is a low knock, a brick a short blip -- higher for the dear
|
|
38
|
+
// rows -- a wall a duller tap, and a lost ball a drop. The module is the games' sound, not
|
|
39
|
+
// Tetrust's alone; both set `sfx` from their own switch as they render, and only one plays at a
|
|
40
|
+
// time because only one tab is on screen.
|
|
41
|
+
// PING AND PONG (operator, 2026-09-12: "make sure to have distinct 'Ping' and 'Pong' sounds like
|
|
42
|
+
// Arkanoid does for the paddle and block impacts"). They were 300 Hz and 680 Hz, both falling
|
|
43
|
+
// square blips -- close enough in timbre that with the ball moving you could not tell which had
|
|
44
|
+
// happened without looking. Now they are opposite in every dimension a listener actually uses:
|
|
45
|
+
//
|
|
46
|
+
// PONG (bat) low, and it FALLS -- a soft wooden knock under you
|
|
47
|
+
// PING (brick) high, and it RISES -- a bright strike up at the wall
|
|
48
|
+
//
|
|
49
|
+
// Pitch alone is a weak cue on small speakers; the direction of the sweep survives them.
|
|
50
|
+
paddle: [200, 150, 0.075, 'triangle', 0.075],
|
|
51
|
+
brick: [1046, 1245, 0.042, 'square', 0.05],
|
|
52
|
+
brickhard: [1396, 1760, 0.05, 'square', 0.055],
|
|
53
|
+
wall: [420, 400, 0.04, 'triangle', 0.04],
|
|
54
|
+
life: [320, 90, 0.42, 'sawtooth', 0.08],
|
|
55
|
+
levelup: [520, 1180, 0.3, 'triangle', 0.07],
|
|
56
|
+
// BLOCKANOID (arkanoid.js): a silver brick RINGS rather than breaks and gold rings harder and
|
|
57
|
+
// lower, so the ear learns which bricks are worth hitting before the eye does. A capsule is a
|
|
58
|
+
// rising chime (it is always good news), the laser a short zap, a minion a dull pop.
|
|
59
|
+
silver: [560, 620, 0.06, 'triangle', 0.055],
|
|
60
|
+
gold: [240, 210, 0.07, 'square', 0.05],
|
|
61
|
+
capsule: [620, 1560, 0.22, 'triangle', 0.075],
|
|
62
|
+
laser: [1200, 420, 0.05, 'sawtooth', 0.045],
|
|
63
|
+
enemy: [380, 140, 0.12, 'sawtooth', 0.06],
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const S = { ctx: null, music: false, sfx: false, timer: null, at: 0, i: 0, gain: null, live: new Set() };
|
|
67
|
+
|
|
68
|
+
function context() {
|
|
69
|
+
if (S.ctx) return S.ctx;
|
|
70
|
+
const AC = globalThis.AudioContext || globalThis.webkitAudioContext;
|
|
71
|
+
if (!AC) return null;
|
|
72
|
+
try {
|
|
73
|
+
S.ctx = new AC();
|
|
74
|
+
S.gain = S.ctx.createGain();
|
|
75
|
+
S.gain.gain.value = 0.9;
|
|
76
|
+
S.gain.connect(S.ctx.destination);
|
|
77
|
+
} catch { S.ctx = null; }
|
|
78
|
+
return S.ctx;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Call on a user gesture: a suspended context (autoplay policy) is resumed here. */
|
|
82
|
+
export function unlock() {
|
|
83
|
+
const c = context();
|
|
84
|
+
if (c && c.state === 'suspended') c.resume().catch(() => {});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function tone(freqFrom, freqTo, secs, wave, gain, at = null) {
|
|
88
|
+
const c = context();
|
|
89
|
+
if (!c) return;
|
|
90
|
+
const t0 = at ?? c.currentTime;
|
|
91
|
+
const o = c.createOscillator(), g = c.createGain();
|
|
92
|
+
o.type = wave;
|
|
93
|
+
o.frequency.setValueAtTime(freqFrom, t0);
|
|
94
|
+
if (freqTo !== freqFrom) o.frequency.exponentialRampToValueAtTime(Math.max(1, freqTo), t0 + secs);
|
|
95
|
+
g.gain.setValueAtTime(0.0001, t0);
|
|
96
|
+
g.gain.exponentialRampToValueAtTime(gain, t0 + 0.008);
|
|
97
|
+
g.gain.exponentialRampToValueAtTime(0.0001, t0 + secs);
|
|
98
|
+
o.connect(g); g.connect(S.gain);
|
|
99
|
+
o.start(t0); o.stop(t0 + secs + 0.02);
|
|
100
|
+
// remembered until it has played, so a stop (pause, switch off) can cut what was scheduled
|
|
101
|
+
// ahead rather than letting a second and a half of tune run on
|
|
102
|
+
S.live.add(o);
|
|
103
|
+
o.onended = () => S.live.delete(o);
|
|
104
|
+
}
|
|
105
|
+
function hush() {
|
|
106
|
+
for (const o of S.live) { try { o.stop(); } catch { /* already stopped */ } }
|
|
107
|
+
S.live.clear();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One sound effect, if effects are on. */
|
|
111
|
+
export function play(name) {
|
|
112
|
+
if (!S.sfx) return;
|
|
113
|
+
const d = SFX[name];
|
|
114
|
+
if (!d) return;
|
|
115
|
+
tone(d[0], d[1], d[2], d[3], d[4]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function setSfx(on) { S.sfx = !!on; }
|
|
119
|
+
|
|
120
|
+
// THE MUSIC: a lookahead scheduler. Every 200 ms it schedules whatever notes fall in the next
|
|
121
|
+
// 1.5 s, on the AUDIO clock (absolute times), so the tune keeps its tempo however late the page's
|
|
122
|
+
// timers run -- and they run late: a galaxy repaint or a well repaint can hold the main thread
|
|
123
|
+
// well past a timer. The first cut looked 250 ms ahead on a 90 ms timer and stuttered whenever a
|
|
124
|
+
// frame took longer than that ("the music is glitching out and not playing at a steady state").
|
|
125
|
+
// A stop takes at most the lookahead to fall silent, which a pause can afford. Melody on a square
|
|
126
|
+
// wave, the same note an octave down on a soft triangle underneath.
|
|
127
|
+
// (second cut, same day: "The song wobbles and is not consistent ... lock that shit down". The
|
|
128
|
+
// wobble was not the lookahead: the HUD redraw called setMusic(true) on every app render, and
|
|
129
|
+
// each call restarted the scheduler from "now" over the notes already queued -- so the tune
|
|
130
|
+
// stuttered on itself every few seconds. setMusic is idempotent now, and the lookahead is a
|
|
131
|
+
// generous four seconds: the notes are on the audio thread's clock the moment they are queued,
|
|
132
|
+
// and nothing the page does after that can move them.)
|
|
133
|
+
const LOOKAHEAD_S = 4;
|
|
134
|
+
const TIMER_MS = 500;
|
|
135
|
+
function schedule() {
|
|
136
|
+
const c = S.ctx;
|
|
137
|
+
if (!c || !S.music) return;
|
|
138
|
+
const beat = 60 / BPM;
|
|
139
|
+
// fallen behind (the tab was hidden, the thread was held): pick the tune up from now rather
|
|
140
|
+
// than cramming the missed notes into an instant
|
|
141
|
+
if (S.at < c.currentTime - 0.05) S.at = c.currentTime + 0.02;
|
|
142
|
+
while (S.at < c.currentTime + LOOKAHEAD_S) {
|
|
143
|
+
const [name, beats] = THEME[S.i % THEME.length];
|
|
144
|
+
const dur = beats * beat;
|
|
145
|
+
const hz = HZ[name] ?? 0;
|
|
146
|
+
if (hz > 0) {
|
|
147
|
+
tone(hz, hz, dur * 0.88, 'square', 0.045, S.at);
|
|
148
|
+
tone(hz / 2, hz / 2, dur * 0.88, 'triangle', 0.03, S.at);
|
|
149
|
+
}
|
|
150
|
+
S.at += dur;
|
|
151
|
+
S.i++;
|
|
152
|
+
}
|
|
153
|
+
S.timer = setTimeout(schedule, TIMER_MS);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function setMusic(on) {
|
|
157
|
+
const want = !!on;
|
|
158
|
+
if (want === S.music && (!want || S.timer)) return; // already so: never restart a running tune
|
|
159
|
+
S.music = want;
|
|
160
|
+
if (S.timer) { clearTimeout(S.timer); S.timer = null; }
|
|
161
|
+
if (!S.music) { hush(); return; }
|
|
162
|
+
const c = context();
|
|
163
|
+
if (!c) return;
|
|
164
|
+
if (c.state === 'suspended') c.resume().catch(() => {});
|
|
165
|
+
S.at = c.currentTime + 0.05;
|
|
166
|
+
schedule();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Hold the tune (a pause): the scheduler stops, the place in the tune is kept. */
|
|
170
|
+
export function holdMusic(hold) {
|
|
171
|
+
if (hold) { if (S.timer) { clearTimeout(S.timer); S.timer = null; } hush(); return; }
|
|
172
|
+
if (S.music && !S.timer && S.ctx) { S.at = S.ctx.currentTime + 0.05; schedule(); }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function state() { return { music: S.music, sfx: S.sfx, live: !!S.ctx }; }
|