blockyard 0.0.9 → 0.1.1

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 (97) hide show
  1. package/CHANGELOG.md +359 -1
  2. package/README.md +48 -27
  3. package/SECURITY.md +2 -2
  4. package/bin/blockyard.js +2 -1
  5. package/docs/API.md +17 -15
  6. package/docs/ARCHITECTURE.md +128 -10
  7. package/docs/CONFIGURATION.md +39 -30
  8. package/docs/DEFECTS.md +4 -1
  9. package/docs/GETTING-STARTED.md +18 -8
  10. package/docs/INSTALL.md +97 -37
  11. package/docs/MEASUREMENTS.md +147 -0
  12. package/docs/PLAN-SCORCHED-YARD.md +456 -0
  13. package/docs/PLAN-SKIES.md +142 -0
  14. package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
  15. package/docs/SECURITY.md +58 -22
  16. package/docs/TROUBLESHOOTING.md +44 -5
  17. package/docs/USER-GUIDE.md +505 -35
  18. package/package.json +4 -2
  19. package/public/404.html +1 -1
  20. package/public/css/app.css +393 -82
  21. package/public/donate-qr.png +0 -0
  22. package/public/index.html +353 -109
  23. package/public/js/agents.js +228 -51
  24. package/public/js/app.js +131 -16
  25. package/public/js/blockanoid.js +15 -7
  26. package/public/js/blockout.js +15 -7
  27. package/public/js/blockscene3d.js +230 -38
  28. package/public/js/charts.js +21 -21
  29. package/public/js/depthchart.js +31 -27
  30. package/public/js/details3d.js +1481 -73
  31. package/public/js/doom.js +31 -0
  32. package/public/js/dosaudio.js +48 -0
  33. package/public/js/dosgame.js +389 -0
  34. package/public/js/dosio.js +186 -0
  35. package/public/js/dospc.js +1353 -0
  36. package/public/js/dosworker.js +196 -0
  37. package/public/js/explorer.js +7 -1
  38. package/public/js/livingsky.js +494 -0
  39. package/public/js/login.js +8 -2
  40. package/public/js/markets.js +46 -8
  41. package/public/js/mining.js +314 -36
  42. package/public/js/panels.js +41 -28
  43. package/public/js/pricechart.js +14 -13
  44. package/public/js/quake.js +20 -0
  45. package/public/js/safenext.js +14 -0
  46. package/public/js/scorched.js +1051 -0
  47. package/public/js/scorchedai.js +227 -0
  48. package/public/js/scorchedair.js +286 -0
  49. package/public/js/scorchedfx.js +376 -0
  50. package/public/js/scorchedshop.js +105 -0
  51. package/public/js/scorchedwind.js +69 -0
  52. package/public/js/scorchedyard.js +1338 -0
  53. package/public/js/settings.js +368 -100
  54. package/public/js/soundcard.js +459 -0
  55. package/public/js/tetrust.js +15 -6
  56. package/public/js/tetsound.js +35 -5
  57. package/public/js/theme.js +235 -0
  58. package/public/js/wolf3d.js +22 -0
  59. package/public/js/x86.js +1978 -0
  60. package/scripts/check.js +46 -0
  61. package/scripts/donate-qr.py +12 -9
  62. package/scripts/dos-bench.js +56 -0
  63. package/scripts/index-build.js +9 -2
  64. package/scripts/pool-map.js +152 -36
  65. package/scripts/setup.js +142 -22
  66. package/scripts/shots.mjs +27 -0
  67. package/scripts/smoke.sh +7 -6
  68. package/scripts/tls.js +31 -0
  69. package/scripts/ui.js +4 -2
  70. package/server/auth/sessions.js +33 -13
  71. package/server/chain/blockfile.js +64 -5
  72. package/server/chain/index/build.js +451 -58
  73. package/server/chain/index/heights.js +29 -3
  74. package/server/chain/index/live.js +13 -7
  75. package/server/chain/index/rows.js +6 -1
  76. package/server/chain/index/store.js +28 -5
  77. package/server/chain/index/worker.js +23 -11
  78. package/server/collect/logparse.js +65 -18
  79. package/server/collect/markets.js +76 -7
  80. package/server/collect/mining.js +32 -0
  81. package/server/collect/monitor.js +54 -12
  82. package/server/collect/network.js +305 -0
  83. package/server/config.js +53 -22
  84. package/server/http/api.js +119 -18
  85. package/server/http/games.js +77 -0
  86. package/server/http/server.js +30 -5
  87. package/server/http/sse.js +53 -7
  88. package/server/main.js +66 -11
  89. package/server/rpc/allowlist.js +26 -0
  90. package/server/rpc/client.js +30 -2
  91. package/server/store/audit.js +6 -1
  92. package/server/store/history.js +19 -3
  93. package/server/store/ledger.js +15 -4
  94. package/server/tls/selfsigned.js +160 -0
  95. package/systemd/blockyard.service +41 -8
  96. package/docs/PRIVATE-LEADERBOARD.md +0 -230
  97. package/docs/STATE-2026-09-09.md +0 -200
