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.
- package/CHANGELOG.md +359 -1
- package/README.md +48 -27
- package/SECURITY.md +2 -2
- package/bin/blockyard.js +2 -1
- package/docs/API.md +17 -15
- package/docs/ARCHITECTURE.md +128 -10
- package/docs/CONFIGURATION.md +39 -30
- package/docs/DEFECTS.md +4 -1
- package/docs/GETTING-STARTED.md +18 -8
- package/docs/INSTALL.md +97 -37
- package/docs/MEASUREMENTS.md +147 -0
- package/docs/PLAN-SCORCHED-YARD.md +456 -0
- package/docs/PLAN-SKIES.md +142 -0
- package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
- package/docs/SECURITY.md +58 -22
- package/docs/TROUBLESHOOTING.md +44 -5
- package/docs/USER-GUIDE.md +505 -35
- package/package.json +4 -2
- package/public/404.html +1 -1
- package/public/css/app.css +393 -82
- package/public/donate-qr.png +0 -0
- package/public/index.html +353 -109
- package/public/js/agents.js +228 -51
- package/public/js/app.js +131 -16
- package/public/js/blockanoid.js +15 -7
- package/public/js/blockout.js +15 -7
- package/public/js/blockscene3d.js +230 -38
- package/public/js/charts.js +21 -21
- package/public/js/depthchart.js +31 -27
- package/public/js/details3d.js +1481 -73
- 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 +7 -1
- package/public/js/livingsky.js +494 -0
- package/public/js/login.js +8 -2
- package/public/js/markets.js +46 -8
- package/public/js/mining.js +314 -36
- package/public/js/panels.js +41 -28
- package/public/js/pricechart.js +14 -13
- package/public/js/quake.js +20 -0
- package/public/js/safenext.js +14 -0
- package/public/js/scorched.js +1051 -0
- package/public/js/scorchedai.js +227 -0
- package/public/js/scorchedair.js +286 -0
- package/public/js/scorchedfx.js +376 -0
- package/public/js/scorchedshop.js +105 -0
- package/public/js/scorchedwind.js +69 -0
- package/public/js/scorchedyard.js +1338 -0
- package/public/js/settings.js +368 -100
- package/public/js/soundcard.js +459 -0
- package/public/js/tetrust.js +15 -6
- package/public/js/tetsound.js +35 -5
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/scripts/check.js +46 -0
- package/scripts/donate-qr.py +12 -9
- package/scripts/dos-bench.js +56 -0
- package/scripts/index-build.js +9 -2
- package/scripts/pool-map.js +152 -36
- package/scripts/setup.js +142 -22
- package/scripts/shots.mjs +27 -0
- package/scripts/smoke.sh +7 -6
- package/scripts/tls.js +31 -0
- package/scripts/ui.js +4 -2
- package/server/auth/sessions.js +33 -13
- package/server/chain/blockfile.js +64 -5
- package/server/chain/index/build.js +451 -58
- package/server/chain/index/heights.js +29 -3
- package/server/chain/index/live.js +13 -7
- package/server/chain/index/rows.js +6 -1
- package/server/chain/index/store.js +28 -5
- package/server/chain/index/worker.js +23 -11
- package/server/collect/logparse.js +65 -18
- package/server/collect/markets.js +76 -7
- package/server/collect/mining.js +32 -0
- package/server/collect/monitor.js +54 -12
- package/server/collect/network.js +305 -0
- package/server/config.js +53 -22
- package/server/http/api.js +119 -18
- package/server/http/games.js +77 -0
- package/server/http/server.js +30 -5
- package/server/http/sse.js +53 -7
- package/server/main.js +66 -11
- package/server/rpc/allowlist.js +26 -0
- package/server/rpc/client.js +30 -2
- package/server/store/audit.js +6 -1
- package/server/store/history.js +19 -3
- package/server/store/ledger.js +15 -4
- package/server/tls/selfsigned.js +160 -0
- package/systemd/blockyard.service +41 -8
- package/docs/PRIVATE-LEADERBOARD.md +0 -230
- 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
|
+
}
|