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,459 @@
|
|
|
1
|
+
// SOUNDCARD: the Sound Blaster Pro 2 in the DOOM Diversion's PC -- a DSP that plays digitised sound
|
|
2
|
+
// through DMA, and an OPL3 FM synthesiser for the music (operator, 2026-09-15: "Get DOOM working as
|
|
3
|
+
// a diversion inside blockyard with zero dependancies").
|
|
4
|
+
//
|
|
5
|
+
// Our own implementation, at the level a driver sees the card: the DSP's reset handshake, command
|
|
6
|
+
// bytes and interrupts; the 8237 DMA controller's address and count registers, which is where the
|
|
7
|
+
// samples come from (the driver leaves them in conventional memory and the card pulls them); and the
|
|
8
|
+
// OPL's register map, timers and status byte, which is how a driver decides the chip exists at all.
|
|
9
|
+
//
|
|
10
|
+
// THE SYNTHESIS IS A MODEL, NOT A CHIP. Each OPL operator is a phase accumulator, one of eight
|
|
11
|
+
// waveforms, and an envelope in decibels (attack, decay to the sustain level, release) with the
|
|
12
|
+
// chip's documented rate scaling: 2826 ms for a full attack and 39281 ms for a full 96 dB decay at
|
|
13
|
+
// rate 4, each rate step a quarter of an octave faster. Modulation is phase modulation in cycles --
|
|
14
|
+
// a modulator at full level moves its carrier by four cycles, the chip's own scaling. It sounds like
|
|
15
|
+
// an OPL; it is not sample-exact to one, and nothing here pretends it is.
|
|
16
|
+
//
|
|
17
|
+
// Time is the machine's: `tick(t)` is called with the PC's clock, and every millisecond of it
|
|
18
|
+
// produces `rate` samples a second of stereo output, mixed from the DSP's DAC and the FM voices, into
|
|
19
|
+
// a ring the caller drains. The DSP's end-of-block interrupt is raised from inside that same loop,
|
|
20
|
+
// so a driver's next block is requested exactly when the last one ran out, in machine time.
|
|
21
|
+
|
|
22
|
+
const OPL_RATE = 49716;
|
|
23
|
+
|
|
24
|
+
// ------------------------------------------------------------------ OPL tables
|
|
25
|
+
const WAVE_BITS = 10, WAVE_LEN = 1 << WAVE_BITS;
|
|
26
|
+
const WAVES = [];
|
|
27
|
+
{
|
|
28
|
+
const sin = (i) => Math.sin((2 * Math.PI * (i + 0.5)) / WAVE_LEN);
|
|
29
|
+
const mk = (f) => { const t = new Float32Array(WAVE_LEN); for (let i = 0; i < WAVE_LEN; i++) t[i] = f(i); return t; };
|
|
30
|
+
const half = WAVE_LEN / 2, quarter = WAVE_LEN / 4;
|
|
31
|
+
WAVES.push(mk((i) => sin(i))); // 0 sine
|
|
32
|
+
WAVES.push(mk((i) => (i < half ? sin(i) : 0))); // 1 half-sine
|
|
33
|
+
WAVES.push(mk((i) => Math.abs(sin(i)))); // 2 abs-sine
|
|
34
|
+
WAVES.push(mk((i) => ((i % half) < quarter ? Math.abs(sin(i)) : 0))); // 3 pulse-sine
|
|
35
|
+
WAVES.push(mk((i) => (i < half ? sin(i * 2) : 0))); // 4 even-periods sine
|
|
36
|
+
WAVES.push(mk((i) => (i < half ? Math.abs(sin(i * 2)) : 0))); // 5 even-periods abs-sine
|
|
37
|
+
WAVES.push(mk((i) => (i < half ? 1 : -1))); // 6 square
|
|
38
|
+
WAVES.push(mk((i) => (i < half ? Math.pow(10, -(i / half) * 4.8) : -Math.pow(10, -((WAVE_LEN - 1 - i) / half) * 4.8)))); // 7 derived square
|
|
39
|
+
}
|
|
40
|
+
// the tremolo LFO: a raised cosine, 0 to 1, over 256 steps
|
|
41
|
+
const LFO_TREM = new Float32Array(256);
|
|
42
|
+
for (let i = 0; i < 256; i++) LFO_TREM[i] = (1 - Math.cos((2 * Math.PI * i) / 256)) * 0.5;
|
|
43
|
+
const MULT = [0.5, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 10, 12, 12, 15, 15];
|
|
44
|
+
const KSL_TABLE = [0, 18, 24, 27, 30, 32.25, 33.75, 35.25, 36, 37.5, 38.25, 39, 39.75, 40.5, 41.25, 42];
|
|
45
|
+
const KSL_SCALE = [0, 0.5, 0.25, 1]; // 0, 3, 1.5 and 6 dB an octave, as fractions of the table's 6
|
|
46
|
+
const MAX_DB = 96;
|
|
47
|
+
// dB -> linear, in tenths of a dB: the envelope is summed in dB and looked up once a sample
|
|
48
|
+
const DB_STEPS = 10;
|
|
49
|
+
const DB_TO_AMP = new Float32Array(MAX_DB * DB_STEPS * 2 + 2);
|
|
50
|
+
for (let i = 0; i < DB_TO_AMP.length; i++) DB_TO_AMP[i] = i >= MAX_DB * DB_STEPS ? 0 : Math.pow(10, -i / DB_STEPS / 20);
|
|
51
|
+
|
|
52
|
+
/** Seconds for a full attack and a full 96 dB decay at an effective rate (0..63). */
|
|
53
|
+
export function oplRateTimes(rate) {
|
|
54
|
+
if (rate < 4) return { attack: Infinity, decay: Infinity };
|
|
55
|
+
if (rate >= 60) return { attack: 0, decay: 0.3 / 1000 * Math.pow(2, -(Math.min(rate, 63) - 60) / 4) * 8 };
|
|
56
|
+
const k = Math.pow(2, (rate - 4) / 4);
|
|
57
|
+
return { attack: 2.82624 / k, decay: 39.28064 / k };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const EG_OFF = 0, EG_ATTACK = 1, EG_DECAY = 2, EG_SUSTAIN = 3, EG_RELEASE = 4;
|
|
61
|
+
|
|
62
|
+
class Operator {
|
|
63
|
+
constructor() {
|
|
64
|
+
this.am = 0; this.vib = 0; this.egt = 0; this.ksr = 0; this.mult = 1;
|
|
65
|
+
this.ksl = 0; this.tl = 0; this.ar = 0; this.dr = 0; this.sl = 0; this.rr = 0; this.wave = 0;
|
|
66
|
+
this.phase = 0; this.env = MAX_DB; this.stage = EG_OFF;
|
|
67
|
+
this.out = 0; this.prev = 0;
|
|
68
|
+
// worked out per render call from the registers (Opl3.prepare)
|
|
69
|
+
this.attackK = 0; this.decayInc = 0; this.releaseInc = 0; this.slLevel = 0; this.levelDb = 0; this.inc = 0; this.table = null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
class Channel {
|
|
74
|
+
constructor() {
|
|
75
|
+
this.fnum = 0; this.block = 0; this.key = false; this.fb = 0; this.cnt = 0;
|
|
76
|
+
this.left = true; this.right = true;
|
|
77
|
+
this.ops = [new Operator(), new Operator()];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class Opl3 {
|
|
82
|
+
constructor(rate) {
|
|
83
|
+
this.rate = rate;
|
|
84
|
+
this.reg = new Uint8Array(0x200);
|
|
85
|
+
this.chans = Array.from({ length: 18 }, () => new Channel());
|
|
86
|
+
this.newMode = false; // register 0x105 bit 0: OPL3 features on
|
|
87
|
+
this.wse = false; // register 0x01 bit 5: waveform select (OPL2)
|
|
88
|
+
this.nts = 0;
|
|
89
|
+
this.amDepth = 1; this.vibDepth = 7;
|
|
90
|
+
this.lfoAm = 0; this.lfoVib = 0;
|
|
91
|
+
this.timer1 = 0; this.timer2 = 0; this.status = 0; this.timerCtl = 0;
|
|
92
|
+
this.index = [0, 0];
|
|
93
|
+
this.trem = new Float32Array(0); this.vib = new Float32Array(0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The status byte: timer flags in bits 5-7, and OPL3's low bits read 0 (an OPL2 reads 6). */
|
|
97
|
+
readStatus() { return this.status; }
|
|
98
|
+
|
|
99
|
+
write(array, reg, v) {
|
|
100
|
+
const r = (array << 8) | reg;
|
|
101
|
+
this.reg[r] = v;
|
|
102
|
+
if (array === 1 && reg === 0x05) { this.newMode = (v & 1) === 1; return; }
|
|
103
|
+
if (array === 0) {
|
|
104
|
+
switch (reg) {
|
|
105
|
+
case 0x01: this.wse = (v & 0x20) !== 0; return;
|
|
106
|
+
case 0x02: this.timer1 = v; return;
|
|
107
|
+
case 0x03: this.timer2 = v; return;
|
|
108
|
+
case 0x04:
|
|
109
|
+
if (v & 0x80) { this.status = 0; return; } // reset the flags
|
|
110
|
+
this.timerCtl = v;
|
|
111
|
+
// a started timer has overflowed by the time a driver reads the status: detection waits
|
|
112
|
+
// longer than either period, so it is honest to raise the flag at once
|
|
113
|
+
if ((v & 1) && !(v & 0x40)) this.status |= 0xc0;
|
|
114
|
+
if ((v & 2) && !(v & 0x20)) this.status |= 0xa0;
|
|
115
|
+
return;
|
|
116
|
+
case 0x08: this.nts = (v >> 6) & 1; return;
|
|
117
|
+
case 0xbd: this.amDepth = v & 0x80 ? 4.8 : 1; this.vibDepth = v & 0x40 ? 14 : 7; return;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const base = array === 0 ? 0 : 9;
|
|
121
|
+
if (reg >= 0x20 && reg <= 0xf5 && reg < 0xa0 || reg >= 0xe0) {
|
|
122
|
+
const opi = OP_SLOT[reg & 0x1f];
|
|
123
|
+
if (opi < 0) return;
|
|
124
|
+
const ch = this.chans[base + Math.floor(opi / 2)], op = ch.ops[opi % 2];
|
|
125
|
+
switch (reg & 0xe0) {
|
|
126
|
+
case 0x20: op.am = (v >> 7) & 1; op.vib = (v >> 6) & 1; op.egt = (v >> 5) & 1; op.ksr = (v >> 4) & 1; op.mult = MULT[v & 15]; return;
|
|
127
|
+
case 0x40: op.ksl = (v >> 6) & 3; op.tl = v & 0x3f; return;
|
|
128
|
+
case 0x60: op.ar = (v >> 4) & 15; op.dr = v & 15; return;
|
|
129
|
+
case 0x80: op.sl = (v >> 4) & 15; op.rr = v & 15; if (op.stage === EG_SUSTAIN && op.env < this.slDb(op)) op.stage = EG_DECAY; return;
|
|
130
|
+
case 0xe0: op.wave = v & 7; return;
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (reg >= 0xa0 && reg <= 0xa8) { const ch = this.chans[base + reg - 0xa0]; ch.fnum = (ch.fnum & 0x300) | v; return; }
|
|
135
|
+
if (reg >= 0xb0 && reg <= 0xb8) {
|
|
136
|
+
const ch = this.chans[base + reg - 0xb0];
|
|
137
|
+
ch.fnum = (ch.fnum & 0xff) | ((v & 3) << 8);
|
|
138
|
+
ch.block = (v >> 2) & 7;
|
|
139
|
+
const key = (v & 0x20) !== 0;
|
|
140
|
+
if (key && !ch.key) for (const op of ch.ops) { op.stage = EG_ATTACK; op.phase = 0; }
|
|
141
|
+
if (!key && ch.key) for (const op of ch.ops) if (op.stage !== EG_OFF) op.stage = EG_RELEASE;
|
|
142
|
+
ch.key = key;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (reg >= 0xc0 && reg <= 0xc8) {
|
|
146
|
+
const ch = this.chans[base + reg - 0xc0];
|
|
147
|
+
ch.fb = (v >> 1) & 7; ch.cnt = v & 1;
|
|
148
|
+
ch.left = (v & 0x10) !== 0; ch.right = (v & 0x20) !== 0;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
slDb(op) { return op.sl === 15 ? 93 : op.sl * 3; }
|
|
153
|
+
|
|
154
|
+
effRate(ch, op, r) {
|
|
155
|
+
if (r === 0) return 0;
|
|
156
|
+
const rof = (ch.block << 1) | ((ch.fnum >> (this.nts ? 8 : 9)) & 1);
|
|
157
|
+
return Math.min(63, r * 4 + (op.ksr ? rof : rof >> 2));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Mix `n` stereo frames into out[off..] (added, not replaced). Registers do not change inside a
|
|
162
|
+
* call -- the machine writes them between calls -- so everything that depends only on them (a
|
|
163
|
+
* channel's frequency, an operator's level and envelope rates) is worked out once per call, and
|
|
164
|
+
* the per-sample loop is phase, envelope and a table read.
|
|
165
|
+
*/
|
|
166
|
+
render(out, off, n) {
|
|
167
|
+
const dt = 1 / this.rate;
|
|
168
|
+
const waveMask = this.newMode ? 7 : this.wse ? 3 : 0;
|
|
169
|
+
if (this.trem.length < n) { this.trem = new Float32Array(n); this.vib = new Float32Array(n); }
|
|
170
|
+
const trem = this.trem, vib = this.vib;
|
|
171
|
+
const vibScale = this.vibDepth * Math.LN2 / 1200; // cents to a frequency ratio, to first order
|
|
172
|
+
for (let i = 0; i < n; i++) {
|
|
173
|
+
this.lfoAm += 3.7 * dt; if (this.lfoAm >= 1) this.lfoAm -= 1;
|
|
174
|
+
this.lfoVib += 6.1 * dt; if (this.lfoVib >= 1) this.lfoVib -= 1;
|
|
175
|
+
trem[i] = LFO_TREM[(this.lfoAm * 256) | 0] * this.amDepth;
|
|
176
|
+
vib[i] = 1 + WAVES[0][(this.lfoVib * WAVE_LEN) | 0] * vibScale;
|
|
177
|
+
}
|
|
178
|
+
for (let c = 0; c < 18; c++) {
|
|
179
|
+
const ch = this.chans[c];
|
|
180
|
+
const m = ch.ops[0], k = ch.ops[1];
|
|
181
|
+
if (m.stage === EG_OFF && k.stage === EG_OFF) continue;
|
|
182
|
+
if (c >= 9 && !this.newMode) continue;
|
|
183
|
+
const cycles = ch.fnum * (1 << ch.block) * OPL_RATE / 1048576 * dt; // one cycle a sample = 1
|
|
184
|
+
const kslDb = Math.max(0, KSL_TABLE[ch.fnum >> 6] - 6 * (7 - ch.block));
|
|
185
|
+
this.prepare(ch, m, cycles, kslDb, dt, waveMask);
|
|
186
|
+
this.prepare(ch, k, cycles, kslDb, dt, waveMask);
|
|
187
|
+
const fb = ch.fb ? Math.pow(2, ch.fb - 6) * 0.5 : 0;
|
|
188
|
+
const gl = (!this.newMode || ch.left) ? 0.11 : 0, gr = (!this.newMode || ch.right) ? 0.11 : 0;
|
|
189
|
+
for (let i = 0; i < n; i++) {
|
|
190
|
+
// modulator, with feedback from its own last two outputs
|
|
191
|
+
const mo = this.sample(ch, m, fb ? (m.out + m.prev) * fb : 0, trem[i], vib[i]);
|
|
192
|
+
m.prev = m.out; m.out = mo;
|
|
193
|
+
const s = ch.cnt === 0 ? this.sample(ch, k, mo * 4, trem[i], vib[i]) : mo + this.sample(ch, k, 0, trem[i], vib[i]);
|
|
194
|
+
out[off + i * 2] += s * gl;
|
|
195
|
+
out[off + i * 2 + 1] += s * gr;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// the per-call constants of one operator, stored on it
|
|
201
|
+
prepare(ch, op, cycles, kslDb, dt, waveMask) {
|
|
202
|
+
const at = oplRateTimes(this.effRate(ch, op, op.ar)).attack;
|
|
203
|
+
op.attackK = at === 0 ? -1 : at === Infinity ? 0 : (dt / at) * 3.22;
|
|
204
|
+
const dtime = oplRateTimes(this.effRate(ch, op, op.dr)).decay;
|
|
205
|
+
op.decayInc = dtime === Infinity ? 0 : MAX_DB * dt / dtime;
|
|
206
|
+
const rtime = oplRateTimes(this.effRate(ch, op, op.rr)).decay;
|
|
207
|
+
op.releaseInc = rtime === Infinity ? 0 : MAX_DB * dt / rtime;
|
|
208
|
+
op.slLevel = this.slDb(op);
|
|
209
|
+
op.levelDb = op.tl * 0.75 + kslDb * KSL_SCALE[op.ksl];
|
|
210
|
+
op.inc = cycles * op.mult;
|
|
211
|
+
op.table = WAVES[op.wave & waveMask];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
sample(ch, op, pm, trem, vib) {
|
|
215
|
+
// envelope, in dB of attenuation
|
|
216
|
+
switch (op.stage) {
|
|
217
|
+
case EG_ATTACK:
|
|
218
|
+
// the chip's attack is exponential in dB: fast at first, easing into full level
|
|
219
|
+
if (op.attackK < 0) op.env = 0; else op.env -= (op.env + 4) * op.attackK;
|
|
220
|
+
if (op.env <= 0) { op.env = 0; op.stage = EG_DECAY; }
|
|
221
|
+
break;
|
|
222
|
+
case EG_DECAY:
|
|
223
|
+
op.env += op.decayInc;
|
|
224
|
+
if (op.env >= op.slLevel) { op.env = op.slLevel; op.stage = EG_SUSTAIN; }
|
|
225
|
+
break;
|
|
226
|
+
case EG_SUSTAIN:
|
|
227
|
+
if (!op.egt) op.env += op.releaseInc; // a percussive sound keeps decaying
|
|
228
|
+
break;
|
|
229
|
+
case EG_RELEASE:
|
|
230
|
+
op.env += op.releaseInc;
|
|
231
|
+
break;
|
|
232
|
+
default:
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
if (op.env >= MAX_DB) {
|
|
236
|
+
op.env = MAX_DB;
|
|
237
|
+
if (op.stage === EG_RELEASE || !ch.key) op.stage = EG_OFF;
|
|
238
|
+
}
|
|
239
|
+
op.phase += op.vib ? op.inc * vib : op.inc;
|
|
240
|
+
if (op.phase >= 1) op.phase -= Math.floor(op.phase);
|
|
241
|
+
const db = op.env + op.levelDb + (op.am ? trem : 0);
|
|
242
|
+
if (db >= MAX_DB) return 0;
|
|
243
|
+
let p = op.phase + pm;
|
|
244
|
+
p -= Math.floor(p);
|
|
245
|
+
return op.table[(p * WAVE_LEN) | 0] * DB_TO_AMP[(db * DB_STEPS) | 0];
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// register offset (low 5 bits of 0x20..0x95, 0xE0..0xF5) -> operator slot: channel * 2 + (0 mod, 1 car)
|
|
249
|
+
const OP_SLOT = [0, 2, 4, 1, 3, 5, -1, -1, 6, 8, 10, 7, 9, 11, -1, -1, 12, 14, 16, 13, 15, 17, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1];
|
|
250
|
+
|
|
251
|
+
// ------------------------------------------------------------------ the card
|
|
252
|
+
/**
|
|
253
|
+
* A Sound Blaster Pro 2 at `base` (0x220), IRQ `irq`, 8-bit DMA `dma`, with its OPL3 at 0x388 too.
|
|
254
|
+
* `mem` is the machine's memory (DMA reads it). Output is stereo at `rate`.
|
|
255
|
+
*/
|
|
256
|
+
export function createSoundCard({ mem, base = 0x220, irq = 7, dma = 1, rate = 48000, ring = 1 << 16 } = {}) {
|
|
257
|
+
const opl = new Opl3(rate);
|
|
258
|
+
const dsp = {
|
|
259
|
+
resetting: false, queue: [], cmd: -1, args: [], need: 0,
|
|
260
|
+
speaker: true, rate: 11025, stereo: false, blockSize: 0x7ff,
|
|
261
|
+
playing: false, auto: false, paused: false, left: 0, exitAuto: false,
|
|
262
|
+
irqPending: false, test: 0, level: 0, lastSample: 0x80, pos: 0,
|
|
263
|
+
irqAt: -1,
|
|
264
|
+
};
|
|
265
|
+
const dmac = {
|
|
266
|
+
addr: new Uint16Array(4), count: new Uint16Array(4), baseAddr: new Uint16Array(4), baseCount: new Uint16Array(4),
|
|
267
|
+
page: new Uint8Array(4), mode: new Uint8Array(4), mask: 0x0f, flip: false, status: 0,
|
|
268
|
+
};
|
|
269
|
+
const mixer = { index: 0, reg: new Uint8Array(256) };
|
|
270
|
+
mixer.reg[0x22] = 0xff; mixer.reg[0x04] = 0xff; mixer.reg[0x26] = 0xff;
|
|
271
|
+
const PAGE_PORT = [0x87, 0x83, 0x81, 0x82];
|
|
272
|
+
|
|
273
|
+
// output ring: interleaved stereo float frames
|
|
274
|
+
const buf = new Float32Array(ring * 2);
|
|
275
|
+
let head = 0, count = 0;
|
|
276
|
+
const scratch = new Float32Array(8192);
|
|
277
|
+
let lastT = -1, outFrac = 0, dspFrac = 0;
|
|
278
|
+
let pendingIrq = null;
|
|
279
|
+
|
|
280
|
+
function resetDsp() {
|
|
281
|
+
dsp.playing = false; dsp.auto = false; dsp.paused = false; dsp.cmd = -1; dsp.need = 0; dsp.args = [];
|
|
282
|
+
dsp.queue = [0xaa]; dsp.stereo = false; dsp.exitAuto = false;
|
|
283
|
+
}
|
|
284
|
+
function command(c) {
|
|
285
|
+
dsp.cmd = c; dsp.args = [];
|
|
286
|
+
const needs = { 0x10: 1, 0x14: 2, 0x16: 2, 0x17: 2, 0x24: 2, 0x40: 1, 0x41: 2, 0x42: 2, 0x48: 2, 0x74: 2, 0x75: 2, 0x76: 2, 0x77: 2, 0x80: 2, 0xe0: 1, 0xe2: 1, 0xe4: 1 };
|
|
287
|
+
if ((c & 0xf0) === 0xb0 || (c & 0xf0) === 0xc0) dsp.need = 3;
|
|
288
|
+
else dsp.need = needs[c] ?? 0;
|
|
289
|
+
if (dsp.need === 0) execute();
|
|
290
|
+
}
|
|
291
|
+
function startBlock(len, auto) {
|
|
292
|
+
dsp.left = len; dsp.auto = auto; dsp.playing = true; dsp.paused = false; dsp.exitAuto = false;
|
|
293
|
+
}
|
|
294
|
+
function execute() {
|
|
295
|
+
const c = dsp.cmd, a = dsp.args;
|
|
296
|
+
dsp.cmd = -1;
|
|
297
|
+
switch (c) {
|
|
298
|
+
case 0x10: dsp.level = a[0]; dsp.lastSample = a[0]; return;
|
|
299
|
+
case 0x14: case 0x16: case 0x17: case 0x91: startBlock(c === 0x91 ? dsp.blockSize + 1 : (a[0] | (a[1] << 8)) + 1, false); return;
|
|
300
|
+
case 0x1c: case 0x90: startBlock(dsp.blockSize + 1, true); return;
|
|
301
|
+
case 0x20: dsp.queue.push(0x80); return;
|
|
302
|
+
case 0x40: dsp.rate = Math.round(1000000 / (256 - a[0])); return;
|
|
303
|
+
case 0x41: case 0x42: dsp.rate = (a[0] << 8) | a[1]; return;
|
|
304
|
+
case 0x48: dsp.blockSize = a[0] | (a[1] << 8); return;
|
|
305
|
+
case 0x80: dsp.silence = (a[0] | (a[1] << 8)) + 1; startBlock(dsp.silence, false); dsp.silent = true; return;
|
|
306
|
+
case 0xa0: dsp.stereo = false; return;
|
|
307
|
+
case 0xa8: dsp.stereo = true; return;
|
|
308
|
+
case 0xd0: dsp.paused = true; return;
|
|
309
|
+
case 0xd1: dsp.speaker = true; return;
|
|
310
|
+
case 0xd3: dsp.speaker = false; return;
|
|
311
|
+
case 0xd4: dsp.paused = false; return;
|
|
312
|
+
case 0xd8: dsp.queue.push(dsp.speaker ? 0xff : 0x00); return;
|
|
313
|
+
case 0xda: dsp.exitAuto = true; return;
|
|
314
|
+
case 0xe0: dsp.queue.push(~a[0] & 0xff); return;
|
|
315
|
+
case 0xe1: dsp.queue.push(3, 2); return;
|
|
316
|
+
case 0xe3: for (const ch of 'COPYRIGHT (C) CREATIVE TECHNOLOGY LTD, 1992.\0') dsp.queue.push(ch.charCodeAt(0)); return;
|
|
317
|
+
case 0xe4: dsp.test = a[0]; return;
|
|
318
|
+
case 0xe8: dsp.queue.push(dsp.test); return;
|
|
319
|
+
case 0xf2: case 0xf3: pendingIrq = 'soon'; return;
|
|
320
|
+
case 0xf8: dsp.queue.push(0); return;
|
|
321
|
+
}
|
|
322
|
+
if ((c & 0xf0) === 0xb0 || (c & 0xf0) === 0xc0) {
|
|
323
|
+
// SB16 transfers: mode byte then a length; the 8-bit ones are the ones this card honours
|
|
324
|
+
startBlock((a[1] | (a[2] << 8)) + 1, (c & 0x04) !== 0);
|
|
325
|
+
dsp.stereo = (a[0] & 0x20) !== 0;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// one sample off DMA: returns 0..255, or -1 when the channel has nothing
|
|
330
|
+
function dmaByte() {
|
|
331
|
+
const ch = dma & 3;
|
|
332
|
+
if (dmac.mask & (1 << ch)) return -1;
|
|
333
|
+
const addr = (dmac.page[ch] << 16) | dmac.addr[ch];
|
|
334
|
+
const v = mem[addr];
|
|
335
|
+
dmac.addr[ch] = (dmac.addr[ch] + ((dmac.mode[ch] & 0x20) ? -1 : 1)) & 0xffff;
|
|
336
|
+
dmac.count[ch] = (dmac.count[ch] - 1) & 0xffff;
|
|
337
|
+
if (dmac.count[ch] === 0xffff) {
|
|
338
|
+
dmac.status |= 1 << ch;
|
|
339
|
+
if (dmac.mode[ch] & 0x10) { dmac.addr[ch] = dmac.baseAddr[ch]; dmac.count[ch] = dmac.baseCount[ch]; }
|
|
340
|
+
else dmac.mask |= 1 << ch;
|
|
341
|
+
}
|
|
342
|
+
return v;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function portIn(port) {
|
|
346
|
+
if (port === 0x388 || port === 0x38a || port === base + 8 || port === base) return opl.readStatus();
|
|
347
|
+
if (port === base + 0x0a) { if (dsp.queue.length) dsp.last = dsp.queue.shift(); return dsp.last ?? 0xaa; }
|
|
348
|
+
if (port === base + 0x0c) return 0x7f; // ready for a command
|
|
349
|
+
if (port === base + 0x0e) { dsp.irqPending = false; return dsp.queue.length ? 0xff : 0x7f; }
|
|
350
|
+
if (port === base + 0x04) return mixer.index;
|
|
351
|
+
if (port === base + 0x05) return mixer.reg[mixer.index];
|
|
352
|
+
if (port === base + 0x06) return 0xff;
|
|
353
|
+
if (port <= 0x07) {
|
|
354
|
+
const ch = port >> 1, isCount = port & 1;
|
|
355
|
+
const v = isCount ? dmac.count[ch] : dmac.addr[ch];
|
|
356
|
+
const r = dmac.flip ? v >> 8 : v & 0xff;
|
|
357
|
+
dmac.flip = !dmac.flip;
|
|
358
|
+
return r;
|
|
359
|
+
}
|
|
360
|
+
if (port === 0x08) { const s = dmac.status; dmac.status &= 0xf0; return s; }
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function portOut(port, v) {
|
|
365
|
+
if (port === 0x388 || port === base + 8 || port === base) { opl.index[0] = v; return true; }
|
|
366
|
+
if (port === 0x389 || port === base + 9 || port === base + 1) { opl.write(0, opl.index[0], v); return true; }
|
|
367
|
+
if (port === 0x38a || port === base + 2) { opl.index[1] = v; return true; }
|
|
368
|
+
if (port === 0x38b || port === base + 3) { opl.write(1, opl.index[1], v); return true; }
|
|
369
|
+
if (port === base + 0x06) {
|
|
370
|
+
if (v & 1) { dsp.resetting = true; return true; }
|
|
371
|
+
if (dsp.resetting) { dsp.resetting = false; resetDsp(); }
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
if (port === base + 0x0c) {
|
|
375
|
+
if (dsp.cmd >= 0) { dsp.args.push(v); if (dsp.args.length >= dsp.need) execute(); }
|
|
376
|
+
else command(v);
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
if (port === base + 0x04) { mixer.index = v; return true; }
|
|
380
|
+
if (port === base + 0x05) { mixer.reg[mixer.index] = v; if (mixer.index === 0x00) mixer.reg.fill(0); return true; }
|
|
381
|
+
if (port <= 0x07) {
|
|
382
|
+
const ch = port >> 1, isCount = port & 1;
|
|
383
|
+
const arr = isCount ? dmac.count : dmac.addr, baseArr = isCount ? dmac.baseCount : dmac.baseAddr;
|
|
384
|
+
arr[ch] = dmac.flip ? (arr[ch] & 0xff) | (v << 8) : (arr[ch] & 0xff00) | v;
|
|
385
|
+
baseArr[ch] = arr[ch];
|
|
386
|
+
dmac.flip = !dmac.flip;
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
if (port === 0x0a) { if (v & 4) dmac.mask |= 1 << (v & 3); else dmac.mask &= ~(1 << (v & 3)); return true; }
|
|
390
|
+
if (port === 0x0b) { dmac.mode[v & 3] = v; return true; }
|
|
391
|
+
if (port === 0x0c) { dmac.flip = false; return true; }
|
|
392
|
+
if (port === 0x0f) { dmac.mask = v & 0x0f; return true; }
|
|
393
|
+
const pi = PAGE_PORT.indexOf(port);
|
|
394
|
+
if (pi >= 0) { dmac.page[pi] = v; return true; }
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function push(l, r) {
|
|
399
|
+
if (count === ring) { head = (head + 1) % ring; count--; } // overrun: drop the oldest
|
|
400
|
+
const at = (head + count) % ring;
|
|
401
|
+
buf[at * 2] = l; buf[at * 2 + 1] = r;
|
|
402
|
+
count++;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Advance the card to machine time `t` (ms), raising its interrupt through `raiseIrq`. */
|
|
406
|
+
function tick(t, raiseIrq) {
|
|
407
|
+
if (pendingIrq === 'soon') { pendingIrq = null; dsp.irqPending = true; raiseIrq(irq); }
|
|
408
|
+
if (lastT < 0) { lastT = t; return; }
|
|
409
|
+
// the machine calls this every few thousand instructions -- a sample or two each time. Work in
|
|
410
|
+
// batches of at least 128 frames (under 3 ms): a DSP block is thousands of samples, so its
|
|
411
|
+
// interrupt is no later for it, and the synthesiser's per-call set-up is paid 300 times a
|
|
412
|
+
// second instead of 40,000
|
|
413
|
+
if ((t - lastT) * rate / 1000 + outFrac < 128) return;
|
|
414
|
+
let ms = t - lastT;
|
|
415
|
+
lastT = t;
|
|
416
|
+
if (ms <= 0) return;
|
|
417
|
+
if (ms > 200) ms = 200; // a stall is not replayed as a burst
|
|
418
|
+
outFrac += ms * rate / 1000;
|
|
419
|
+
let n = Math.floor(outFrac);
|
|
420
|
+
outFrac -= n;
|
|
421
|
+
const step = dsp.rate / rate / (dsp.stereo ? 2 : 1);
|
|
422
|
+
let off = 0;
|
|
423
|
+
while (n > 0) {
|
|
424
|
+
const k = Math.min(n, scratch.length >> 1);
|
|
425
|
+
scratch.fill(0, 0, k * 2);
|
|
426
|
+
opl.render(scratch, 0, k);
|
|
427
|
+
for (let i = 0; i < k; i++) {
|
|
428
|
+
let s = 0;
|
|
429
|
+
if (dsp.playing && !dsp.paused) {
|
|
430
|
+
dspFrac += step;
|
|
431
|
+
while (dspFrac >= 1 && dsp.playing) {
|
|
432
|
+
dspFrac -= 1;
|
|
433
|
+
if (dsp.silent) dsp.lastSample = 0x80;
|
|
434
|
+
else { const b = dmaByte(); if (b >= 0) dsp.lastSample = b; }
|
|
435
|
+
if (--dsp.left <= 0) {
|
|
436
|
+
dsp.irqPending = true; raiseIrq(irq);
|
|
437
|
+
if (dsp.auto && !dsp.exitAuto) dsp.left = dsp.blockSize + 1;
|
|
438
|
+
else { dsp.playing = false; dsp.silent = false; dsp.lastSample = 0x80; }
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (dsp.speaker) s = (dsp.lastSample - 128) / 128 * 0.9;
|
|
443
|
+
push(scratch[i * 2] + s, scratch[i * 2 + 1] + s);
|
|
444
|
+
}
|
|
445
|
+
n -= k; off += k;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Take up to `max` frames of output (interleaved stereo), or all there is. */
|
|
450
|
+
function drain(max = count) {
|
|
451
|
+
const n = Math.min(max, count);
|
|
452
|
+
const out = new Float32Array(n * 2);
|
|
453
|
+
for (let i = 0; i < n; i++) { const at = (head + i) % ring; out[i * 2] = buf[at * 2]; out[i * 2 + 1] = buf[at * 2 + 1]; }
|
|
454
|
+
head = (head + n) % ring; count -= n;
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return { opl, dsp, dmac, mixer, portIn, portOut, tick, drain, get buffered() { return count; }, rate };
|
|
459
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// TETRUST, the game (operator, 2026-09-12: "a playable tetris game using our 3d Engine ...
|
|
2
|
+
// 'Tetrust' is a play on words for Bitcoiners to always trust and verify").
|
|
3
|
+
//
|
|
4
|
+
// This file is the RULES and nothing else: no DOM, no canvas, no clock. A game is a plain object,
|
|
5
|
+
// every move is a function on it, and the random piece order comes from a seed -- so the whole of
|
|
6
|
+
// it runs under node:test and a board state can be asserted cell by cell. What is drawn (and when)
|
|
7
|
+
// is tetrust.js's business; it asks this file for the tiles and hands them to the 3D engine.
|
|
8
|
+
//
|
|
9
|
+
// Coordinates follow the engine's board: x runs left to right, y runs UP from row 0 on the floor.
|
|
10
|
+
// A piece falls toward y = 0. That is the opposite of most Tetris code, and it is on purpose --
|
|
11
|
+
// the engine draws row 0 at the bottom of the screen, so the rules speak the renderer's language
|
|
12
|
+
// rather than flipping on the way out.
|
|
13
|
+
|
|
14
|
+
export const COLS = 10;
|
|
15
|
+
export const ROWS = 20;
|
|
16
|
+
|
|
17
|
+
// Seven pieces, four orientations each, as cells in a 4x4 box (x right, y up). Spelled out rather
|
|
18
|
+
// than rotated by formula: a rotation table is a fact you can read, a matrix is a thing you debug.
|
|
19
|
+
export const PIECES = Object.freeze({
|
|
20
|
+
I: { color: '#3ec9ff', rot: [[[0, 2], [1, 2], [2, 2], [3, 2]], [[2, 0], [2, 1], [2, 2], [2, 3]], [[0, 1], [1, 1], [2, 1], [3, 1]], [[1, 0], [1, 1], [1, 2], [1, 3]]] },
|
|
21
|
+
O: { color: '#f5d142', rot: [[[1, 1], [2, 1], [1, 2], [2, 2]], [[1, 1], [2, 1], [1, 2], [2, 2]], [[1, 1], [2, 1], [1, 2], [2, 2]], [[1, 1], [2, 1], [1, 2], [2, 2]]] },
|
|
22
|
+
T: { color: '#b06bff', rot: [[[0, 1], [1, 1], [2, 1], [1, 2]], [[1, 0], [1, 1], [1, 2], [2, 1]], [[0, 1], [1, 1], [2, 1], [1, 0]], [[1, 0], [1, 1], [1, 2], [0, 1]]] },
|
|
23
|
+
S: { color: '#2ecc8f', rot: [[[1, 2], [2, 2], [0, 1], [1, 1]], [[1, 2], [1, 1], [2, 1], [2, 0]], [[1, 2], [2, 2], [0, 1], [1, 1]], [[1, 2], [1, 1], [2, 1], [2, 0]]] },
|
|
24
|
+
Z: { color: '#ef5a5a', rot: [[[0, 2], [1, 2], [1, 1], [2, 1]], [[2, 2], [2, 1], [1, 1], [1, 0]], [[0, 2], [1, 2], [1, 1], [2, 1]], [[2, 2], [2, 1], [1, 1], [1, 0]]] },
|
|
25
|
+
J: { color: '#3b82f6', rot: [[[0, 2], [0, 1], [1, 1], [2, 1]], [[1, 2], [2, 2], [1, 1], [1, 0]], [[0, 1], [1, 1], [2, 1], [2, 0]], [[1, 2], [1, 1], [1, 0], [0, 0]]] },
|
|
26
|
+
L: { color: '#f7931a', rot: [[[2, 2], [0, 1], [1, 1], [2, 1]], [[1, 2], [1, 1], [1, 0], [2, 0]], [[0, 1], [1, 1], [2, 1], [0, 0]], [[0, 2], [1, 2], [1, 1], [1, 0]]] },
|
|
27
|
+
});
|
|
28
|
+
export const KINDS = Object.keys(PIECES);
|
|
29
|
+
|
|
30
|
+
// Scoring is the classic table, times the level: the reward for clearing four at once is well
|
|
31
|
+
// over four singles, which is the whole reason to leave a column open and wait for the I.
|
|
32
|
+
export const LINE_SCORE = [0, 100, 300, 500, 800];
|
|
33
|
+
export const LINES_PER_LEVEL = 10;
|
|
34
|
+
|
|
35
|
+
/** How long a piece rests on a row before gravity takes it down one, at this level. */
|
|
36
|
+
export function gravityMs(level) {
|
|
37
|
+
// brisk from the first level (operator: "Smaller, faster, playable!"): 700 ms a row at level
|
|
38
|
+
// one, 65 ms quicker each level, never under 80
|
|
39
|
+
return Math.max(80, 700 - (level - 1) * 65);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A small seeded generator, so a game's piece order is reproducible under test.
|
|
43
|
+
function rng(seed) {
|
|
44
|
+
let s = (seed >>> 0) || 1;
|
|
45
|
+
return () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// THE SEVEN-BAG: every piece once, in a shuffled order, then a fresh bag. It is what keeps a game
|
|
49
|
+
// fair -- no run of five S pieces, and never more than twelve pieces between one I and the next.
|
|
50
|
+
function fillBag(g) {
|
|
51
|
+
const bag = [...KINDS];
|
|
52
|
+
for (let i = bag.length - 1; i > 0; i--) { const j = Math.floor(g.rnd() * (i + 1)); [bag[i], bag[j]] = [bag[j], bag[i]]; }
|
|
53
|
+
g.queue.push(...bag);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function newGame(seed = Date.now()) {
|
|
57
|
+
const g = {
|
|
58
|
+
// board[y][x] = { k: kind, id } | null. Every locked cell carries an ID it keeps as rows
|
|
59
|
+
// shift down after a clear: the renderer animates a vanished id as a departure (the cleared
|
|
60
|
+
// line drifting away) and a moved id as a move, so only the cleared cells leave.
|
|
61
|
+
board: Array.from({ length: ROWS }, () => new Array(COLS).fill(null)),
|
|
62
|
+
queue: [], rnd: rng(seed), seed,
|
|
63
|
+
cur: null, // { kind, rot, x, y }
|
|
64
|
+
score: 0, lines: 0, level: 1, pieces: 0, nextId: 1,
|
|
65
|
+
over: false,
|
|
66
|
+
cleared: [], // rows cleared by the last lock, for the renderer to celebrate
|
|
67
|
+
clearedCells: [], // ...and the cells that were on them: { x, y, k, id }
|
|
68
|
+
};
|
|
69
|
+
fillBag(g);
|
|
70
|
+
spawn(g);
|
|
71
|
+
return g;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The cells a piece occupies at a position and orientation, in board coordinates. */
|
|
75
|
+
export function cellsOf(kind, rot, x, y) {
|
|
76
|
+
return PIECES[kind].rot[((rot % 4) + 4) % 4].map(([cx, cy]) => [x + cx, y + cy]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** True if any cell is outside the well or on a locked block. Above the well counts as blocked. */
|
|
80
|
+
export function collides(g, cells) {
|
|
81
|
+
for (const [x, y] of cells) {
|
|
82
|
+
if (x < 0 || x >= COLS || y < 0 || y >= ROWS) return true;
|
|
83
|
+
if (g.board[y][x]) return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function spawn(g) {
|
|
89
|
+
if (g.queue.length < 7) fillBag(g);
|
|
90
|
+
const kind = g.queue.shift();
|
|
91
|
+
const cur = { kind, rot: 0, x: 3, y: ROWS - 4 };
|
|
92
|
+
g.cur = cur;
|
|
93
|
+
g.pieces++;
|
|
94
|
+
// GAME OVER is a piece that cannot even appear: the stack has reached the top of the well
|
|
95
|
+
if (collides(g, cellsOf(kind, 0, cur.x, cur.y))) { g.over = true; g.cur = null; }
|
|
96
|
+
return cur;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function peekNext(g) { return g.queue[0]; }
|
|
100
|
+
|
|
101
|
+
/** Slide left or right; a blocked slide simply does not happen. */
|
|
102
|
+
export function move(g, dx) {
|
|
103
|
+
if (!g.cur || g.over) return false;
|
|
104
|
+
const c = g.cur;
|
|
105
|
+
if (collides(g, cellsOf(c.kind, c.rot, c.x + dx, c.y))) return false;
|
|
106
|
+
c.x += dx;
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Rotate, with WALL KICKS: a rotation that lands in a wall or a block is tried again shifted one
|
|
112
|
+
* then two cells either way, and one cell down. Without kicks a piece against the wall cannot
|
|
113
|
+
* turn, which is the single most common "this game feels broken" in a Tetris.
|
|
114
|
+
*/
|
|
115
|
+
export function rotate(g, dir = 1) {
|
|
116
|
+
if (!g.cur || g.over) return false;
|
|
117
|
+
const c = g.cur;
|
|
118
|
+
const rot = (((c.rot + dir) % 4) + 4) % 4;
|
|
119
|
+
for (const [kx, ky] of [[0, 0], [-1, 0], [1, 0], [-2, 0], [2, 0], [0, -1], [-1, -1], [1, -1]]) {
|
|
120
|
+
if (!collides(g, cellsOf(c.kind, rot, c.x + kx, c.y + ky))) { c.rot = rot; c.x += kx; c.y += ky; return true; }
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Where the piece would come to rest if dropped: the row the ghost is drawn on. */
|
|
126
|
+
export function ghostY(g) {
|
|
127
|
+
if (!g.cur) return null;
|
|
128
|
+
const c = g.cur;
|
|
129
|
+
let y = c.y;
|
|
130
|
+
while (!collides(g, cellsOf(c.kind, c.rot, c.x, y - 1))) y--;
|
|
131
|
+
return y;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The piece kind at a cell, or null. */
|
|
135
|
+
export function kindAt(g, x, y) { return g.board[y]?.[x]?.k ?? null; }
|
|
136
|
+
|
|
137
|
+
function lock(g) {
|
|
138
|
+
const c = g.cur;
|
|
139
|
+
for (const [x, y] of cellsOf(c.kind, c.rot, c.x, c.y)) g.board[y][x] = { k: c.kind, id: g.nextId++ };
|
|
140
|
+
// LINES: every full row goes, and everything above it comes down one
|
|
141
|
+
const full = [];
|
|
142
|
+
for (let y = 0; y < ROWS; y++) if (g.board[y].every(Boolean)) full.push(y);
|
|
143
|
+
// the cells of the lines, kept for the screen: they DRIFT UP AND AWAY (tetrust.js) while the
|
|
144
|
+
// rows above come down, so the board here has already forgotten them
|
|
145
|
+
g.clearedCells = full.flatMap((y) => g.board[y].map((cell, x) => ({ x, y, k: cell.k, id: cell.id })));
|
|
146
|
+
for (const y of [...full].reverse()) { g.board.splice(y, 1); g.board.push(new Array(COLS).fill(null)); }
|
|
147
|
+
g.cleared = full;
|
|
148
|
+
if (full.length) {
|
|
149
|
+
g.lines += full.length;
|
|
150
|
+
g.score += LINE_SCORE[full.length] * g.level;
|
|
151
|
+
g.level = 1 + Math.floor(g.lines / LINES_PER_LEVEL);
|
|
152
|
+
}
|
|
153
|
+
spawn(g);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** One step of gravity. Returns true if the piece locked (and so the board changed shape). */
|
|
157
|
+
export function tick(g) {
|
|
158
|
+
if (!g.cur || g.over) return false;
|
|
159
|
+
const c = g.cur;
|
|
160
|
+
if (!collides(g, cellsOf(c.kind, c.rot, c.x, c.y - 1))) { c.y--; g.cleared = []; return false; }
|
|
161
|
+
lock(g);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Soft drop: one row down now, and a point for it. */
|
|
166
|
+
export function softDrop(g) {
|
|
167
|
+
if (!g.cur || g.over) return false;
|
|
168
|
+
const c = g.cur;
|
|
169
|
+
if (collides(g, cellsOf(c.kind, c.rot, c.x, c.y - 1))) { lock(g); return true; }
|
|
170
|
+
c.y--; g.score += 1; g.cleared = [];
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Hard drop: straight to the floor, two points a row, and lock. */
|
|
175
|
+
export function hardDrop(g) {
|
|
176
|
+
if (!g.cur || g.over) return 0;
|
|
177
|
+
const c = g.cur;
|
|
178
|
+
const y = ghostY(g);
|
|
179
|
+
const fell = c.y - y;
|
|
180
|
+
c.y = y;
|
|
181
|
+
g.score += 2 * fell;
|
|
182
|
+
lock(g);
|
|
183
|
+
return fell;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The board as tiles for the 3D engine (board3d): every locked cell a stone in its piece's
|
|
188
|
+
* colour, the falling piece standing a little taller, and its GHOST -- where it will land -- as a
|
|
189
|
+
* thin dim plate on the floor. Ids are positional for the stack and fixed for the piece, so the
|
|
190
|
+
* renderer sees a still board that changes, not a board that reshuffles.
|
|
191
|
+
*/
|
|
192
|
+
// medium neon blue (operator, 2026-09-12: first "neon yellow wireframes", then "maybe medium neon
|
|
193
|
+
// blue at the bottom instead of bright neon yellow for tetrust block landing locations")
|
|
194
|
+
// The default. The colour is the CALLER's to choose (operator, 2026-09-12: "I want to be able to
|
|
195
|
+
// set the ghost wireframe color") -- this file stays pure and knows nothing of the settings store.
|
|
196
|
+
export const GHOST_WIRE = '#3d8bff';
|
|
197
|
+
|
|
198
|
+
export function tiles(g, wire = GHOST_WIRE) {
|
|
199
|
+
const out = [];
|
|
200
|
+
for (let y = 0; y < ROWS; y++) for (let x = 0; x < COLS; x++) {
|
|
201
|
+
const cell = g.board[y][x];
|
|
202
|
+
if (cell) out.push({ txid: `c${cell.id}`, x, y, s: 1, tall: 1, color: PIECES[cell.k].color });
|
|
203
|
+
}
|
|
204
|
+
if (g.cur && !g.over) {
|
|
205
|
+
const c = g.cur;
|
|
206
|
+
const gy = ghostY(g);
|
|
207
|
+
if (gy !== c.y) {
|
|
208
|
+
// THE GHOST IS A WIREFRAME (operator, 2026-09-12: "wireframes on teh bottom of the tetrust
|
|
209
|
+
// playfield, to show where the pieces are going to drop. The solid dark colored stuff is
|
|
210
|
+
// too difficult to see"): the outline of a full cube, no fill, in a colour nothing else on
|
|
211
|
+
// the board uses
|
|
212
|
+
cellsOf(c.kind, c.rot, c.x, gy).forEach(([x, y], i) => out.push({ txid: `g${i}`, x, y, s: 1, tall: 1, color: PIECES[c.kind].color, wire }));
|
|
213
|
+
}
|
|
214
|
+
// the falling piece carries the ids its cells will get when it locks (lock() hands them out in
|
|
215
|
+
// cellsOf order from nextId), so locking is the same four tiles settling, not four vanishing
|
|
216
|
+
// and four appearing
|
|
217
|
+
cellsOf(c.kind, c.rot, c.x, c.y).forEach(([x, y], i) => out.push({ txid: `c${g.nextId + i}`, x, y, s: 1, tall: 1.18, color: PIECES[c.kind].color, piece: true }));
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The next piece alone, centred in a 4x4 box, for the preview. */
|
|
223
|
+
export function previewTiles(kind) {
|
|
224
|
+
if (!kind) return [];
|
|
225
|
+
return cellsOf(kind, 0, 0, 0).map(([x, y], i) => ({ txid: `n${i}`, x, y, s: 1, tall: 1, color: PIECES[kind].color }));
|
|
226
|
+
}
|