@@ -0,0 +1,1338 @@
1
+ // SCORCHED YARD, the tab (operator, 2026-09-16: "re-creating the classic PC DOS game Scorched
2
+ // Earth using our engine ... at least 3 player. 1 Human Player and 2 AI Players ... as faithfully
3
+ // as possible, but make it look fabulous"). docs/PLAN-SCORCHED-YARD.md is the plan; this is M1
4
+ // and M2: the game, the roster, the items, and the shop between rounds.
5
+ //
6
+ // The rules live in scorched.js and know nothing of the screen; the computer players in
7
+ // scorchedai.js know nothing of it either. This file is the screen: the field is the same board3d
8
+ // the other games use, wide and low on the oblique camera, over the same sky canvas; the HUD beside
9
+ // it is the original's status line unrolled -- whose turn, angle, power, weapon, wind, cash, the
10
+ // items, and every tank's health -- and between rounds the overlay is the shop.
11
+ import { board3d } from './details3d.js';
12
+ import { paintBlasts, paintDeaths, paintDust, paintAim, paintSolution, paintShells } from './scorchedfx.js';
13
+ import { paintRipple, skyBrightness } from './scorchedwind.js';
14
+ import { makeFluid, stepFluid, setSolid, warmFluid, makeTracers, stepTracers, paintTracers } from './scorchedair.js';
15
+ import {
16
+ newGame, current, aim, fire, step, settled, nextRound, cycleWeapon, useItem, drive, landTiles, actorTiles, leader, buy,
17
+ trajectory, dirtAt, shellLook, simulateShot,
18
+ WEAPONS, WEAPON_ORDER, ITEMS, COLS, ROWS, TANK_W,
19
+ } from './scorched.js';
20
+ import { SHOP } from './scorchedshop.js';
21
+ import { decide, prepare, shop as aiShop } from './scorchedai.js';
22
+ import { loadSettings, setSetting, scorchedOptions, nextSky, SKY_LABELS } from './settings.js';
23
+ import * as sound from './tetsound.js';
24
+
25
+ const SCORES_KEY = 'blockyard.scorched.scores';
26
+ const KEEP = 10;
27
+
28
+ // TWO CANVASES OVER THE SKY. The land is 1,600-odd cubes (a cube per cell: scorched.js landTiles
29
+ // says why not a tile per run) and is redrawn only when the dirt changes; the actors -- tanks, the
30
+ // shells, the trace, falling dirt, fire, the blasts -- are a few dozen tiles on a transparent
31
+ // canvas over it, redrawn every frame of a flight. Same camera on both, so they register exactly.
32
+ // `still`, as every game here is: a key press lands where you pressed it, and nothing flies but
33
+ // what the rules say flies.
34
+ const FIELD = {
35
+ gridW: COLS, gridH: ROWS,
36
+ oblique: { ox: 0.10, oy: 0.30, headroom: 3, flight: 0 },
37
+ dome: 0,
38
+ // THE FRONT OF THE BOARD IS THE PICTURE (operator, 2026-09-16: "We really need better
39
+ // illumination of the front of the board, it's dull and looks muted"). Overhead lit the tops of
40
+ // the cubes and left every face the player actually looks at on one flat shade. The lamp stands
41
+ // at the viewer, low, so the front faces carry the light and the strata separate.
42
+ light: 'front', lightHeight: 'low',
43
+ // and a hard lamp: the shipped range put every face within half a stop of every other and the
44
+ // board read as tinted dirt rather than lit dirt (operator: "too washed out")
45
+ // 1.7, not the 2.15 tried first: with the lamp at the viewer the front face comes out at about
46
+ // 1.1 of its own colour, and the face turned away at under 0.2. Higher pushed the front past
47
+ // 1.25, where a channel clips and a saturated colour goes pale -- which is the "washed out" the
48
+ // operator kept seeing. Vibrance is the strata's own saturation; the lamp only shades them.
49
+ lightGain: 1.7, topLight: 0.55,
50
+ gridStep: 4,
51
+ space: true,
52
+ background: 'rgba(0,0,0,0)',
53
+ spaceFloor: 'rgba(0,0,0,0.22)',
54
+ neonCell: 'rgba(60,200,140,0.14)',
55
+ };
56
+ const ACTORS = { ...FIELD, grid: false, spaceFloor: 'rgba(0,0,0,0)', neonCell: 'rgba(0,0,0,0)', overlay: paintOver };
57
+ 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 };
58
+
59
+ const AI_THINK_MS = 700; // a computer player's pause before it fires
60
+ const FALL_MS_PER_CELL = 55; // how long a run of dirt takes to fall each cell (plus a base)
61
+ const FALL_BASE_MS = 180;
62
+ const BLAST_MS = 650;
63
+ const SMOKE_MS = 2400; // the smoke after a blast, rising and drifting on the wind
64
+ const DEATH_MS = 1100; // a tank's pieces and sparks
65
+ const DUST_MS = 700; // dust where fallen dirt lands
66
+ const TALK_MS = 2400;
67
+ const FIRE_MS = 2600; // how long napalm burns on screen
68
+ const BEAM_MS = 420; // how long a laser's line stays
69
+ const WIND_MS = 33; // the wind redraws at 30 a second whatever else is happening
70
+ const DEMO_ROUND_MS = 4200; // attract mode: how long the round's scoreboard stands before the next
71
+ const DEMO_WAR_MS = 7000; // and how long the war's result stands before a fresh one
72
+
73
+ const G = {
74
+ game: null, running: false, paused: false, why: '', raf: null, last: 0, dirty: true, bound: false,
75
+ state: null, h: null,
76
+ html: {}, // what each panel last had written into it, so it is not rewritten every frame
77
+ rep: null, // the held key's ramp: { key, n, at }
78
+ lastShot: null, // what the human fired last, for R
79
+ lastWeapon: null, // the human's last weapon fired, for Q
80
+ target: null, // the tank id the human marked, for the correction helper
81
+ impact: null, // where the human's last shot came down: { x, y, hit, shooterX }
82
+ confirmAt: 0, // when fire was pressed once on the last of a kind
83
+ weaponsOpen: false, // the weapon grid is showing
84
+ editing: null, // 'angle' | 'power' while a number is being typed
85
+ paintNow: 0, // the instant the overlay layer paints at
86
+ flowAt: 0, // the last frame's clock, for the step
87
+ airTravel: 0, // the signed distance the air has run, for the ripple and the bands
88
+ windDrawnAt: 0, // when the wind plane last drew: it is held to thirty a second
89
+ skyLight: 0, skyLightAt: 0, // how bright the sky behind the air is, sampled now and then
90
+ fluid: null, fluidLand: '', tracers: null, // the simulated air, the land it was given, and the streaklines carried by it
91
+ fluidTops: null, fluidRows: null, fluidVersion: -1, // the land's heights and floor rows when the air last read it, for crater deltas
92
+ windEased: undefined, // the wind the flow is actually blowing at: it bends into a change
93
+ windShown: undefined, // the last wind the game reported, to date a change
94
+ windAtChange: 0, // when it changed, for the banner's brightness
95
+ windAt: 0, // when the wind layer last moved, so it keeps blowing while the board is idle
96
+ aiAt: 0, // when the computer's turn began, for the pause before it fires
97
+ aiPlan: null, // { tank, at, from, to }: the shot a computer player chose as its turn began
98
+ settleT0: 0, settleMs: 0, // the fall on screen
99
+ landKey: '', // what the land canvas last drew: the land version and whether dirt is falling
100
+ blasts: [], // { id, x, y, r, t0, big } pictures of the blasts, drained as they end
101
+ deaths: [], // { id, x, y, colour, t0 } a tank going up
102
+ dusts: [], // { id, x, y, t0 } where fallen dirt landed
103
+ falls: new Map(), // tank id -> { from, to, t0, ms, chute } a tank on its way down
104
+ talkTimer: null,
105
+ demoTimer: null, // attract mode's wait between a round and the next, and between wars
106
+ fires: [], // { cells, t0 } napalm on the ground, drained as it burns out
107
+ beams: [], // { x0, y0, x1, y1, t0 } laser lines
108
+ drag: null, // a mouse aim in progress
109
+ dragClickAt: 0, // when the drag last clicked, so it ticks rather than machine-guns
110
+ shopping: false, // the shop is open between rounds
111
+ seq: 0,
112
+ };
113
+
114
+ function opts(base) {
115
+ const t = scorchedOptions(loadSettings());
116
+ return {
117
+ ...base,
118
+ space: true, idleFx: false, shadows: false,
119
+ hover: false, // a playfield does not light up under the pointer
120
+ stars: false, galaxy: false, // the sky is the panel's canvas behind the field
121
+ ...t.gridOpts, grid: base.grid === false ? false : t.grid,
122
+ edges: true, facetPx: Infinity, crownPx: Infinity, sheen: false,
123
+ neon: false,
124
+ still: true,
125
+ transition: { rise: 0, travel: 1, drop: 0 },
126
+ };
127
+ }
128
+
129
+ // ------------------------------------------------------------------ scores, this browser's
130
+ export function loadScores(storage = globalThis.localStorage) {
131
+ try {
132
+ const raw = storage?.getItem(SCORES_KEY);
133
+ const list = raw ? JSON.parse(raw) : [];
134
+ return Array.isArray(list) ? list.filter((r) => r && Number.isFinite(r.score)).slice(0, KEEP) : [];
135
+ } catch { return []; }
136
+ }
137
+ export function recordScore(entry, storage = globalThis.localStorage) {
138
+ const list = [...loadScores(storage), { score: entry.score, kills: entry.kills, rounds: entry.rounds, won: !!entry.won, at: entry.at ?? Date.now() }]
139
+ .sort((a, b) => b.score - a.score || b.kills - a.kills)
140
+ .slice(0, KEEP);
141
+ try { storage?.setItem(SCORES_KEY, JSON.stringify(list)); } catch { /* private mode, quota */ }
142
+ return list;
143
+ }
144
+ export function rankOf(score, list) {
145
+ const i = list.findIndex((r) => score > r.score);
146
+ if (i >= 0) return i + 1;
147
+ return list.length < KEEP ? list.length + 1 : null;
148
+ }
149
+
150
+ // ------------------------------------------------------------------ the pictures
151
+ function shadeTo(hex, k) {
152
+ const h = hex.replace('#', '');
153
+ const ch = (i) => Math.max(0, Math.min(255, Math.round(parseInt(h.slice(i, i + 2), 16) * k)));
154
+ return `#${[ch(0), ch(2), ch(4)].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
155
+ }
156
+
157
+ const mixHex = (a, b, k) => {
158
+ const p = (h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
159
+ const x = p(a), y = p(b);
160
+ return `#${x.map((v, i) => Math.max(0, Math.min(255, Math.round(v + (y[i] - v) * k))).toString(16).padStart(2, '0')).join('')}`;
161
+ };
162
+ const hash = (n) => { const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); };
163
+
164
+ /** What is left of a tank on the tile layer once its fire is painted: three pieces of hull, tumbling. */
165
+ export function deathTiles(deaths, now, ms = DEATH_MS) {
166
+ const out = [];
167
+ for (const d of deaths) {
168
+ const t = Math.min(1, Math.max(0, (now - d.t0) / ms));
169
+ if (t >= 1) continue;
170
+ for (let i = 0; i < 3; i++) {
171
+ const h = (k) => hash(d.id * 23 + i * 11 + k);
172
+ const a = Math.PI * (0.25 + 0.5 * h(1)), sp = 4 + h(2) * 6;
173
+ const x = d.x + Math.cos(a) * sp * t * (i - 1), y = d.y + 0.6 + Math.sin(a) * sp * t - 9 * t * t;
174
+ if (y < 0) continue;
175
+ out.push({ txid: `piece${d.id}_${i}`, x: x - 0.4, y: y - 0.4, s: 0.8, tall: 0.01, color: shadeTo(d.colour, 0.8), poly: [[-0.4, -0.2], [0.35, -0.3], [0.4, 0.2], [-0.3, 0.35]], rot: t * (4 + i * 3) * (i % 2 ? 1 : -1) });
176
+ }
177
+ }
178
+ return out;
179
+ }
180
+
181
+
182
+ /** Where each falling tank is at `now`: from its old height to its new, by gravity or under a parachute. */
183
+ export function fallingTanks(falls, now) {
184
+ const tankY = new Map(), chutes = new Set();
185
+ for (const [id, f] of falls) {
186
+ const t = Math.min(1, Math.max(0, (now - f.t0) / f.ms));
187
+ const e = f.chute ? t : t * t;
188
+ tankY.set(id, f.from + (f.to - f.from) * e);
189
+ if (f.chute && t < 1) chutes.add(id);
190
+ }
191
+ return { tankY, chutes };
192
+ }
193
+
194
+ /** Napalm on screen: each burning cell a lit cube that flickers and dies down over FIRE_MS. */
195
+ export function fireTiles(fires, now, ms = FIRE_MS) {
196
+ const out = [];
197
+ for (const f of fires) {
198
+ const age = now - f.t0;
199
+ if (age >= ms) continue;
200
+ const seen = new Set();
201
+ for (const c of f.cells) {
202
+ const key = `${c.x},${c.y}`;
203
+ if (seen.has(key)) continue;
204
+ seen.add(key);
205
+ const lit = Math.min(1, age / 200) * Math.max(0, 1 - age / ms) * (0.75 + 0.25 * Math.sin(age / 60 + c.x * 1.7));
206
+ if (lit <= 0.02) continue;
207
+ out.push({ txid: `fire${f.id}_${key}`, x: c.x, y: c.y, s: 1, tall: 0.6 + 0.5 * lit, color: shadeTo(lit > 0.5 ? '#ffb03a' : '#c8401e', 0.5 + lit * 0.5) });
208
+ }
209
+ }
210
+ return out;
211
+ }
212
+
213
+ /** A laser on screen: a string of hot beads along the line, fading over BEAM_MS. */
214
+ export function beamTiles(beams, now, ms = BEAM_MS) {
215
+ const out = [];
216
+ for (const b of beams) {
217
+ const t = Math.min(1, Math.max(0, (now - b.t0) / ms));
218
+ if (t >= 1) continue;
219
+ const len = Math.hypot(b.x1 - b.x0, b.y1 - b.y0);
220
+ const n = Math.max(2, Math.min(80, Math.round(len * 1.5)));
221
+ for (let i = 0; i <= n; i++) {
222
+ const k = i / n;
223
+ const x = b.x0 + (b.x1 - b.x0) * k, y = b.y0 + (b.y1 - b.y0) * k;
224
+ if (y < 0 || y > ROWS + 2) continue;
225
+ out.push({ txid: `beam${b.id}_${i}`, x: x - 0.2, y: y - 0.2, s: 0.4, tall: 0.4, sphere: true, color: shadeTo(i % 2 ? '#ff5a5a' : '#fff0f0', 1 - t) });
226
+ }
227
+ }
228
+ return out;
229
+ }
230
+
231
+ /**
232
+ * THE WIND MADE VISIBLE (operator, 2026-09-16: "some sort of effects that reflect the change in
233
+ * wind speed"; then "the dots don't convey enough motion"; then "it's swaying back and forth and
234
+ * clipping vs the terrain. It should be drawn behind everything else on a flat plane").
235
+ *
236
+ * Three tries. Dots in the scene said nothing about speed. Streaks in the scene said it, but they
237
+ * were tiles in a 3D board: they bobbed as they crossed, and a tile at a cell the land also
238
+ * occupies is drawn over the dirt, so they cut into the hills. Both faults come from putting the
239
+ * air in the same space as the ground.
240
+ *
241
+ * So the wind is its OWN FLAT LAYER, a plain 2D canvas between the sky and the land. It knows
242
+ * nothing about cells, the camera or the terrain: streaks run dead level from one side to the
243
+ * other, and the land canvas in front of them hides whatever passes behind a hill, which is
244
+ * exactly what should happen. Nothing sways; nothing clips.
245
+ *
246
+ * A streak is a tapered dash whose LENGTH is the speed -- a breeze draws short marks, a gale long
247
+ * ones -- and they run in three bands of depth: the far ones short, dim and slow, the near ones
248
+ * long, pale and quick, so the air has thickness. A slow gust wave lengthens and quickens them
249
+ * together. Nothing at all in still air.
250
+ */
251
+ const hashAt = (i) => (k) => { const x = Math.sin((i * 7 + k) * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); };
252
+ // Three depths. The far band is short, dim and slow; the near one longer, paler and quicker. The
253
+ // colours are mid greys on purpose: pale enough to read against a night sky, dark enough to read
254
+ // against the Living sky's blue, and never so bright that a streak looks like a tracer round.
255
+ // THE BANNER: the wind written on the field itself, at the top, so the answer to "which way, how
256
+ // hard" is there the moment the turn begins rather than after the air has drifted enough to read.
257
+ // Chevrons pointing downwind, as many as the wind is strong, bright while it is new.
258
+ export function windBanner(wind, w, h, fresh = 0) {
259
+ const strength = Math.min(1, Math.abs(wind ?? 0) / 10);
260
+ if (!w || !h || strength < 0.03) return [];
261
+ const dir = wind < 0 ? -1 : 1;
262
+ const n = 1 + Math.round(strength * 6);
263
+ const size = Math.max(7, Math.min(16, w * 0.011));
264
+ const y = Math.max(10, h * 0.045);
265
+ const gap = size * 1.5;
266
+ const x0 = w / 2 - ((n - 1) * gap) / 2;
267
+ const out = [];
268
+ for (let i = 0; i < n; i++) {
269
+ const lead = 1 - i / (n + 1); // the leading chevron is the brightest
270
+ out.push({ x: x0 + i * gap, y, size, dir, alpha: (0.3 + 0.45 * lead) * (0.55 + 0.45 * fresh) });
271
+ }
272
+ return out;
273
+ }
274
+
275
+ export function paintBanner(ctx, chevrons) {
276
+ for (const c of chevrons) {
277
+ const back = -c.dir * c.size * 0.5, front = c.dir * c.size * 0.5;
278
+ ctx.strokeStyle = `rgba(236,240,248,${c.alpha.toFixed(3)})`;
279
+ ctx.lineWidth = Math.max(1.4, c.size * 0.18);
280
+ ctx.lineJoin = 'miter';
281
+ ctx.beginPath();
282
+ ctx.moveTo(c.x + back, c.y - c.size * 0.45);
283
+ ctx.lineTo(c.x + front, c.y);
284
+ ctx.lineTo(c.x + back, c.y + c.size * 0.45);
285
+ ctx.stroke();
286
+ }
287
+ }
288
+
289
+ /**
290
+ * The land's silhouette as the fluid's floor. The land canvas is drawn at the wrap's size and
291
+ * scaled up 1.085 about its bottom centre by CSS, the wind plane is not scaled, so each fluid cell's
292
+ * centre is mapped back through that scale before its pixel is read.
293
+ */
294
+ function fluidFloor(f, land, w, h) {
295
+ let data = null;
296
+ try { data = land.getContext('2d', { willReadFrequently: true }).getImageData(0, 0, land.width, land.height).data; } catch { return; }
297
+ const k = land.width / Math.max(1, land.clientWidth || w), LW = land.width, LH = land.height;
298
+ const cellW = w / f.nx, cellH = h / f.ny, S = 1.085;
299
+ f.floorRow = new Int16Array(f.nx);
300
+ setSolid(f, (x) => {
301
+ const xs = (x + 0.5) * cellW, xc = w / 2 + (xs - w / 2) / S;
302
+ let row = f.ny;
303
+ for (let y = 0; y < f.ny; y++) {
304
+ const ys = (y + 0.5) * cellH, yc = h - (h - ys) / S;
305
+ const px = Math.round(xc * k), py = Math.round(yc * k);
306
+ if (px < 0 || py < 0 || px >= LW || py >= LH) continue;
307
+ if (data[(py * LW + px) * 4 + 3] > 60) { row = y; break; }
308
+ }
309
+ f.floorRow[x] = row;
310
+ return row;
311
+ });
312
+ }
313
+
314
+ function drawWind(now) {
315
+ const c = el('syWind');
316
+ if (!c) return;
317
+ // AT MOST THIRTY TIMES A SECOND (operator, 2026-09-16: "performance freezing and jittering when the
318
+ // cubes are being blown up"). The field redraws at sixty while a shell flies and a blast plays, and
319
+ // this plane went with it; the air moves slowly enough that half the frames show nothing new.
320
+ if (now - (G.windDrawnAt ?? 0) < WIND_MS - 4) return;
321
+ G.windDrawnAt = now;
322
+ const w = c.clientWidth, h = c.clientHeight;
323
+ if (!w || !h) return;
324
+ const dpr = Math.min(2, window.devicePixelRatio || 1);
325
+ const pw = Math.round(w * dpr), ph = Math.round(h * dpr);
326
+ if (c.width !== pw || c.height !== ph) { c.width = pw; c.height = ph; }
327
+ const ctx = c.getContext('2d');
328
+ if (!ctx) return;
329
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
330
+ const wind = G.game?.wind ?? 0;
331
+ // THE WIND EASES INTO ITS NEW VALUE rather than being swapped for it (operator: "the old wind
332
+ // needs to fade out when ending, and new wind needs to draw in"). The field is advected, so the
333
+ // streaks BEND into the new speed and direction over about half a second instead of jumping --
334
+ // which is what a change of wind looks like, and it needs no crossfade of two layers.
335
+ const dt = G.flowAt ? Math.min(120, now - G.flowAt) : 16;
336
+ G.flowAt = now;
337
+ if (G.windEased === undefined) G.windEased = wind;
338
+ else G.windEased += (wind - G.windEased) * Math.min(1, dt / 420);
339
+ if (Math.abs(wind - G.windEased) < 0.02) G.windEased = wind;
340
+ if (wind !== G.windShown) { G.windShown = wind; G.windAtChange = now; }
341
+ // THE SIGNED DISTANCE THE AIR HAS TRAVELLED: speed follows the eased wind's size and sign, so the
342
+ // ripple and the bands only ever move downwind, and bend round through zero when the wind turns
343
+ const we = G.windEased, strength = Math.min(1, Math.abs(we) / 10);
344
+ G.airTravel = (G.airTravel ?? 0) + (dt / 1000) * w * (0.02 + 0.2 * strength) * (we < 0 ? -1 : 1);
345
+ ctx.clearRect(0, 0, w, h);
346
+ // the sky, refracted: seen through moving air it bows in a ripple that rolls downwind
347
+ const sky = el('sySky');
348
+ if (sky && sky.width) {
349
+ const sr = sky.getBoundingClientRect(), cr = c.getBoundingClientRect();
350
+ const scale = sky.width / Math.max(1, sr.width);
351
+ paintRipple(ctx, sky, { sx: (cr.left - sr.left) * scale, sy: (cr.top - sr.top) * scale, scale }, w, h, G.airTravel, we, 12);
352
+ }
353
+ // THE AIR, SIMULATED (scorchedair.js): a small fluid pushed by the wind, with the land as its
354
+ // solid floor, carrying streaklines.
355
+ //
356
+ // THE FLOOR WITHOUT A STALL (operator, 2026-09-16: "performance freezing and jittering when the
357
+ // cubes are being blown up"). Measured on a nuke: every land change re-ran six simulated seconds
358
+ // of air (73 ms) and read the land canvas back from the GPU (6 ms), on top of the land's own
359
+ // redraw -- three times per blast, a 150 ms freeze each. Now the canvas is read, and the air warmed
360
+ // up, only when a NEW land arrives (a new game or a new round, or the panel is resized). A crater
361
+ // only moves the floor, and that comes from the rules' own column heights, which cost nothing.
362
+ if (!G.fluid) G.fluid = makeFluid(96, 48, 3);
363
+ const land = el('syLand');
364
+ const game = G.game;
365
+ const fieldKey = `${game?.seed}|${game?.round}|${w}x${h}`;
366
+ if (land && land.width && game && G.fluidLand !== fieldKey && G.landKey) {
367
+ fluidFloor(G.fluid, land, w, h);
368
+ G.fluidLand = fieldKey;
369
+ G.fluidTops = Int16Array.from(game.tops);
370
+ G.fluidRows = Int16Array.from(G.fluid.floorRow ?? []);
371
+ G.fluidVersion = game.landVersion;
372
+ warmFluid(G.fluid, 6, we);
373
+ G.tracers = makeTracers(G.fluid, 320, 5);
374
+ for (let i = 0; i < 40; i++) stepTracers(G.fluid, G.tracers, 33); // paths already drawn out when first seen
375
+ } else if (game && G.fluidTops && game.landVersion !== G.fluidVersion) {
376
+ // a crater, a fall, a pile of dirt: move each column's floor by the change in its height
377
+ G.fluidVersion = game.landVersion;
378
+ const rowsPerCell = G.fluid.ny / ROWS;
379
+ setSolid(G.fluid, (x) => {
380
+ const base = G.fluidRows[x] ?? G.fluid.ny;
381
+ return base + Math.round((G.fluidTops[x] - game.tops[x]) * rowsPerCell);
382
+ });
383
+ }
384
+ stepFluid(G.fluid, dt, { wind: we });
385
+ if (G.tracers) stepTracers(G.fluid, G.tracers, dt);
386
+ if (sky && now - (G.skyLightAt ?? 0) > 1500) { G.skyLight = skyBrightness(sky); G.skyLightAt = now; }
387
+ if (G.tracers) paintTracers(ctx, G.fluid, G.tracers, w, h, { bright: G.skyLight ?? 0, wind: we });
388
+ paintBanner(ctx, windBanner(wind, w, h, Math.max(0, 1 - (now - (G.windAtChange ?? 0)) / 2500)));
389
+ }
390
+
391
+ /** The cells of the runs still falling, as "x,y" keys: left off the land canvas while the actor canvas animates them. */
392
+ export function fallingCells(g) {
393
+ const set = new Set();
394
+ for (const f of g.falling ?? []) for (let k = 0; k < f.len; k++) set.add(`${f.x},${f.y + k}`);
395
+ return set;
396
+ }
397
+
398
+ /**
399
+ * The actor layer at `now`: the tanks, the shells and the trace, the dirt still falling (each cell
400
+ * a cube lifted by what it has left to fall, gathering speed), fire, beams, and the blasts over
401
+ * everything.
402
+ */
403
+ export function actorLayer(g, now, { settleT0 = 0, settleMs = 1, blasts = [], fires = [], beams = [], deaths = [], dusts = [], falls = new Map() } = {}) {
404
+ const { tankY, chutes } = fallingTanks(falls, now);
405
+ const out = actorTiles(g, { tankY, chutes });
406
+ const falling = g.falling ?? [];
407
+ if (falling.length && settleMs > 0) {
408
+ const t = Math.min(1, Math.max(0, (now - settleT0) / settleMs));
409
+ const e = t * t;
410
+ for (const f of falling) {
411
+ const lift = (f.from - f.y) * (1 - e);
412
+ for (let k = 0; k < f.len; k++) {
413
+ const y = f.y + k;
414
+ out.push({ txid: `c${f.x}:${y}`, x: f.x, y, s: 1, tall: 1, floor: lift, color: '#8a6a3f' });
415
+ }
416
+ }
417
+ }
418
+ out.push(...fireTiles(fires, now), ...beamTiles(beams, now), ...deathTiles(deaths, now));
419
+ return out;
420
+ }
421
+
422
+ // ------------------------------------------------------------------ the painted layer
423
+ // The fire, the smoke and the aim gauge are not tiles: they are drawn over the board through the
424
+ // renderer's own projector (details3d's `overlay` hook), with the same primitives the fireworks
425
+ // use. scorchedfx.js holds the drawing; this is the bridge -- what is burning, where, and when.
426
+ function paintOver(ctx, view, hx) {
427
+ const g = G.game;
428
+ if (!g) return;
429
+ const now = G.paintNow;
430
+ const P = (x, y, z = 1.2) => hx.project(x, y, z, view);
431
+ const o0 = P(0, 0), ox = P(1, 0), oy = P(0, 1);
432
+ const U = { x: Math.abs(ox.x - o0.x) || 8, y: Math.abs(oy.y - o0.y) || 8 };
433
+ const S = hx.softStops;
434
+ paintDust(ctx, P, U, G.dusts, now, { softStops: S, ms: DUST_MS });
435
+ paintBlasts(ctx, P, U, G.blasts, now, { wind: g.wind ?? 0, softStops: S, ms: BLAST_MS, smokeMs: SMOKE_MS });
436
+ paintDeaths(ctx, P, U, G.deaths, now, { softStops: S, ms: DEATH_MS });
437
+ paintShells(ctx, P, U, g.shells, now, { softStops: S, looks: shellLook });
438
+ // CHEAT MODE: the firing solution, redrawn every frame the aim moves. Clipped where the shell
439
+ // would meet the dirt or leave the field, so what is drawn is the shot, not a parabola over it.
440
+ const t = current(g);
441
+ // C4, THE AIM GUIDE: cheat mode draws the whole flight and the landing; otherwise the Aim guide
442
+ // setting draws the first fifth of it (enough to read the lean), the whole of it, or nothing
443
+ if (t && t.kind === 'human' && g.phase === 'aim' && !G.shopping) {
444
+ const o = scorchedOptions(loadSettings());
445
+ const guide = o.cheat ? 'cheat' : o.aimGuide;
446
+ if (guide !== 'off') {
447
+ const sol = solutionOf(g, t);
448
+ if (sol) {
449
+ const short = guide === 'short';
450
+ const pts = short ? sol.pts.slice(0, Math.max(3, Math.ceil(sol.pts.length * 0.2))) : sol.pts;
451
+ paintSolution(ctx, P, U, pts, { colour: guide === 'cheat' ? '255,224,140' : '220,232,255', impact: short ? null : sol.impact });
452
+ }
453
+ }
454
+ // the marked target: a ring on the ground under it
455
+ if (o.helper && G.target != null && g.tanks[G.target]?.alive) {
456
+ const tg = g.tanks[G.target], c = P(tg.x + TANK_W / 2, tg.y + 0.4, 1.2);
457
+ ctx.strokeStyle = 'rgba(255,120,90,0.85)';
458
+ ctx.lineWidth = Math.max(1.2, U.x * 0.12);
459
+ ctx.beginPath(); ctx.ellipse(c.x, c.y, U.x * 1.7, U.y * 0.9, 0, 0, Math.PI * 2); ctx.stroke();
460
+ }
461
+ }
462
+ // the gauge belongs to whoever is aiming, and only while they are aiming
463
+ if (t && g.phase === 'aim' && !G.shopping) {
464
+ paintAim(ctx, P, U, {
465
+ x: t.x + TANK_W / 2, y: t.y, angle: t.angle, power: t.power,
466
+ colour: t.colour,
467
+ dim: t.kind !== 'human',
468
+ });
469
+ }
470
+ }
471
+
472
+ /**
473
+ * The path the shell would take from where the barrel points now: the rules' own `trajectory`,
474
+ * stopped at the first cell of dirt it would enter or at the edge of the field. Answers the points
475
+ * and where it lands, or null when it flies off the board.
476
+ */
477
+ export function solutionOf(g, tank, secs = 12) {
478
+ const pts = trajectory(g, tank, secs);
479
+ const out = [];
480
+ for (const p of pts) {
481
+ if (p.x < 0 || p.x > COLS || p.y > ROWS + 40) { return out.length > 1 ? { pts: out, impact: null } : null; }
482
+ out.push(p);
483
+ if (p.y <= 0) return { pts: out, impact: { x: p.x, y: 0 } };
484
+ if (p.y < ROWS && dirtAt(g, Math.floor(p.x), Math.floor(p.y))) return { pts: out, impact: p };
485
+ }
486
+ return out.length > 1 ? { pts: out, impact: null } : null;
487
+ }
488
+
489
+ // ------------------------------------------------------------------ the screen
490
+ const el = (id) => document.getElementById(id);
491
+
492
+ // WRITING A PANEL ONLY WHEN IT CHANGED (2026-09-16: "i can't buy anything in the shop between
493
+ // rounds"). Each of these compared the markup it was about to write with `box.innerHTML` -- but the
494
+ // browser gives that back NORMALISED (`disabled` comes out as `disabled=""`, entities are re-coded),
495
+ // so the comparison never matched and the panel was rebuilt on every draw. Harmless while the board
496
+ // only drew on events; fatal once the wind made it draw thirty times a second, because a button
497
+ // replaced between the press and the release is a button that never gets clicked. The shop was
498
+ // unusable and the two typed readouts would have gone the same way.
499
+ //
500
+ // So each panel remembers the string IT wrote, and the scrolling one keeps its place.
501
+ export function setHtml(box, html, key) {
502
+ if (!box || G.html[key] === html) return false;
503
+ const top = box.scrollTop;
504
+ box.innerHTML = html;
505
+ G.html[key] = html;
506
+ if (top) box.scrollTop = top;
507
+ return true;
508
+ }
509
+
510
+
511
+ const money = (n) => `$${Math.round(n).toLocaleString()}`;
512
+
513
+ function overlay(msg, sub, button, dim = false) {
514
+ const ov = el('syOver'), m = el('syMsg'), s = el('sySub'), b = el('syResume');
515
+ if (!ov) return;
516
+ ov.classList.toggle('hidden', !msg);
517
+ ov.classList.toggle('dim', dim);
518
+ if (m) m.textContent = msg ?? '';
519
+ if (s) s.textContent = sub ?? '';
520
+ if (b) b.textContent = button ?? 'play';
521
+ }
522
+
523
+ const windArrow = (w) => (w > 0.05 ? '→' : w < -0.05 ? '←' : '·');
524
+
525
+ function drawStats() {
526
+ const g = G.game, box = el('syStats');
527
+ if (!box) return;
528
+ let rows;
529
+ if (!g) rows = [['round', '–'], ['turn', '–'], ['wind', '–'], ['angle', '–'], ['power', '–'], ['weapon', '–'], ['cash', '–']];
530
+ else {
531
+ // WHILE THE SHOP IS OPEN THE PANEL IS YOURS (operator, 2026-09-16: "my cash doesn't get
532
+ // subtracted when I purchase stuff"). The purchase always took the money; the panel was
533
+ // showing the tank whose turn it was when the round ended, and when that was a computer
534
+ // player its cash sat there unmoved while yours went down in the shop's own header. Between
535
+ // rounds the panel shows the human, who is the one shopping.
536
+ const you = g.tanks.find((k) => k.kind === 'human');
537
+ const t = G.shopping && you ? you : current(g);
538
+ const w = WEAPONS[t.weapon];
539
+ const count = t.weapon === 'babyMissile' ? '∞' : String(t.inventory[t.weapon] ?? 0);
540
+ rows = [
541
+ ['round', `${g.round} of ${g.rounds}`],
542
+ ['turn', G.shopping && you ? `${t.name} · shopping` : t.name],
543
+ ['wind', `${windArrow(g.wind)} ${Math.abs(g.wind).toFixed(1)}`],
544
+ ['angle', `${t.angle}°`],
545
+ ['power', String(t.power)],
546
+ ['weapon', `${w.name} × ${count}`],
547
+ ['cash', money(t.cash)],
548
+ ];
549
+ // C2: the tank you marked, and how your last shot did against it
550
+ const opt = scorchedOptions(loadSettings());
551
+ if (opt.helper && you && G.target != null) {
552
+ const tg = g.tanks[G.target];
553
+ if (tg?.alive) {
554
+ const c = G.impact ? correctionOf({ ...you, x: G.impact.from.x, power: G.impact.from.power }, tg, G.impact) : null;
555
+ const said = !c ? 'no shot yet' : c.word === 'hit' ? 'hit' : c.word === 'close' ? 'close' : `${Math.abs(c.miss)} ${c.word}`;
556
+ rows.push(['target', `${tg.name} \u00b7 ${said}`]);
557
+ }
558
+ }
559
+ }
560
+ if (G.editing) return; // a number is being typed: leave the panel alone
561
+ const html = rows.map(([k, v]) => `<i>${k}</i><b${k === 'angle' || k === 'power' ? ` class="syval" data-edit="${k}" title="click to type it"` : ''}>${v}</b>`).join('');
562
+ setHtml(box, html, 'stats');
563
+ const fireBtn = el('syFire');
564
+ if (fireBtn) {
565
+ fireBtn.disabled = !humanTurn();
566
+ // C5: the button says what it will send
567
+ const me = g ? current(g) : null;
568
+ let label = 'fire';
569
+ if (me && me.kind === 'human') {
570
+ const w = WEAPONS[me.weapon];
571
+ const n = me.weapon === 'babyMissile' ? '\u221e' : String(me.inventory[me.weapon] ?? 0);
572
+ label = performance.now() - G.confirmAt < 3000 && isLastOfKind(me) ? `confirm \u00b7 your last ${w.name}` : `fire \u00b7 ${w.name} \u00d7 ${n}`;
573
+ }
574
+ if (fireBtn.textContent !== label) fireBtn.textContent = label;
575
+ fireBtn.classList.toggle('syconfirm', label.startsWith('confirm'));
576
+ }
577
+ drawItems();
578
+ }
579
+
580
+ // THE ITEMS LINE: what the tank on turn carries, the shield that is up, and what is armed for the
581
+ // shot. Each is a pill; the keys beside them do the same as the pills.
582
+ function drawItems() {
583
+ const g = G.game, box = el('syItems');
584
+ if (!box) return;
585
+ const t = g ? current(g) : null;
586
+ if (!t) { if (box.innerHTML !== '') box.innerHTML = ''; return; }
587
+ const pills = [];
588
+ if (t.shield) pills.push(`<span class="sypill on" title="${ITEMS[t.shield.id].name}, ${t.shield.hp} left">🛡 ${t.shield.hp}</span>`);
589
+ for (const [id, n] of Object.entries(t.items)) {
590
+ if (!n) continue;
591
+ const it = ITEMS[id];
592
+ const armed = t.armed?.[id];
593
+ const key = { battery: 'B', shield: 'S', forceShield: 'S', heavyShield: 'S', fuel: 'A D', contactTrigger: 'T', heatGuidance: 'H' }[id];
594
+ // C3: the pills are buttons -- they already name themselves and their key; they take a click too
595
+ const use = { battery: 'battery', shield: 'shield', forceShield: 'shield', heavyShield: 'shield', contactTrigger: 'contactTrigger', heatGuidance: 'heatGuidance' }[id];
596
+ const tag = use ? 'button' : 'span';
597
+ pills.push(`<${tag}${use ? ` type="button" data-use="${use}"` : ''} class="sypill${armed ? ' on' : ''}${use ? ' sypillbtn' : ''}" title="${it.name}: ${it.note ?? ''}${key ? ` — ${key}` : ''}">${it.name} × ${n}${armed ? ' ✓' : ''}</${tag}>`);
598
+ }
599
+ const html = pills.join('') || '<span class="faint">no items — the shop opens between rounds</span>';
600
+ setHtml(box, html, 'items');
601
+ }
602
+
603
+ function drawTanks() {
604
+ const g = G.game, box = el('syTanks');
605
+ if (!box) return;
606
+ const html = g ? g.tanks.map((t) => {
607
+ const cur = g.phase !== 'over' && current(g) === t;
608
+ return `<div class="sytank${t.alive ? '' : ' dead'}${cur ? ' now' : ''}"><i class="sydot sydot-${t.id}"></i><b>${t.name}</b>`
609
+ + `<meter min="0" max="100" low="34" high="67" optimum="100" value="${t.health}" title="${t.health} health"></meter>`
610
+ + `<span>${t.alive ? `${t.health}${t.shield ? '🛡' : ''}` : '☠'}</span><small>${t.score} pts · ${t.kills} kills · ${money(t.cash)}</small></div>`;
611
+ }).join('') : '';
612
+ setHtml(box, html, 'tanks');
613
+ }
614
+
615
+ function drawScores(highlightAt = null) {
616
+ const t = el('syScores');
617
+ if (!t) return;
618
+ const list = loadScores();
619
+ const html = list.length
620
+ ? `<tr><th>#</th><th>score</th><th>kills</th><th>won</th><th>when</th></tr>` + list.map((r, i) =>
621
+ `<tr${r.at === highlightAt ? ' class="now"' : ''}><td>${i + 1}</td><td>${r.score.toLocaleString()}</td><td>${r.kills}</td><td>${r.won ? 'yes' : 'no'}</td><td>${new Date(r.at).toISOString().slice(0, 10)}</td></tr>`).join('')
622
+ : `<tr><td class="faint">no battles yet — fire the first shot</td></tr>`;
623
+ setHtml(t, html, 'scores');
624
+ }
625
+
626
+ // THE SHOP, between rounds: every weapon and item with its price and pack, what you own, and a
627
+ // buy button while you can afford it. Drawn into the overlay; the overlay's button goes on.
628
+ function drawShop() {
629
+ const g = G.game, box = el('syShop');
630
+ if (!box) return;
631
+ if (!G.shopping || !g) { box.classList.add('hidden'); G.html.shop = null; return; }
632
+ const you = g.tanks.find((t) => t.kind === 'human');
633
+ if (!you) { box.classList.add('hidden'); G.html.shop = null; return; }
634
+ box.classList.remove('hidden');
635
+ const row = (e) => {
636
+ const owned = (e.item ? you.items[e.id] : you.inventory[e.id]) ?? 0;
637
+ const can = you.cash >= e.price;
638
+ return `<div class="syrow${can ? '' : ' poor'}"><b>${e.name}</b><span class="syhint">${e.note ?? ''}</span>`
639
+ + `<span class="syprice">${money(e.price)} · ${e.pack}</span><span class="syown">${owned ? `have ${owned}` : ''}</span>`
640
+ + `<button type="button" class="btn sybuy" data-buy="${e.id}"${can ? '' : ' disabled'}>buy</button></div>`;
641
+ };
642
+ const weapons = SHOP.filter((e) => !e.item).map(row).join('');
643
+ const items = SHOP.filter((e) => e.item).map(row).join('');
644
+ const html = `<div class="syshophead">the shop <span class="sp"></span><b>${money(you.cash)}</b></div>`
645
+ + `<div class="sycols"><div><h4>Weapons</h4>${weapons}</div><div><h4>Items</h4>${items}</div></div>`;
646
+ setHtml(box, html, 'shop');
647
+ }
648
+
649
+ const SWITCHES = [['syMusic', 'music'], ['sySfx', 'sfx'], ['syTalkSw', 'talk'], ['syFast', 'fast'], ['syCheat', 'cheat'], ['syDemo', 'demo']];
650
+ function drawSwitches() {
651
+ const t = scorchedOptions(loadSettings());
652
+ // ONE SKY BUTTON (docs/PLAN-SKIES.md): Galaxy, Earth or none, round the ring, in place of the old
653
+ // star field and galaxy pair -- the same setting the Sky tab's table shows for this board
654
+ const sb = el('sySkySw');
655
+ if (sb) { sb.textContent = `\u2600 ${SKY_LABELS[t.sky.sky] ?? t.sky.sky}`; sb.classList.toggle('on', t.sky.sky !== 'none'); sb.setAttribute('aria-pressed', t.sky.sky !== 'none' ? 'true' : 'false'); }
656
+ for (const [id, key] of SWITCHES) {
657
+ const b = el(id);
658
+ if (!b) continue;
659
+ b.classList.toggle('on', !!t[key]);
660
+ b.setAttribute('aria-pressed', t[key] ? 'true' : 'false');
661
+ }
662
+ sound.setSfx(t.sfx);
663
+ // the march plays while a game runs; a pause holds it; the switch off stops it
664
+ sound.setMusic(t.music && G.running && !G.paused, 'scorched');
665
+ }
666
+ function flip(key) {
667
+ const cur = scorchedOptions(loadSettings())[key];
668
+ setSetting(loadSettings(), `scorched.${key}`, !cur);
669
+ sound.unlock();
670
+ drawSwitches();
671
+ if (key === 'demo') { clearTimeout(G.demoTimer); G.demoTimer = null; if (G.running) { start(); return; } }
672
+ G.dirty = true;
673
+ if (!G.running || G.paused) draw();
674
+ }
675
+
676
+ function drawSky() {
677
+ const sky = el('sySky');
678
+ if (!sky) return;
679
+ const t = scorchedOptions(loadSettings());
680
+ const earth = t.sky.skyType === 'earth';
681
+ board3d(sky, [], {
682
+ ...SKY,
683
+ ...t.sky, // which sky this board chose, and what it is made of (settings.js skyFor)
684
+ // the horizon is where the land is, not the bottom of the panel: the hills fill the lower
685
+ // third, so the sun and the moon set behind them rather than under them
686
+ skyHorizon: 0.58,
687
+ // the clouds of the Living sky drift with this round's wind, and turn with it
688
+ skyWind: G.game ? Math.sign(G.game.wind || 1) * (0.4 + Math.abs(G.game.wind) / 4) : 1,
689
+ // and each round draws its own hour of the Living sky (the original's sky changed each round)
690
+ ...(G.game && t.roundSky && earth ? { skyClock: 'fixed', skyHour: ROUND_HOURS[(G.game.seed + G.game.round * 7) % ROUND_HOURS.length] } : {}),
691
+ });
692
+ }
693
+ const ROUND_HOURS = [6.6, 9, 12, 15, 17.8, 19, 21.5, 1];
694
+
695
+ // ------------------------------------------------------------------ the controls (docs/PLAN-SCORCHED-YARD.md §12)
696
+ /**
697
+ * C2, THE CORRECTION. Where your last shot came down, against the tank you marked: how far short or
698
+ * over, along the line from your muzzle to it, and the power that would have carried it the right
699
+ * distance. Range goes roughly with the square of the power, so the correction scales the power by
700
+ * the square root of the distance ratio -- your own last shot's arithmetic, nothing solved: the
701
+ * wind and the hills are unchanged, and a changed angle makes it only an estimate.
702
+ */
703
+ export function correctionOf(shooter, target, impact) {
704
+ if (!shooter || !target || !impact) return null;
705
+ const mx = shooter.x + TANK_W / 2, tx = target.x + TANK_W / 2;
706
+ const side = tx >= mx ? 1 : -1;
707
+ const want = Math.abs(tx - mx);
708
+ const got = (impact.x - mx) * side;
709
+ if (impact.hit === target.id) return { miss: 0, word: 'hit', power: shooter.power };
710
+ const miss = Math.round(got - want);
711
+ const word = Math.abs(miss) <= 1 ? 'close' : miss < 0 ? 'short' : 'over';
712
+ const power = got > 0.5 ? Math.round(shooter.power * Math.sqrt(Math.max(0.5, want) / got)) : shooter.power + 150;
713
+ return { miss, word, power: Math.max(50, Math.min(1000, power)) };
714
+ }
715
+
716
+ /** C3, THE QUICK PICKS: the weapons a tank owns, in the shop's order -- key 1 is the first of them. */
717
+ export function ownedWeapons(tank) {
718
+ return WEAPON_ORDER.filter((w) => (tank.inventory[w] ?? 0) > 0);
719
+ }
720
+
721
+ /** C5: is this the last of a weapon sold one at a time (a Nuke, a Death's Head)? */
722
+ export function isLastOfKind(tank, weaponId = tank.weapon) {
723
+ const w = WEAPONS[weaponId];
724
+ return !!w && w.pack === 1 && (tank.inventory[weaponId] ?? 0) === 1;
725
+ }
726
+
727
+ /** The paint order of a resting grid of land cubes under this camera: back rows first, columns outside in. */
728
+ export function landOrder(p, q) {
729
+ const cx = COLS / 2;
730
+ return q.y - p.y || Math.abs(q.x + 0.5 - cx) - Math.abs(p.x + 0.5 - cx) || q.x - p.x;
731
+ }
732
+
733
+ function draw(now = performance.now()) {
734
+ const g = G.game;
735
+ G.paintNow = now; // the overlay paints at the frame's own instant
736
+ const field = el('syField'), land = el('syLand');
737
+ el('syFieldWrap')?.classList.toggle('idle', !g);
738
+ if (g) {
739
+ // the land, only when it changed: the version moves with every blast and settle, and the
740
+ // falling cells come off it while they are on their way down on the actor canvas
741
+ const settling = g.falling.length > 0;
742
+ const key = `${g.landVersion}:${settling ? 1 : 0}`;
743
+ if (land && key !== G.landKey) {
744
+ // THE LAND IN A KNOWN ORDER (operator, 2026-09-16: "performance freezing and jittering when the
745
+ // cubes are being blown up"). The renderer's general paint order builds an outline for every
746
+ // cube and tests pairs for overlap: right for blocks in flight, and 43 ms for this grid of
747
+ // 1,800 identical resting cubes, twice a blast. A resting grid has one correct order, found by
748
+ // diffing every pixel against the general sort: back rows first, and within a row the columns
749
+ // from the outside in, the right one first where two tie at the centre. Pixel-identical, 7 ms.
750
+ board3d(land, landTiles(g, { omit: settling ? fallingCells(g) : null }).sort(landOrder), { ...opts(FIELD), order: 'given' });
751
+ G.landKey = key;
752
+ }
753
+ drawWind(now);
754
+ if (field) board3d(field, actorLayer(g, now, { settleT0: G.settleT0, settleMs: G.settleMs, blasts: G.blasts, fires: G.fires, beams: G.beams, deaths: G.deaths, dusts: G.dusts, falls: G.falls }), opts(ACTORS));
755
+ }
756
+ drawStats();
757
+ drawTanks();
758
+ drawShop();
759
+ drawWeapons();
760
+ }
761
+
762
+ // ------------------------------------------------------------------ what the tanks say
763
+ // (M4; the original's tanks talked, "Nuke 'em" and the rest) -- a bubble over the field at the
764
+ // tank's place, for a couple of seconds, picked by a hash so the same moment says the same thing
765
+ const SAY = {
766
+ fire: ['Fire in the hole!', 'Eat this.', 'Incoming!', 'Say hello.', "Nuke 'em!", "This one's for you.", 'Watch this.', 'Bombs away.', 'Take that.'],
767
+ hurt: ['Ouch!', 'Hey!', "Is that all you've got?", "I'm hit!", "You'll pay for that.", 'Cheap shot.', "Just wait 'til my turn.", 'Missed the good bits.'],
768
+ death: ['Tell my wife…', 'I regret nothing.', 'Oops.', 'Argh!', 'Not like this.', 'Well played.', 'See you next round.'],
769
+ miss: ['Missed me!', 'Close.', 'Try again.', 'Ha!'],
770
+ };
771
+ function say(tank, kind, salt = 0) {
772
+ const box = el('syTalk');
773
+ if (!box || !tank || !scorchedOptions(loadSettings()).talk) return;
774
+ const lines = SAY[kind] ?? SAY.fire;
775
+ box.textContent = lines[Math.floor(hash(tank.id * 31 + (G.game?.shots ?? 0) * 7 + salt) * lines.length)];
776
+ box.style.setProperty('--x', `${((tank.x + TANK_W / 2) / COLS) * 100}%`);
777
+ box.style.setProperty('--y', `${(1 - (tank.y + 3.2) / ROWS) * 100}%`);
778
+ box.classList.remove('hidden');
779
+ if (G.talkTimer) clearTimeout(G.talkTimer);
780
+ G.talkTimer = setTimeout(() => box.classList.add('hidden'), TALK_MS);
781
+ }
782
+
783
+ // ------------------------------------------------------------------ what the rules report
784
+ function onEvents(events, now) {
785
+ const g = G.game;
786
+ for (const e of events) {
787
+ switch (e.kind) {
788
+ case 'fire': sound.play('syFire'); say(g.tanks[e.tank], 'fire'); break;
789
+ case 'blast':
790
+ G.blasts.push({ id: ++G.seq, x: e.x, y: e.y, r: e.radius, t0: now, big: e.radius >= 4, riot: !!e.riot });
791
+ sound.play(e.radius >= 4 ? 'syBig' : 'syBlast');
792
+ break;
793
+ case 'dirt': G.blasts.push({ id: ++G.seq, x: e.x, y: e.y, r: e.radius * 0.6, t0: now, riot: true }); sound.play('syDirt'); break;
794
+ case 'napalm': G.fires.push({ id: ++G.seq, cells: e.cells, t0: now }); sound.play('syBlast'); break;
795
+ case 'laser': G.beams.push({ id: ++G.seq, x0: e.x0, y0: e.y0, x1: e.x1, y1: e.y1, t0: now }); sound.play('syHit'); break;
796
+ case 'hit': if (e.damage > 0) sound.play('syHit'); if (e.damage >= 20 && g.tanks[e.tank].alive) say(g.tanks[e.tank], 'hurt', 1); break;
797
+ case 'fall': {
798
+ if (e.damage > 0) sound.play('syHit');
799
+ const tk = g.tanks[e.tank];
800
+ if (e.cells > 0.5) G.falls.set(e.tank, { from: tk.y + e.cells, to: tk.y, t0: now, ms: FALL_BASE_MS + FALL_MS_PER_CELL * e.cells, chute: false });
801
+ break;
802
+ }
803
+ case 'chute': {
804
+ sound.play('rotate');
805
+ const tk = g.tanks[e.tank];
806
+ G.falls.set(e.tank, { from: tk.y + e.cells, to: tk.y, t0: now, ms: 300 + 260 * e.cells, chute: true });
807
+ break;
808
+ }
809
+ case 'disrupt': sound.play('syDirt'); break;
810
+ case 'shield': sound.play('levelup'); break;
811
+ case 'shieldDown': sound.play('life'); break;
812
+ case 'battery': sound.play('clear'); break;
813
+ case 'death': { sound.play('syDeath'); const tk = g.tanks[e.tank]; G.deaths.push({ id: ++G.seq, x: tk.x + TANK_W / 2, y: tk.y, colour: tk.colour, t0: now }); say(tk, 'death', 2); G.h?.toast?.(`${tk.name} is destroyed`);
814
+ // knocked out with rounds still to play: say what the ways out are, rather than leaving a
815
+ // dead player watching the computer finish (operator, 2026-09-16)
816
+ if (tk.kind === 'human' && g.round < g.rounds) G.h?.toast?.('you are out for this round — restart (F2) for a new war, or watch it out');
817
+ break; }
818
+ case 'bounce': sound.play('wall'); break;
819
+ case 'turn': G.aiAt = now; sound.play('syTurn'); drawSky(); break;
820
+ case 'round': drawSky(); break;
821
+ case 'roundOver': roundOver(e); break;
822
+ default: break;
823
+ }
824
+ }
825
+ }
826
+
827
+ function startSettle(now) {
828
+ const g = G.game;
829
+ if (!g.falling.length) { settled(g); return; }
830
+ const drop = Math.max(...g.falling.map((f) => f.from - f.y));
831
+ G.settleT0 = now;
832
+ G.settleMs = FALL_BASE_MS + FALL_MS_PER_CELL * drop;
833
+ sound.play('syDirt');
834
+ }
835
+
836
+ // ------------------------------------------------------------------ the loop
837
+ function frame(t) {
838
+ G.raf = null;
839
+ if (!G.running) return;
840
+ if (document.hidden || G.state?.page !== 'scorched') { pause(document.hidden ? 'paused — you looked away' : 'paused — you changed tabs'); return; }
841
+ const dt = G.last ? Math.min(250, t - G.last) : 0;
842
+ G.last = t;
843
+ const g = G.game;
844
+ const fast = scorchedOptions(loadSettings()).fast;
845
+ const before = g.phase;
846
+ if (g.phase === 'flight') {
847
+ const events = step(g, fast ? dt * 3 : dt);
848
+ onEvents(events, t);
849
+ G.dirty = true;
850
+ if (g.phase === 'settle') startSettle(t);
851
+ }
852
+ if (g.phase === 'settle') {
853
+ G.dirty = true;
854
+ if (!g.falling.length || t - G.settleT0 >= G.settleMs) {
855
+ for (const f of g.falling.slice(0, 14)) if (f.from - f.y >= 2) G.dusts.push({ id: ++G.seq, x: f.x, y: f.y + f.len, t0: t });
856
+ settled(g);
857
+ onEvents(step(g, 0), t); // the turn event, or the round's end
858
+ }
859
+ }
860
+ if (g.phase === 'aim') {
861
+ const cur = current(g);
862
+ // THE COMPUTER DECIDES AT THE START OF ITS TURN, AND ITS BARREL SWINGS TO THE SHOT (operator,
863
+ // 2026-09-16: "The AI is trying to fire at opponents that are no longer on the board"). It used
864
+ // to decide only when its thinking pause ran out, so for the whole pause its gauge -- and in cheat
865
+ // mode its trajectory -- still showed its LAST shot, usually aimed at the tank it had just
866
+ // destroyed. It now plans the moment its turn begins, against the tanks alive then, and the
867
+ // barrel and the power ease from the old aim to the new one while it thinks, so what you watch
868
+ // it line up is what it fires.
869
+ if (cur.kind !== 'human') {
870
+ if (!G.aiPlan || G.aiPlan.tank !== cur.id || G.aiPlan.at !== G.aiAt) {
871
+ prepare(g, cur); // a battery, a shield, before the shot
872
+ G.aiPlan = { tank: cur.id, at: G.aiAt, from: { angle: cur.angle, power: cur.power }, to: decide(g, cur) };
873
+ }
874
+ const k = Math.min(1, (t - G.aiAt) / (AI_THINK_MS * 0.8));
875
+ const e = k * k * (3 - 2 * k);
876
+ const { from, to } = G.aiPlan;
877
+ aim(g, cur, { angle: from.angle + (to.angle - from.angle) * e, power: from.power + (to.power - from.power) * e });
878
+ G.dirty = true;
879
+ }
880
+ if (cur.kind !== 'human' && t - G.aiAt >= AI_THINK_MS) {
881
+ aim(g, cur, G.aiPlan.to);
882
+ G.aiPlan = null;
883
+ fire(g, cur);
884
+ onEvents(step(g, 0), t);
885
+ if (g.phase === 'settle') startSettle(t); // the laser is over at once
886
+ G.dirty = true;
887
+ }
888
+ }
889
+ if (G.blasts.length) { G.blasts = G.blasts.filter((b) => t - b.t0 < SMOKE_MS); G.dirty = true; }
890
+ if (G.deaths.length) { G.deaths = G.deaths.filter((d) => t - d.t0 < DEATH_MS); G.dirty = true; }
891
+ if (G.dusts.length) { G.dusts = G.dusts.filter((d) => t - d.t0 < DUST_MS); G.dirty = true; }
892
+ if (G.falls.size) { for (const [id, f] of G.falls) if (t - f.t0 >= f.ms) G.falls.delete(id); G.dirty = true; }
893
+ if (G.fires.length) { G.fires = G.fires.filter((f) => t - f.t0 < FIRE_MS); G.dirty = true; }
894
+ if (G.beams.length) { G.beams = G.beams.filter((b) => t - b.t0 < BEAM_MS); G.dirty = true; }
895
+ // THE WIND NEVER STOPS (operator: "the wind effects stop when nothing is animating"). The loop
896
+ // only drew when something had changed, so on your own turn -- the longest part of the game --
897
+ // the air froze mid-gust. The wind layer is its own reason to redraw, at its own rate, and the
898
+ // land canvas is cached, so this repaints the actors and nothing else.
899
+ if (Math.abs(g.wind ?? 0) >= 0.3 && t - G.windAt >= WIND_MS) { G.windAt = t; G.dirty = true; }
900
+ if (before !== g.phase) G.dirty = true;
901
+ if (G.dirty) { draw(t); G.dirty = false; }
902
+ if (g.phase !== 'over') G.raf = requestAnimationFrame(frame);
903
+ }
904
+
905
+ // THE OPPONENTS (M3): a mix climbs from the easy ones -- Shooter, Tosser, Chooser, Spoiler,
906
+ // Cyborg -- or every seat the one kind the setting names. Named for what they are, the manual's way.
907
+ const MIX = ['shooter', 'tosser', 'chooser', 'spoiler', 'cyborg', 'poolshark'];
908
+ const NAMES = { moron: 'Moron', shooter: 'Shooter', poolshark: 'Poolshark', tosser: 'Tosser', chooser: 'Chooser', spoiler: 'Spoiler', cyborg: 'Cyborg', unknown: 'Unknown' };
909
+ /**
910
+ * The seats. ATTRACT MODE (M5): with `demo` on there is no human seat -- the human's chair is
911
+ * taken by another computer player, so the war plays itself on a wall. Everything else is the
912
+ * same game: the same shop, the same rounds, the same rules.
913
+ */
914
+ export function players(t = scorchedOptions(loadSettings())) {
915
+ const list = t.demo ? [] : [{ name: 'You', kind: 'human' }];
916
+ const seen = {};
917
+ const seats = t.opponents + (t.demo ? 1 : 0);
918
+ for (let i = 0; i < seats; i++) {
919
+ const kind = t.opponentKind === 'mix' ? MIX[i % MIX.length] : t.opponentKind;
920
+ seen[kind] = (seen[kind] ?? 0) + 1;
921
+ list.push({ name: seen[kind] > 1 ? `${NAMES[kind] ?? kind} ${seen[kind]}` : (NAMES[kind] ?? kind), kind });
922
+ }
923
+ return list;
924
+ }
925
+
926
+ /** Attract mode is on and this game has nobody at the keys. */
927
+ const demoing = () => !!G.game && !G.game.tanks.some((t) => t.kind === 'human');
928
+ function demoWait(ms, go) {
929
+ clearTimeout(G.demoTimer);
930
+ G.demoTimer = setTimeout(() => { G.demoTimer = null; if (demoing()) go(); }, ms);
931
+ }
932
+
933
+ function start() {
934
+ const t = scorchedOptions(loadSettings());
935
+ clearTimeout(G.demoTimer); G.demoTimer = null;
936
+ // THE LAND CANVAS FORGETS THE OLD GAME (operator, 2026-09-16: "more than 2 opponents are placed
937
+ // in mid air and in ground"). The land is redrawn only when its version moves, and a fresh game
938
+ // starts back at the same version as the last one did -- so a restart before a shot, or a new
939
+ // game after a change of opponents, placed the new tanks over the OLD terrain: some in the air,
940
+ // some buried. The key is cleared here, so the first draw of a game always draws its own land.
941
+ G.landKey = '';
942
+ G.aiPlan = null;
943
+ G.target = null; G.impact = null; G.lastWeapon = null; G.confirmAt = 0; G.weaponsOpen = false;
944
+ G.game = newGame(players(t), { rounds: t.rounds, walls: t.walls, wind: t.wind, gravity: t.gravity, land: t.land, cash: t.cash, interest: t.interest / 100 });
945
+ G.blasts = []; G.fires = []; G.beams = []; G.deaths = []; G.dusts = []; G.falls = new Map(); G.shopping = false;
946
+ G.running = true; G.paused = false; G.last = 0; G.dirty = true; G.aiAt = performance.now();
947
+ overlay(null);
948
+ drawScores();
949
+ sound.unlock();
950
+ drawSwitches();
951
+ onEvents(step(G.game, 0), performance.now());
952
+ if (!G.raf) G.raf = requestAnimationFrame(frame);
953
+ }
954
+
955
+ /** A fresh war, from round one, whatever the board is doing. */
956
+ function restart() {
957
+ clearTimeout(G.demoTimer); G.demoTimer = null;
958
+ sound.unlock();
959
+ sound.holdMusic(false);
960
+ start();
961
+ G.h?.toast?.('a new war');
962
+ }
963
+
964
+ function pause(why) {
965
+ if (!G.running || G.paused) return;
966
+ G.paused = true; G.why = why;
967
+ overlay(why, 'the guns are holding', 'resume', true);
968
+ sound.holdMusic(true);
969
+ G.h?.toast?.(why);
970
+ }
971
+
972
+ function resume() {
973
+ const g = G.game;
974
+ if (!G.running) { start(); return; }
975
+ if (g?.phase === 'roundOver') { nextRoundNow(); return; }
976
+ if (!G.paused) return;
977
+ G.paused = false; G.last = 0; G.dirty = true;
978
+ overlay(null);
979
+ sound.unlock();
980
+ sound.holdMusic(false);
981
+ if (!G.raf) G.raf = requestAnimationFrame(frame);
982
+ }
983
+
984
+ function roundOver(e) {
985
+ const g = G.game;
986
+ const w = e.winner != null ? g.tanks[e.winner] : null;
987
+ if (e.last) { draw(); gameOver(); return; }
988
+ G.shopping = true;
989
+ draw();
990
+ overlay(
991
+ w ? `${w.name} ${w.kind === 'human' ? 'hold' : 'holds'} the field` : 'nobody left standing',
992
+ `round ${e.round} of ${g.rounds} is over. ${g.tanks.map((t) => `${t.name} ${t.score}`).join(' · ')}. Spend what you earned, then N or the button for the next round.`,
993
+ 'next round',
994
+ true,
995
+ );
996
+ if (demoing()) demoWait(DEMO_ROUND_MS, nextRoundNow); // nobody to shop: the attract mode plays on
997
+ }
998
+
999
+ function nextRoundNow() {
1000
+ const g = G.game;
1001
+ if (!g || g.phase !== 'roundOver') return;
1002
+ for (const t of g.tanks) aiShop(g, t); // the computer shops as the round turns
1003
+ G.shopping = false;
1004
+ nextRound(g);
1005
+ G.landKey = ''; // new land: never trust the old canvas
1006
+ G.blasts = []; G.fires = []; G.beams = []; G.last = 0; G.dirty = true; G.aiAt = performance.now();
1007
+ overlay(null);
1008
+ onEvents(step(g, 0), performance.now());
1009
+ if (!G.raf) G.raf = requestAnimationFrame(frame);
1010
+ }
1011
+
1012
+ function gameOver() {
1013
+ const g = G.game;
1014
+ G.running = false; G.paused = false; G.shopping = false;
1015
+ g.phase = 'over';
1016
+ sound.setMusic(false);
1017
+ sound.play('over');
1018
+ draw();
1019
+ const you = g.tanks.find((t) => t.kind === 'human');
1020
+ const top = leader(g);
1021
+ const list = loadScores();
1022
+ const rank = you ? rankOf(you.score, list) : null;
1023
+ const at = Date.now();
1024
+ if (rank && you) recordScore({ score: you.score, kills: you.kills, rounds: g.rounds, won: top === you, at });
1025
+ drawScores(rank ? at : null);
1026
+ overlay(
1027
+ top ? `${top.name} ${top.kind === 'human' ? 'win' : 'wins'} the war` : 'a draw',
1028
+ `${g.tanks.map((t) => `${t.name} ${t.score} (${money(t.cash)})`).join(' · ')}${rank ? ` — #${rank} on this browser` : ''}.`,
1029
+ 'play again',
1030
+ true,
1031
+ );
1032
+ if (demoing()) demoWait(DEMO_WAR_MS, start); // and the next war begins by itself
1033
+ }
1034
+
1035
+ // C3, THE WEAPON GRID: what you own, biggest blast first, with counts, radii and the key that picks
1036
+ // each; W opens it, a click picks, Escape closes. Thirty-three weapons behind [ and ] was a dozen
1037
+ // key presses to reach a Nuke once the shop had been kind.
1038
+ function drawWeapons() {
1039
+ const box = el('syWeapons');
1040
+ if (!box) return;
1041
+ const g = G.game, me = g ? current(g) : null;
1042
+ if (!G.weaponsOpen || !me || me.kind !== 'human') { box.classList.add('hidden'); G.html.weapons = null; return; }
1043
+ box.classList.remove('hidden');
1044
+ const owned = ownedWeapons(me);
1045
+ const keyOf = new Map(owned.slice(0, 9).map((w, i) => [w, String(i + 1)]));
1046
+ const rows = [...owned].sort((a, b) => (WEAPONS[b].radius ?? 0) - (WEAPONS[a].radius ?? 0)).map((w) => {
1047
+ const W = WEAPONS[w];
1048
+ const n = w === 'babyMissile' ? '\u221e' : me.inventory[w];
1049
+ return `<button type="button" class="syweapon${w === me.weapon ? ' on' : ''}" data-weapon="${w}"><b>${W.name}</b><span>\u00d7 ${n}</span><i>${W.radius ? `blast ${W.radius.toFixed(1)}` : W.kind}</i>${keyOf.has(w) ? `<kbd>${keyOf.get(w)}</kbd>` : '<kbd></kbd>'}</button>`;
1050
+ }).join('');
1051
+ setHtml(box, `<div class="syweaponshead">weapons <span class="sp"></span><small>W or Esc closes \u00b7 1\u20139 pick \u00b7 Q your last</small></div><div class="syweaponlist">${rows}</div>`, 'weapons');
1052
+ }
1053
+ function pickWeapon(id) {
1054
+ const g = G.game, me = g ? current(g) : null;
1055
+ if (!me || me.kind !== 'human' || (me.inventory[id] ?? 0) <= 0) return false;
1056
+ me.weapon = id;
1057
+ G.confirmAt = 0;
1058
+ sound.play('rotate');
1059
+ G.dirty = true;
1060
+ drawWeapons();
1061
+ if (!G.raf) draw();
1062
+ return true;
1063
+ }
1064
+
1065
+ // ------------------------------------------------------------------ input
1066
+ // THE HELD KEY'S STEP. The browser repeats a held key for us; what it cannot do is accelerate.
1067
+ // Presses of the same key closer together than RAMP_GAP are counted as one hold: the first four
1068
+ // move by one, the next six by two, and after that by five. Any other key, or a pause, starts over.
1069
+ const RAMP_GAP = 260;
1070
+ export function rampStep(n) { return n < 4 ? 1 : n < 10 ? 2 : 5; }
1071
+ function repeatStep(key, now = performance.now()) {
1072
+ const r = G.rep;
1073
+ if (r && r.key === key && now - r.at < RAMP_GAP) { r.n += 1; r.at = now; } else G.rep = { key, n: 1, at: now };
1074
+ return rampStep(G.rep.n);
1075
+ }
1076
+
1077
+ /** C2: move the power to what the last shot's miss says it should have been. */
1078
+ function correctPower(g, t) {
1079
+ if (!scorchedOptions(loadSettings()).helper) return;
1080
+ const tg = G.target != null ? g.tanks[G.target] : null;
1081
+ if (!tg?.alive) { G.h?.toast?.('click an enemy tank to mark it first'); return; }
1082
+ if (!G.impact) { G.h?.toast?.('fire once at it first'); return; }
1083
+ const c = correctionOf({ ...t, x: G.impact.from.x, power: G.impact.from.power }, tg, G.impact);
1084
+ if (!c || c.word === 'hit') return;
1085
+ aim(g, t, { power: c.power });
1086
+ sound.play('soft');
1087
+ G.h?.toast?.(`power ${c.power}, from ${Math.abs(c.miss)} ${c.word}`);
1088
+ }
1089
+
1090
+ /** The last shot again, exactly: the original's most missed convenience. */
1091
+ function repeatShot(g, t) {
1092
+ const last = G.lastShot;
1093
+ if (!last) { G.h?.toast?.('no shot to repeat yet'); return; }
1094
+ aim(g, t, { angle: last.angle, power: last.power });
1095
+ if (last.weapon && (t.inventory[last.weapon] ?? 0) > 0) t.weapon = last.weapon;
1096
+ sound.play('rotate');
1097
+ }
1098
+
1099
+ // THE WHEEL, over the field: power, and with Shift the angle. The pointer is already there, and a
1100
+ // wheel is the one input that gives a hundred units without a hundred presses.
1101
+ function onWheel(e) {
1102
+ if (!humanTurn()) return;
1103
+ const g = G.game, t = current(g);
1104
+ const dir = e.deltaY < 0 ? 1 : -1;
1105
+ e.preventDefault();
1106
+ if (e.shiftKey) { aim(g, t, { angle: t.angle + dir }); sound.play('move'); }
1107
+ else { aim(g, t, { power: t.power + dir * (e.ctrlKey ? 50 : 10) }); sound.play('soft'); }
1108
+ G.dirty = true;
1109
+ if (!G.raf) draw();
1110
+ }
1111
+
1112
+ // TYPED NUMBERS. A player reading a solution off the last shot wants to enter it, not walk to it:
1113
+ // clicking the angle or the power on the HUD turns that value into a box. drawStats leaves the
1114
+ // panel alone while one is open, so the number under the cursor does not move as it is typed.
1115
+ function onStatsClick(e) {
1116
+ const b = e.target?.closest?.('b[data-edit]');
1117
+ if (!b || !humanTurn()) return;
1118
+ const which = b.dataset.edit;
1119
+ const g = G.game, t = current(g);
1120
+ const input = document.createElement('input');
1121
+ input.type = 'number';
1122
+ input.className = 'syedit';
1123
+ input.value = String(which === 'angle' ? t.angle : t.power);
1124
+ input.min = '0';
1125
+ input.max = which === 'angle' ? '180' : '1000';
1126
+ G.editing = which;
1127
+ b.replaceChildren(input);
1128
+ input.focus();
1129
+ input.select();
1130
+ const done = (commit) => {
1131
+ if (G.editing !== which) return;
1132
+ G.editing = null;
1133
+ const v = Number(input.value);
1134
+ if (commit && Number.isFinite(v)) aim(g, t, which === 'angle' ? { angle: v } : { power: v });
1135
+ G.dirty = true;
1136
+ draw();
1137
+ };
1138
+ input.addEventListener('keydown', (ev) => {
1139
+ ev.stopPropagation();
1140
+ if (ev.key === 'Enter') { ev.preventDefault(); done(true); }
1141
+ if (ev.key === 'Escape') { ev.preventDefault(); done(false); }
1142
+ });
1143
+ input.addEventListener('blur', () => done(true));
1144
+ }
1145
+
1146
+ function humanTurn() {
1147
+ const g = G.game;
1148
+ return G.running && !G.paused && g && g.phase === 'aim' && current(g).kind === 'human';
1149
+ }
1150
+
1151
+ function onKey(e) {
1152
+ if (G.state?.page !== 'scorched') return;
1153
+ const tag = e.target?.tagName;
1154
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
1155
+ const g = G.game;
1156
+ if (e.key === 'Enter' && !humanTurn()) { e.preventDefault(); resume(); return; }
1157
+ if (e.key === 'n' || e.key === 'N') { if (g?.phase === 'roundOver') { e.preventDefault(); nextRoundNow(); } return; }
1158
+ // A WAY OUT WHEN YOU ARE DEAD (operator, 2026-09-16: "There is no way to restart the game if I
1159
+ // die. I have to wait for the AI to finish the game"). A war is five rounds; a player knocked out
1160
+ // in round one had nothing to do but watch. F2 and the button start a fresh one from round one.
1161
+ if (e.key === 'F2') { e.preventDefault(); restart(); return; }
1162
+ if (e.key === 'Escape' && G.weaponsOpen) { e.preventDefault(); G.weaponsOpen = false; drawWeapons(); return; }
1163
+ if (e.key === 'p' || e.key === 'P' || e.key === 'Escape') { if (G.running && g?.phase !== 'roundOver') { e.preventDefault(); G.paused ? resume() : pause('paused'); } return; }
1164
+ if (!humanTurn()) return;
1165
+ const t = current(g);
1166
+ // THE RAMP (the control scope's C1): a held arrow steps 1, then 2, then 5 -- crossing 180
1167
+ // degrees is a second and a half, and the last degree is still one press. Shift and Ctrl keep
1168
+ // their fixed fine and coarse steps, so a player who knows the number can still land on it.
1169
+ const ramp = repeatStep(e.key);
1170
+ const big = e.shiftKey ? 5 : ramp;
1171
+ const pstep = e.ctrlKey ? 100 : e.shiftKey ? 1 : 10 * ramp;
1172
+ let used = true;
1173
+ switch (e.key) {
1174
+ case 'ArrowLeft': aim(g, t, { angle: t.angle + big }); sound.play('move'); break;
1175
+ case 'ArrowRight': aim(g, t, { angle: t.angle - big }); sound.play('move'); break;
1176
+ case 'ArrowUp': aim(g, t, { power: t.power + pstep }); sound.play('soft'); break;
1177
+ case 'ArrowDown': aim(g, t, { power: t.power - pstep }); sound.play('soft'); break;
1178
+ case ',': case '<': aim(g, t, { power: t.power - 1 }); sound.play('soft'); break;
1179
+ case '.': case '>': aim(g, t, { power: t.power + 1 }); sound.play('soft'); break;
1180
+ case 'r': case 'R': repeatShot(g, t); break;
1181
+ case 'w': case 'W': G.weaponsOpen = !G.weaponsOpen; drawWeapons(); break;
1182
+ case 'q': case 'Q': if (!(G.lastWeapon && pickWeapon(G.lastWeapon))) G.h?.toast?.('no weapon fired yet this game'); break;
1183
+ case 'c': case 'C': correctPower(g, t); break;
1184
+ case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9': {
1185
+ const w = ownedWeapons(t)[Number(e.key) - 1];
1186
+ if (w) pickWeapon(w);
1187
+ break;
1188
+ }
1189
+ case 'PageUp': case ']': cycleWeapon(t, 1); sound.play('rotate'); break;
1190
+ case 'PageDown': case '[': cycleWeapon(t, -1); sound.play('rotate'); break;
1191
+ case 'a': case 'A': if (drive(g, t, -1)) { onEvents(step(g, 0), performance.now()); sound.play('move'); } break;
1192
+ case 'd': case 'D': if (drive(g, t, 1)) { onEvents(step(g, 0), performance.now()); sound.play('move'); } break;
1193
+ case 'b': case 'B': if (useItem(g, t, 'battery')) onEvents(step(g, 0), performance.now()); break;
1194
+ case 's': case 'S': if (useItem(g, t, 'shield')) onEvents(step(g, 0), performance.now()); break;
1195
+ case 't': case 'T': useItem(g, t, 'contactTrigger'); break;
1196
+ case 'h': case 'H': useItem(g, t, 'heatGuidance'); break;
1197
+ case ' ': case 'Enter': fireNow(); break;
1198
+ default: used = false;
1199
+ }
1200
+ if (used) { e.preventDefault(); G.dirty = true; if (!G.raf) draw(); }
1201
+ }
1202
+
1203
+ function fireNow() {
1204
+ const g = G.game;
1205
+ if (!humanTurn()) return;
1206
+ const me = current(g);
1207
+ const t = scorchedOptions(loadSettings());
1208
+ // C5, THE LAST OF A KIND ASKS ONCE: the last Nuke is the single most common regret in the original
1209
+ if (t.confirmLast && isLastOfKind(me)) {
1210
+ const now = performance.now();
1211
+ if (now - G.confirmAt > 3000) {
1212
+ G.confirmAt = now;
1213
+ G.h?.toast?.(`your last ${WEAPONS[me.weapon].name} \u2014 fire again to send it`);
1214
+ sound.play('rotate');
1215
+ drawStats();
1216
+ return;
1217
+ }
1218
+ }
1219
+ G.confirmAt = 0;
1220
+ G.lastShot = { angle: me.angle, power: me.power, weapon: me.weapon }; // R fires it again
1221
+ G.lastWeapon = me.weapon; // Q picks it again
1222
+ // C2: where this shot will come down, from the rules' own simulation of it, for the correction
1223
+ const sim = simulateShot(g, me, me.angle, me.power);
1224
+ G.impact = sim && !sim.lost ? { x: sim.x, y: sim.y, hit: sim.hit, from: { x: me.x, power: me.power } } : null;
1225
+ if (fire(g, me)) {
1226
+ onEvents(step(g, 0), performance.now());
1227
+ if (g.phase === 'settle') startSettle(performance.now()); // the laser is over at once
1228
+ G.dirty = true;
1229
+ }
1230
+ }
1231
+
1232
+ // THE MOUSE: drag anywhere on the field to aim -- the direction from your tank is the angle, the
1233
+ // distance is the power -- and fire with the button or space. A click without a drag changes nothing.
1234
+ function pointerGrid(e) {
1235
+ const field = el('syField');
1236
+ const r = field?.getBoundingClientRect();
1237
+ if (!r || !r.width || !r.height) return null;
1238
+ return { x: ((e.clientX - r.left) / r.width) * COLS, y: (1 - (e.clientY - r.top) / r.height) * ROWS };
1239
+ }
1240
+ /** The tank under a point on the field, if any, within a little slack round its hull. */
1241
+ function tankUnder(g, p) {
1242
+ return g.tanks.find((k) => k.alive && Math.abs(p.x - (k.x + TANK_W / 2)) <= 2 && p.y >= k.y - 1 && p.y <= k.y + 3) ?? null;
1243
+ }
1244
+ function onPointerDown(e) {
1245
+ if (!humanTurn()) return;
1246
+ const p = pointerGrid(e);
1247
+ if (!p) return;
1248
+ const g = G.game, me = current(g), under = tankUnder(g, p);
1249
+ // C1: a press on your own tank turns the barrel and leaves the power alone
1250
+ G.drag = { x0: p.x, y0: p.y, moved: false, angleOnly: under === me, on: under && under !== me ? under.id : null };
1251
+ }
1252
+ function onPointerMove(e) {
1253
+ if (!G.drag || !humanTurn()) return;
1254
+ const p = pointerGrid(e);
1255
+ if (!p) return;
1256
+ const g = G.game, t = current(g);
1257
+ const cx = t.x + TANK_W / 2, cy = t.y + 1;
1258
+ const dx = p.x - cx, dy = p.y - cy;
1259
+ if (!G.drag.moved && Math.hypot(p.x - G.drag.x0, p.y - G.drag.y0) < 0.5) return;
1260
+ G.drag.moved = true;
1261
+ const angle = (Math.atan2(dy, dx) * 180) / Math.PI;
1262
+ const wasAngle = Math.round(t.angle), wasPower = Math.round(t.power);
1263
+ aim(g, t, G.drag.angleOnly ? { angle: Math.max(0, Math.min(180, angle)) } : { angle: Math.max(0, Math.min(180, angle)), power: Math.min(1000, Math.hypot(dx, dy) * 28) });
1264
+ // THE CLICKS (operator, 2026-09-16: "I'm not hearing the adjustment clicks when I move with the
1265
+ // mouse"). The keys and the wheel clicked; the drag was silent. It clicks now on every whole
1266
+ // degree and every ten of power it crosses, no more than a few times a frame's worth apart, so
1267
+ // a slow drag ticks and a fast one whirs rather than machine-gunning.
1268
+ const now = performance.now();
1269
+ if (now - (G.dragClickAt ?? 0) >= 45) {
1270
+ if (Math.round(t.angle) !== wasAngle) { sound.play('move'); G.dragClickAt = now; }
1271
+ else if (Math.round(t.power / 10) !== Math.round(wasPower / 10)) { sound.play('soft'); G.dragClickAt = now; }
1272
+ }
1273
+ G.dirty = true;
1274
+ if (!G.raf) draw();
1275
+ }
1276
+ function onPointerUp() {
1277
+ // C2: a click that did not drag, on an enemy tank, marks it as your target
1278
+ const d = G.drag;
1279
+ G.drag = null;
1280
+ if (!d || d.moved || d.on == null || !G.game) return;
1281
+ if (!scorchedOptions(loadSettings()).helper) return;
1282
+ G.target = G.target === d.on ? null : d.on;
1283
+ sound.play('rotate');
1284
+ G.h?.toast?.(G.target == null ? 'target cleared' : `targeting ${G.game.tanks[G.target].name}`);
1285
+ G.dirty = true;
1286
+ if (!G.raf) draw();
1287
+ }
1288
+
1289
+ function onShopClick(e) {
1290
+ const b = e.target.closest?.('[data-buy]');
1291
+ if (!b || !G.shopping || !G.game) return;
1292
+ const you = G.game.tanks.find((t) => t.kind === 'human');
1293
+ if (you && buy(you, b.dataset.buy)) { sound.play('brick'); draw(); }
1294
+ }
1295
+
1296
+ function bind() {
1297
+ if (G.bound) return;
1298
+ G.bound = true;
1299
+ document.addEventListener('keydown', onKey);
1300
+ document.addEventListener('visibilitychange', () => { if (document.hidden && G.running && !G.paused) pause('paused — you looked away'); });
1301
+ el('syResume')?.addEventListener('click', () => resume());
1302
+ el('syFire')?.addEventListener('click', () => fireNow());
1303
+ el('syRestart')?.addEventListener('click', () => restart());
1304
+ el('syShop')?.addEventListener('click', onShopClick);
1305
+ el('syStats')?.addEventListener('click', onStatsClick);
1306
+ el('syWeapons')?.addEventListener('click', (e) => { const b = e.target.closest?.('[data-weapon]'); if (b && pickWeapon(b.dataset.weapon)) { G.weaponsOpen = false; drawWeapons(); } });
1307
+ el('syItems')?.addEventListener('click', (e) => {
1308
+ const b = e.target.closest?.('[data-use]');
1309
+ if (!b || !humanTurn()) return;
1310
+ const g = G.game, t = current(g);
1311
+ if (useItem(g, t, b.dataset.use)) { onEvents(step(g, 0), performance.now()); sound.play('soft'); G.dirty = true; if (!G.raf) draw(); }
1312
+ });
1313
+ el('syField')?.addEventListener('wheel', onWheel, { passive: false });
1314
+ const field = el('syField');
1315
+ field?.addEventListener('pointerdown', onPointerDown);
1316
+ field?.addEventListener('pointermove', onPointerMove);
1317
+ document.addEventListener('pointerup', onPointerUp);
1318
+ for (const [id, key] of SWITCHES) el(id)?.addEventListener('click', () => flip(key));
1319
+ el('sySkySw')?.addEventListener('click', () => { const s = loadSettings(); setSetting(s, 'scorched.sky', nextSky(scorchedOptions(s).sky.sky)); sound.unlock(); drawSwitches(); drawSky(); G.dirty = true; if (!G.raf) draw(); });
1320
+ }
1321
+
1322
+ /** The game on screen, for a tool or a test that drives the page (never the rules' way in). */
1323
+ export function currentGame() { return G.game; }
1324
+
1325
+ export function renderScorchedYard(s, state, h) {
1326
+ G.state = state; G.h = h;
1327
+ bind();
1328
+ drawSwitches();
1329
+ drawSky();
1330
+ if (!G.game) {
1331
+ overlay('Scorched Yard', 'the block space is the battlefield. You against the computer: ← → angle, ↑ ↓ power, [ ] weapon, space fires; or drag on the field to aim. Enter or the button to play.', 'play');
1332
+ drawStats(); drawTanks(); drawScores(); draw();
1333
+ return;
1334
+ }
1335
+ if (G.running && G.paused) overlay(G.why, 'the guns are holding', 'resume', true);
1336
+ if (G.running && !G.paused && !G.raf && G.game.phase !== 'over') { G.last = 0; G.raf = requestAnimationFrame(frame); }
1337
+ else { G.dirty = true; draw(); }
1338
+ }