blockyard 0.1.0 → 0.1.2
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 +112 -0
- package/README.md +13 -11
- package/SECURITY.md +2 -2
- package/docs/API.md +1 -1
- package/docs/ARCHITECTURE.md +36 -5
- package/docs/CONFIGURATION.md +6 -4
- package/docs/DEFECTS.md +4 -1
- package/docs/GETTING-STARTED.md +14 -7
- package/docs/INSTALL.md +7 -4
- 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 +26 -7
- package/docs/TROUBLESHOOTING.md +10 -5
- package/docs/USER-GUIDE.md +247 -9
- package/package.json +4 -2
- package/public/css/app.css +87 -0
- package/public/index.html +58 -6
- package/public/js/app.js +60 -19
- package/public/js/blockanoid.js +15 -7
- package/public/js/blockout.js +15 -7
- package/public/js/blockscene3d.js +51 -11
- package/public/js/depthchart.js +1 -1
- package/public/js/details3d.js +25 -2
- package/public/js/explorer.js +7 -1
- package/public/js/livingsky.js +494 -0
- package/public/js/login.js +3 -2
- package/public/js/mining.js +4 -4
- package/public/js/panels.js +27 -18
- package/public/js/safenext.js +14 -0
- package/public/js/scorched.js +1071 -0
- package/public/js/scorchedai.js +268 -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 +1361 -0
- package/public/js/settings.js +266 -80
- package/public/js/tetrust.js +15 -6
- package/public/js/tetsound.js +35 -5
- package/scripts/check.js +46 -0
- package/scripts/index-build.js +9 -2
- package/scripts/pool-map.js +152 -36
- package/scripts/setup.js +108 -10
- package/scripts/shots.mjs +27 -0
- package/scripts/smoke.sh +6 -5
- 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 +432 -56
- 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 +24 -11
- package/server/collect/network.js +19 -9
- package/server/config.js +7 -0
- package/server/http/api.js +70 -13
- package/server/http/server.js +22 -5
- package/server/http/sse.js +53 -7
- package/server/main.js +13 -3
- 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/systemd/blockyard.service +34 -3
|
@@ -0,0 +1,1071 @@
|
|
|
1
|
+
// SCORCHED YARD, the rules (operator, 2026-09-16: "Scope out a plan for re-creating the classic PC
|
|
2
|
+
// DOS game Scorched Earth using our engine. I suggest using blocks to generate the deformable
|
|
3
|
+
// landscape ... Make it at least 3 player. 1 Human Player and 2 AI Players. Try to reproduce it as
|
|
4
|
+
// faithfully as possible, but make it look fabulous"; then "go with 96x48, start on M1", "start on
|
|
5
|
+
// M2"). docs/PLAN-SCORCHED-YARD.md is the plan; this is its M1 and M2: the landscape, the tanks,
|
|
6
|
+
// the whole roster of shells and items, craters, falling dirt, damage, death, turns, rounds, cash.
|
|
7
|
+
//
|
|
8
|
+
// This file knows nothing of the screen: no DOM, no canvas, no clock. A game is a plain object; a
|
|
9
|
+
// shot is a function of it; the screen (scorchedyard.js) asks for the tiles when it wants to draw.
|
|
10
|
+
// Every random choice comes from the game's own seeded stream, so a round replays exactly in a
|
|
11
|
+
// test. The roster itself is data in scorchedshop.js; the behaviours are here.
|
|
12
|
+
//
|
|
13
|
+
// THE FIELD is W x H cells (96 x 48: the original's 640 x 350 EGA screen was about 2:1, so a cell
|
|
14
|
+
// stands in for six or seven of its pixels). The dirt is a BITMAP over those cells, not a height
|
|
15
|
+
// per column, so that a tunnel or an overhang exists once a digger has been through; a column's
|
|
16
|
+
// top is kept beside it for the fast questions. Grid y runs UP from the floor, as the engine
|
|
17
|
+
// draws it.
|
|
18
|
+
//
|
|
19
|
+
// UNITS: grid cells, and seconds inside the integrator (the screen hands it milliseconds). Power
|
|
20
|
+
// 0..1000 is the original's scale; 1000 throws a shell at V_MAX cells a second, which on Earth
|
|
21
|
+
// gravity carries a 45° shot across the whole field and no further -- the original's feel.
|
|
22
|
+
import {
|
|
23
|
+
WEAPONS, WEAPON_ORDER, ITEMS, ITEM_ORDER, START_INVENTORY, START_ITEMS, START_CASH,
|
|
24
|
+
CASH_PER_DAMAGE, KILL_BONUS, SURVIVOR_BONUS, buy, payInterest,
|
|
25
|
+
} from './scorchedshop.js';
|
|
26
|
+
export { WEAPONS, WEAPON_ORDER, ITEMS, ITEM_ORDER, START_INVENTORY, START_ITEMS, START_CASH, buy, payInterest };
|
|
27
|
+
|
|
28
|
+
export const COLS = 96;
|
|
29
|
+
export const ROWS = 48;
|
|
30
|
+
|
|
31
|
+
// the tanks' colours, in the order players are added (the original's palette, roughly)
|
|
32
|
+
export const PERSONALITIES = Object.freeze(['moron', 'shooter', 'poolshark', 'tosser', 'chooser', 'spoiler', 'cyborg', 'unknown']);
|
|
33
|
+
const UNKNOWN_POOL = Object.freeze(['moron', 'shooter', 'poolshark', 'tosser', 'chooser', 'spoiler', 'cyborg']);
|
|
34
|
+
// EIGHT SEATS, EIGHT COLOURS (operator, 2026-09-16: "I would like to add up to 8 players total like
|
|
35
|
+
// the original"). Distinct at a glance on dirt, and none of them the dirt's own browns.
|
|
36
|
+
export const TANK_COLOURS = Object.freeze(['#f7931a', '#4d8dff', '#2ecc8f', '#ef5a5a', '#c78bff', '#f0c419', '#32d4e0', '#ff86b8']);
|
|
37
|
+
// A COLOUR FOR EACH PERSONALITY (operator, 2026-09-17: "assign a color to each personality"). A tank
|
|
38
|
+
// used to take its colour from its seat, so the first opponent was blue and the second green whatever
|
|
39
|
+
// played there. Now the colour says who you are up against: you are BlockYard orange, and each of
|
|
40
|
+
// the manual's eight has its own. The Unknown has one too, so its colour gives nothing away about
|
|
41
|
+
// the personality it draws each round. A second or third of one kind is a lighter, then a darker,
|
|
42
|
+
// shade of the same colour.
|
|
43
|
+
export const PERSONA_COLOURS = Object.freeze({
|
|
44
|
+
human: '#f7931a', moron: '#f0c419', shooter: '#4d8dff', poolshark: '#32d4e0', tosser: '#2ecc8f',
|
|
45
|
+
chooser: '#c78bff', spoiler: '#ef5a5a', cyborg: '#ff86b8', unknown: '#b7c0cc',
|
|
46
|
+
});
|
|
47
|
+
const mixHex = (hex, to, k) => `#${[1, 3, 5].map((i) => Math.round(parseInt(hex.slice(i, i + 2), 16) * (1 - k) + to * k).toString(16).padStart(2, '0')).join('')}`;
|
|
48
|
+
/** The colour of the `nth` tank (1 = the first) of a kind: its own colour, then shades of it. */
|
|
49
|
+
export function colourFor(kind, nth = 1) {
|
|
50
|
+
const base = PERSONA_COLOURS[kind] ?? TANK_COLOURS[0];
|
|
51
|
+
if (nth <= 1) return base;
|
|
52
|
+
const shades = [[255, 0.35], [0, 0.35], [255, 0.6], [0, 0.55], [255, 0.8], [0, 0.7]];
|
|
53
|
+
const [to, k] = shades[(nth - 2) % shades.length];
|
|
54
|
+
return mixHex(base, to, k);
|
|
55
|
+
}
|
|
56
|
+
export const MAX_PLAYERS = 8;
|
|
57
|
+
export const MAX_HEALTH = 100;
|
|
58
|
+
export const V_MAX = 56; // cells/s at power 1000
|
|
59
|
+
export const GRAVITY = 30; // cells/s² at the Earth setting (1)
|
|
60
|
+
export const WIND_ACCEL = 1.2; // cells/s² per unit of wind
|
|
61
|
+
export const WIND_MAX = 10;
|
|
62
|
+
export const MAX_STEP = 0.25; // a shell never moves more than this in one substep: no tunnelling
|
|
63
|
+
export const FALL_DAMAGE = 4; // per cell fallen beyond the first
|
|
64
|
+
export const DEATH_BLAST = Object.freeze({ radius: 3.5, damage: 60 });
|
|
65
|
+
export const TANK_W = 2; // the hull: two cells wide
|
|
66
|
+
export const TANK_H = 1.4; // hull and turret, for the hitbox
|
|
67
|
+
export const ROLL_SPEED = 14; // cells/s along the ground
|
|
68
|
+
export const BORE_SPEED = 16; // cells/s through dirt
|
|
69
|
+
export const HEAT_PULL = 14; // cells/s² toward the nearest tank, heat-guided and falling
|
|
70
|
+
const clampX = (x) => Math.max(0, Math.min(COLS - 1e-3, x));
|
|
71
|
+
const BOMBLET = Object.freeze({ name: 'bomblet', kind: 'blast', radius: 1.5, damage: 30 }); // a Funky Bomb's pieces, which the shop does not sell
|
|
72
|
+
|
|
73
|
+
// the strata: from the floor up, as a fraction of a column's own height, with a colour each
|
|
74
|
+
// VIBRANT (operator, 2026-09-16: "make the terrain blocks more vibrant colored"). The first set
|
|
75
|
+
// were dust-and-shadow browns that read as tinted rather than lit whatever the lamp did. These
|
|
76
|
+
// are the same strata at full saturation: hot magma, slate rock with some blue in it, terracotta
|
|
77
|
+
// clay, golden soil and a green that is actually green. The lamp then only has to do the shading.
|
|
78
|
+
// AND BRIGHT (operator, 2026-09-16, with a screenshot: "See how the tanks are nice and bright on
|
|
79
|
+
// their front faces? I would like the side of the terrain to be similarly brightened"). The tanks
|
|
80
|
+
// and the land go through the same lamp; the tanks looked lit because their paint is bright. The
|
|
81
|
+
// strata were lifted a fifth to a quarter in value at the same saturation: the front of the land
|
|
82
|
+
// now sits where the front of a tank does.
|
|
83
|
+
export const STRATA = Object.freeze([
|
|
84
|
+
Object.freeze({ to: 0.12, color: '#e84a2c' }), // magma, at the very bottom
|
|
85
|
+
Object.freeze({ to: 0.40, color: '#7b78a8' }), // rock
|
|
86
|
+
Object.freeze({ to: 0.72, color: '#cc6a32' }), // clay
|
|
87
|
+
Object.freeze({ to: 0.97, color: '#e9ad4e' }), // soil
|
|
88
|
+
Object.freeze({ to: 1.00, color: '#55dc4e' }), // the grass cap
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
// ------------------------------------------------------------------- randomness, seeded
|
|
92
|
+
/** A small LCG (the same constants agents.js uses), so a game replays from its seed. */
|
|
93
|
+
export function rng(seed) {
|
|
94
|
+
let s = (Number(seed) >>> 0) || 1;
|
|
95
|
+
return () => { s = (Math.imul(s, 1103515245) + 12345) >>> 0; return (s >>> 8) / 16777216; };
|
|
96
|
+
}
|
|
97
|
+
const hash01 = (n) => { const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); };
|
|
98
|
+
|
|
99
|
+
// ------------------------------------------------------------------- the dirt
|
|
100
|
+
const idx = (x, y) => y * COLS + x;
|
|
101
|
+
export const dirtAt = (g, x, y) => x >= 0 && x < COLS && y >= 0 && y < ROWS && g.dirt[idx(x, y)] === 1;
|
|
102
|
+
/** The top of a column: one above its highest dirt cell (0 for a bare column). */
|
|
103
|
+
export function topOf(g, x) {
|
|
104
|
+
if (x < 0 || x >= COLS) return 0;
|
|
105
|
+
for (let y = ROWS - 1; y >= 0; y--) if (g.dirt[idx(x, y)]) return y + 1;
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
function retop(g, x0 = 0, x1 = COLS - 1) {
|
|
109
|
+
for (let x = Math.max(0, x0); x <= Math.min(COLS - 1, x1); x++) g.tops[x] = topOf(g, x);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The landscape: a sum of low sines with seeded phases, clamped to leave sky above and dirt below.
|
|
114
|
+
* `style` picks the amplitude: 'hills' (the default), 'mountains', 'flat', 'valley'.
|
|
115
|
+
*/
|
|
116
|
+
export function generateLand(g, style = 'hills') {
|
|
117
|
+
const r = g.rnd;
|
|
118
|
+
const p = [r(), r(), r(), r()].map((v) => v * Math.PI * 2);
|
|
119
|
+
const amp = { hills: 0.22, mountains: 0.34, flat: 0.04, valley: 0.26 }[style] ?? 0.22;
|
|
120
|
+
const base = style === 'mountains' ? 0.42 : 0.36;
|
|
121
|
+
g.dirt.fill(0);
|
|
122
|
+
for (let x = 0; x < COLS; x++) {
|
|
123
|
+
const u = x / COLS;
|
|
124
|
+
let h = base
|
|
125
|
+
+ amp * 0.55 * Math.sin(u * Math.PI * 2 * 1.3 + p[0])
|
|
126
|
+
+ amp * 0.30 * Math.sin(u * Math.PI * 2 * 2.9 + p[1])
|
|
127
|
+
+ amp * 0.15 * Math.sin(u * Math.PI * 2 * 5.7 + p[2])
|
|
128
|
+
+ amp * 0.08 * Math.sin(u * Math.PI * 2 * 11 + p[3]);
|
|
129
|
+
if (style === 'valley') h -= 0.18 * Math.cos((u - 0.5) * Math.PI * 2);
|
|
130
|
+
const cells = Math.round(Math.max(3, Math.min(ROWS * 0.78, h * ROWS)));
|
|
131
|
+
for (let y = 0; y < cells; y++) g.dirt[idx(x, y)] = 1;
|
|
132
|
+
}
|
|
133
|
+
retop(g);
|
|
134
|
+
g.landVersion = (g.landVersion ?? 0) + 1;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Clear every dirt cell inside a circle. Returns how many went. */
|
|
138
|
+
export function crater(g, cx, cy, r) {
|
|
139
|
+
let removed = 0;
|
|
140
|
+
for (let x = Math.max(0, Math.floor(cx - r - 1)); x <= Math.min(COLS - 1, Math.ceil(cx + r + 1)); x++) {
|
|
141
|
+
for (let y = Math.max(0, Math.floor(cy - r - 1)); y <= Math.min(ROWS - 1, Math.ceil(cy + r + 1)); y++) {
|
|
142
|
+
if (!g.dirt[idx(x, y)]) continue;
|
|
143
|
+
if (Math.hypot(x + 0.5 - cx, y + 0.5 - cy) <= r) { g.dirt[idx(x, y)] = 0; removed += 1; }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (removed) g.landVersion += 1;
|
|
147
|
+
return removed;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Fill every cell inside a circle with dirt (the dirt weapons). Returns how many were added. */
|
|
151
|
+
export function addDirt(g, cx, cy, r) {
|
|
152
|
+
let added = 0;
|
|
153
|
+
for (let x = Math.max(0, Math.floor(cx - r - 1)); x <= Math.min(COLS - 1, Math.ceil(cx + r + 1)); x++) {
|
|
154
|
+
for (let y = Math.max(0, Math.floor(cy - r - 1)); y <= Math.min(ROWS - 1, Math.ceil(cy + r + 1)); y++) {
|
|
155
|
+
if (g.dirt[idx(x, y)]) continue;
|
|
156
|
+
if (Math.hypot(x + 0.5 - cx, y + 0.5 - cy) <= r) { g.dirt[idx(x, y)] = 1; added += 1; }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (added) g.landVersion += 1;
|
|
160
|
+
return added;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Dirt above a hole falls until it rests: each column in x0..x1 is compacted downward, run by
|
|
165
|
+
* run, and every run that moved is recorded in `g.falling` for the screen to animate.
|
|
166
|
+
*/
|
|
167
|
+
export function settleDirt(g, x0 = 0, x1 = COLS - 1) {
|
|
168
|
+
for (let x = Math.max(0, x0); x <= Math.min(COLS - 1, x1); x++) {
|
|
169
|
+
let write = 0;
|
|
170
|
+
let y = 0;
|
|
171
|
+
while (y < ROWS) {
|
|
172
|
+
if (!g.dirt[idx(x, y)]) { y++; continue; }
|
|
173
|
+
const start = y;
|
|
174
|
+
while (y < ROWS && g.dirt[idx(x, y)]) y++;
|
|
175
|
+
const len = y - start;
|
|
176
|
+
if (start !== write) {
|
|
177
|
+
for (let k = 0; k < len; k++) g.dirt[idx(x, start + k)] = 0;
|
|
178
|
+
for (let k = 0; k < len; k++) g.dirt[idx(x, write + k)] = 1;
|
|
179
|
+
g.falling.push({ x, y: write, len, from: start });
|
|
180
|
+
}
|
|
181
|
+
write += len;
|
|
182
|
+
}
|
|
183
|
+
g.tops[x] = write;
|
|
184
|
+
}
|
|
185
|
+
g.landVersion += 1;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ------------------------------------------------------------------- the game
|
|
189
|
+
/**
|
|
190
|
+
* A new game. `players` is a list of `{ name, kind: 'human' | 'moron' | ..., colour? }`; the
|
|
191
|
+
* first human plays from the keyboard, the rest are decided by scorchedai.js. Options: `seed`,
|
|
192
|
+
* `gravity` (1 = Earth), `wind` ('round' | 'turn' | 'shot' | 'none'), `walls` ('concrete' | 'rubber' |
|
|
193
|
+
* 'wrap' | 'none'), `land` (a landscape style), `rounds`, `cash` (to start), `interest` (a rate).
|
|
194
|
+
*/
|
|
195
|
+
export function newGame(players, opts = {}) {
|
|
196
|
+
const seed = Number.isFinite(opts.seed) ? opts.seed : (Date.now() >>> 0);
|
|
197
|
+
const g = {
|
|
198
|
+
seed, rnd: rng(seed),
|
|
199
|
+
W: COLS, H: ROWS,
|
|
200
|
+
dirt: new Uint8Array(COLS * ROWS), tops: new Int16Array(COLS),
|
|
201
|
+
gravity: Number.isFinite(opts.gravity) ? opts.gravity : 1,
|
|
202
|
+
windMode: opts.wind ?? 'round', walls: opts.walls ?? 'concrete', land: opts.land ?? 'hills',
|
|
203
|
+
interest: Number.isFinite(opts.interest) ? opts.interest : 0.05,
|
|
204
|
+
wind: 0,
|
|
205
|
+
tanks: (players ?? []).map((p, i) => ({
|
|
206
|
+
id: i, name: p.name ?? `Player ${i + 1}`, kind: p.kind ?? 'human', colour: p.colour ?? TANK_COLOURS[i % TANK_COLOURS.length],
|
|
207
|
+
x: 0, y: 0, health: MAX_HEALTH, alive: true, angle: 45, power: 500, weapon: 'babyMissile',
|
|
208
|
+
inventory: { ...START_INVENTORY }, items: { ...START_ITEMS },
|
|
209
|
+
shield: null, // { id, hp, deflect } while one is up
|
|
210
|
+
armed: { contactTrigger: false, heatGuidance: false },
|
|
211
|
+
kills: 0, score: 0, cash: Number.isFinite(opts.cash) ? opts.cash : START_CASH, damageDealt: 0,
|
|
212
|
+
lastHitBy: null, // who hurt this tank last (the Cyborg holds a grudge)
|
|
213
|
+
memory: null, // a computer player's last shot at its target, for correcting
|
|
214
|
+
aimAt: null, // a computer player's shots so far at one target: its aim tightens (scorchedai.js AIM)
|
|
215
|
+
persona: p.kind ?? 'human', // what an Unknown is playing as this round
|
|
216
|
+
})),
|
|
217
|
+
round: 1, rounds: Math.max(1, Math.round(opts.rounds ?? 5)),
|
|
218
|
+
turn: 0, // index into `order`
|
|
219
|
+
order: [], // tank ids, this round's turn order
|
|
220
|
+
phase: 'aim', // 'aim' | 'flight' | 'settle' | 'roundOver' | 'over'
|
|
221
|
+
shells: [], // in flight: { x, y, vx, vy, weapon, owner, path, t, ... }
|
|
222
|
+
lastPath: [], // the last shot's trace, for the screen
|
|
223
|
+
falling: [], // dirt runs on their way down, for the screen
|
|
224
|
+
sparks: [], // events the screen makes sounds and pictures of, drained by it
|
|
225
|
+
shots: 0,
|
|
226
|
+
landVersion: 0, // bumped whenever the dirt changes: the screen redraws its land layer only then
|
|
227
|
+
};
|
|
228
|
+
if (g.tanks.length < 2) throw new RangeError('a round needs at least two tanks');
|
|
229
|
+
startRound(g);
|
|
230
|
+
return g;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Fresh land, every tank placed with full health, the turn order rotated, the wind set. */
|
|
234
|
+
export function startRound(g) {
|
|
235
|
+
generateLand(g, g.land);
|
|
236
|
+
placeTanks(g);
|
|
237
|
+
for (const t of g.tanks) {
|
|
238
|
+
t.health = MAX_HEALTH; t.alive = true; t.shield = null; t.armed = { contactTrigger: false, heatGuidance: false };
|
|
239
|
+
t.lastHitBy = null; t.memory = null; t.aimAt = null;
|
|
240
|
+
// an Unknown is one of the others, drawn each round and never announced (the manual)
|
|
241
|
+
t.persona = t.kind === 'unknown' ? UNKNOWN_POOL[Math.floor(g.rnd() * UNKNOWN_POOL.length)] : t.kind;
|
|
242
|
+
}
|
|
243
|
+
const n = g.tanks.length;
|
|
244
|
+
g.order = g.tanks.map((t) => t.id).map((_, i, a) => a[(i + g.round - 1) % n]);
|
|
245
|
+
g.turn = 0;
|
|
246
|
+
g.phase = 'aim';
|
|
247
|
+
g.shells = []; g.lastPath = []; g.falling = [];
|
|
248
|
+
newWind(g);
|
|
249
|
+
g.sparks.push({ kind: 'round', round: g.round });
|
|
250
|
+
autoDefend(g, current(g));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function placeTanks(g) {
|
|
254
|
+
const n = g.tanks.length;
|
|
255
|
+
// evenly spaced slots with a seeded jitter, shuffled, so the same seed places the same tanks
|
|
256
|
+
const slotW = (COLS - 8) / n;
|
|
257
|
+
const slots = g.tanks.map((_, i) => Math.round(4 + slotW * i + slotW * (0.25 + 0.5 * g.rnd())));
|
|
258
|
+
for (let i = slots.length - 1; i > 0; i--) { const j = Math.floor(g.rnd() * (i + 1)); [slots[i], slots[j]] = [slots[j], slots[i]]; }
|
|
259
|
+
g.tanks.forEach((t, i) => {
|
|
260
|
+
const x = Math.max(1, Math.min(COLS - TANK_W - 1, slots[i]));
|
|
261
|
+
// a level plateau under the hull: the columns it stands on take the lower of their tops
|
|
262
|
+
const top = Math.min(g.tops[x], g.tops[x + 1]);
|
|
263
|
+
for (let cx = x; cx < x + TANK_W; cx++) {
|
|
264
|
+
for (let y = top; y < ROWS; y++) g.dirt[idx(cx, y)] = 0;
|
|
265
|
+
for (let y = 0; y < top; y++) g.dirt[idx(cx, y)] = 1;
|
|
266
|
+
}
|
|
267
|
+
t.x = x; t.y = top;
|
|
268
|
+
t.angle = x < COLS / 2 ? 45 : 135;
|
|
269
|
+
t.power = 500;
|
|
270
|
+
});
|
|
271
|
+
retop(g);
|
|
272
|
+
g.landVersion += 1;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* A NEW WIND. It is drawn at the start of every round; what happens to it inside the round is the
|
|
277
|
+
* mode's business (operator, 2026-09-16: first "it needs to consistently move in one direction, not
|
|
278
|
+
* shift back and forth during the same round", then "the wind speed did not change the entire
|
|
279
|
+
* game").
|
|
280
|
+
*
|
|
281
|
+
* round the shipped mode. The round's DIRECTION is drawn once and held to the end of it; the
|
|
282
|
+
* STRENGTH is drawn afresh for each tank's turn. The air over a round blows one way, and
|
|
283
|
+
* how hard is still a thing to read before every shot.
|
|
284
|
+
* turn the original's: direction and strength both fresh every turn.
|
|
285
|
+
* shot redrawn at every shot.
|
|
286
|
+
* none still air.
|
|
287
|
+
*
|
|
288
|
+
* `sign`, when given, keeps the direction and rolls only the strength.
|
|
289
|
+
*/
|
|
290
|
+
function newWind(g, sign = null) {
|
|
291
|
+
if (g.windMode === 'none') { g.wind = 0; return; }
|
|
292
|
+
const mag = Math.round(g.rnd() * WIND_MAX * 10) / 10;
|
|
293
|
+
const dir = sign ?? (g.rnd() < 0.5 ? -1 : 1);
|
|
294
|
+
g.wind = Math.round(mag * dir * 10) / 10;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export const current = (g) => g.tanks[g.order[g.turn]];
|
|
298
|
+
export const alive = (g) => g.tanks.filter((t) => t.alive);
|
|
299
|
+
const enemiesOf = (g, id) => g.tanks.filter((t) => t.alive && t.id !== id);
|
|
300
|
+
const centre = (t) => ({ x: t.x + TANK_W / 2, y: t.y + 0.7 });
|
|
301
|
+
|
|
302
|
+
/** Aim: angle 0..180 (90 straight up), power 0..1000, clamped. */
|
|
303
|
+
export function aim(g, tank, { angle, power, weapon } = {}) {
|
|
304
|
+
if (angle != null) tank.angle = Math.max(0, Math.min(180, Math.round(angle)));
|
|
305
|
+
if (power != null) tank.power = Math.max(0, Math.min(1000, Math.round(power)));
|
|
306
|
+
if (weapon != null && WEAPONS[weapon]) tank.weapon = weapon;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The next weapon in the cycle that the tank still has, `dir` +1 or -1. */
|
|
310
|
+
export function cycleWeapon(tank, dir = 1) {
|
|
311
|
+
const i = WEAPON_ORDER.indexOf(tank.weapon);
|
|
312
|
+
const n = WEAPON_ORDER.length;
|
|
313
|
+
for (let k = 1; k <= n; k++) {
|
|
314
|
+
const w = WEAPON_ORDER[(((i + dir * k) % n) + n) % n];
|
|
315
|
+
if ((tank.inventory[w] ?? 0) > 0) { tank.weapon = w; return w; }
|
|
316
|
+
}
|
|
317
|
+
return tank.weapon;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The barrel's muzzle, where a shell starts. */
|
|
321
|
+
export function muzzle(tank) {
|
|
322
|
+
const a = (tank.angle * Math.PI) / 180;
|
|
323
|
+
const cx = tank.x + TANK_W / 2, cy = tank.y + 1.1;
|
|
324
|
+
return { x: cx + Math.cos(a) * 1.3, y: cy + Math.sin(a) * 1.3 };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ------------------------------------------------------------------- items, on your turn
|
|
328
|
+
const SHIELD_PREFERENCE = ['heavyShield', 'forceShield', 'shield'];
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Use an item on your turn: a battery heals, a shield goes up (the named one, or the best owned
|
|
332
|
+
* for 'shield'), the two arming items toggle for the next shot. Returns false when the tank has
|
|
333
|
+
* none, or it is not the moment.
|
|
334
|
+
*/
|
|
335
|
+
export function useItem(g, tank, id) {
|
|
336
|
+
if (g.phase !== 'aim' || tank !== current(g) || !tank.alive) return false;
|
|
337
|
+
const item = ITEMS[id];
|
|
338
|
+
if (!item) return false;
|
|
339
|
+
if (item.kind === 'arm') {
|
|
340
|
+
if ((tank.items[id] ?? 0) <= 0) return false;
|
|
341
|
+
tank.armed[id] = !tank.armed[id];
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
if (item.kind === 'battery') {
|
|
345
|
+
if ((tank.items.battery ?? 0) <= 0 || tank.health >= MAX_HEALTH) return false;
|
|
346
|
+
tank.items.battery -= 1;
|
|
347
|
+
tank.health = Math.min(MAX_HEALTH, tank.health + item.heal);
|
|
348
|
+
g.sparks.push({ kind: 'battery', tank: tank.id });
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
if (item.kind === 'shield') return raiseShield(g, tank, (tank.items[id] ?? 0) > 0 ? id : null);
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Put up a shield: the named one, or the best the tank owns. */
|
|
356
|
+
export function raiseShield(g, tank, id = null) {
|
|
357
|
+
if (tank.shield) return false;
|
|
358
|
+
const pick = id ?? SHIELD_PREFERENCE.find((k) => (tank.items[k] ?? 0) > 0);
|
|
359
|
+
if (!pick || (tank.items[pick] ?? 0) <= 0) return false;
|
|
360
|
+
tank.items[pick] -= 1;
|
|
361
|
+
tank.shield = { id: pick, hp: ITEMS[pick].hp };
|
|
362
|
+
g.sparks.push({ kind: 'shield', tank: tank.id, item: pick });
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function autoDefend(g, tank) {
|
|
367
|
+
if (tank?.alive && (tank.items.autoDefense ?? 0) > 0 && !tank.shield) raiseShield(g, tank);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Drive a cell left or right with a unit of fuel, following the ground; a cliff is a fall. */
|
|
371
|
+
export function drive(g, tank, dir) {
|
|
372
|
+
if (g.phase !== 'aim' || tank !== current(g) || !tank.alive) return false;
|
|
373
|
+
if ((tank.items.fuel ?? 0) <= 0) return false;
|
|
374
|
+
const nx = tank.x + (dir < 0 ? -1 : 1);
|
|
375
|
+
if (nx < 0 || nx + TANK_W > COLS) return false;
|
|
376
|
+
const ground = Math.min(g.tops[nx], g.tops[nx + 1]);
|
|
377
|
+
if (ground - tank.y > 2) return false; // too steep to climb
|
|
378
|
+
tank.items.fuel -= 1;
|
|
379
|
+
tank.x = nx;
|
|
380
|
+
tank.y = Math.max(tank.y, ground);
|
|
381
|
+
landTanks(g); // off a cliff: it falls, and may be hurt
|
|
382
|
+
g.sparks.push({ kind: 'drive', tank: tank.id, x: tank.x });
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ------------------------------------------------------------------- firing
|
|
387
|
+
/**
|
|
388
|
+
* Fire the current tank's weapon. Returns false if it is not the moment (a shell in flight, a
|
|
389
|
+
* dead tank, an empty slot). The shell then flies through `step()`; the laser is over at once.
|
|
390
|
+
*/
|
|
391
|
+
export function fire(g, tank = current(g)) {
|
|
392
|
+
if (g.phase !== 'aim' || !tank?.alive || tank !== current(g)) return false;
|
|
393
|
+
if ((tank.inventory[tank.weapon] ?? 0) <= 0) { cycleWeapon(tank, 1); if ((tank.inventory[tank.weapon] ?? 0) <= 0) return false; }
|
|
394
|
+
const w = WEAPONS[tank.weapon];
|
|
395
|
+
if (tank.weapon !== 'babyMissile') tank.inventory[tank.weapon] -= 1; // the baby missile is the original's bottomless one
|
|
396
|
+
if (g.windMode === 'shot') newWind(g);
|
|
397
|
+
g.shots += 1;
|
|
398
|
+
g.sparks.push({ kind: 'fire', tank: tank.id, weapon: tank.weapon });
|
|
399
|
+
// the arming items, one of each spent per shot they are armed for
|
|
400
|
+
const armed = {};
|
|
401
|
+
for (const k of ['contactTrigger', 'heatGuidance']) {
|
|
402
|
+
if (tank.armed[k] && (tank.items[k] ?? 0) > 0) { tank.items[k] -= 1; armed[k] = true; }
|
|
403
|
+
if ((tank.items[k] ?? 0) <= 0) tank.armed[k] = false;
|
|
404
|
+
}
|
|
405
|
+
if (w.kind === 'laser' || w.kind === 'wedge' || w.kind === 'disrupter' || w.kind === 'plasma') {
|
|
406
|
+
if (w.kind === 'laser') laser(g, tank, w);
|
|
407
|
+
else if (w.kind === 'wedge') wedge(g, tank, w);
|
|
408
|
+
else if (w.kind === 'disrupter') disrupt(g, tank);
|
|
409
|
+
else plasma(g, tank, w);
|
|
410
|
+
g.lastPath = [];
|
|
411
|
+
g.phase = 'settle';
|
|
412
|
+
if (!g.falling.length) settled(g);
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
const a = (tank.angle * Math.PI) / 180;
|
|
416
|
+
const v = (tank.power / 1000) * V_MAX;
|
|
417
|
+
const m = muzzle(tank);
|
|
418
|
+
g.shells = [{
|
|
419
|
+
x: m.x, y: m.y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, weapon: tank.weapon, owner: tank.id, path: [{ x: m.x, y: m.y }], t: 0,
|
|
420
|
+
hops: w.hops ?? 0, contact: !!armed.contactTrigger, heat: !!armed.heatGuidance, primary: true,
|
|
421
|
+
}];
|
|
422
|
+
g.phase = 'flight';
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** The shell's positions with no walls in the way (the AI's oracle, and a test's). */
|
|
427
|
+
export function trajectory(g, tank, secs = 8, dt = 1 / 60) {
|
|
428
|
+
const a = (tank.angle * Math.PI) / 180;
|
|
429
|
+
const v = (tank.power / 1000) * V_MAX;
|
|
430
|
+
const m = muzzle(tank);
|
|
431
|
+
let x = m.x, y = m.y, vx = Math.cos(a) * v, vy = Math.sin(a) * v;
|
|
432
|
+
const pts = [{ x, y }];
|
|
433
|
+
const gy = GRAVITY * g.gravity, wx = g.wind * WIND_ACCEL;
|
|
434
|
+
for (let t = 0; t < secs; t += dt) {
|
|
435
|
+
vx += wx * dt; vy -= gy * dt; x += vx * dt; y += vy * dt;
|
|
436
|
+
pts.push({ x, y });
|
|
437
|
+
if (y < 0) break;
|
|
438
|
+
}
|
|
439
|
+
return pts;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Where a plain shell fired now would end up, with the walls, the dirt and the tanks in the way,
|
|
444
|
+
* and nothing changed: the computer players plan with this (docs/PLAN-SCORCHED-YARD.md §6).
|
|
445
|
+
* Returns `{ x, y, hit }` -- the impact, and the id of the tank it struck, if any -- and `lost`
|
|
446
|
+
* when the shell left the field or flew for longer than `secs`.
|
|
447
|
+
*/
|
|
448
|
+
export function simulateShot(g, tank, angle, power, secs = 14) {
|
|
449
|
+
const a = (angle * Math.PI) / 180;
|
|
450
|
+
const v = (Math.max(0, Math.min(1000, power)) / 1000) * V_MAX;
|
|
451
|
+
const m = muzzle({ ...tank, angle });
|
|
452
|
+
let x = m.x, y = m.y, vx = Math.cos(a) * v, vy = Math.sin(a) * v, t = 0;
|
|
453
|
+
const gy = GRAVITY * g.gravity, wx = g.wind * WIND_ACCEL;
|
|
454
|
+
const h = 1 / 120;
|
|
455
|
+
while (t < secs) {
|
|
456
|
+
vx += wx * h; vy -= gy * h; x += vx * h; y += vy * h; t += h;
|
|
457
|
+
if (x < 0 || x >= COLS) {
|
|
458
|
+
if (g.walls === 'rubber') { x = x < 0 ? -x : 2 * COLS - x - 1e-3; vx = -vx * 0.8; }
|
|
459
|
+
else if (g.walls === 'spring') { x = x < 0 ? -x : 2 * COLS - x - 1e-3; vx = -vx * 1.15; }
|
|
460
|
+
else if (g.walls === 'padded') { x = clampX(x); vx = 0; }
|
|
461
|
+
else if (g.walls === 'wrap') { x = ((x % COLS) + COLS) % COLS; }
|
|
462
|
+
else if (g.walls === 'none') return { x, y, hit: null, lost: true };
|
|
463
|
+
else return { x: clampX(x), y, hit: null, lost: false };
|
|
464
|
+
}
|
|
465
|
+
if (y < 0) return { x, y: 0, hit: null, lost: false };
|
|
466
|
+
if (y < ROWS && dirtAt(g, Math.floor(x), Math.floor(y))) return { x, y, hit: null, lost: false };
|
|
467
|
+
const hit = tankAt(g, x, y, t < 0.12 ? tank.id : -1, 0.2);
|
|
468
|
+
if (hit) return { x, y, hit: hit.id, lost: false };
|
|
469
|
+
}
|
|
470
|
+
return { x, y, hit: null, lost: true };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Advance the game by dtMs. Returns the events of the step (the screen makes sounds and pictures
|
|
475
|
+
* of them). Substepped so a shell never crosses more than MAX_STEP a hop, whatever the frame.
|
|
476
|
+
*/
|
|
477
|
+
export function step(g, dtMs) {
|
|
478
|
+
const events = [];
|
|
479
|
+
if (g.phase === 'flight') {
|
|
480
|
+
const dt = Math.min(100, Math.max(0, dtMs)) / 1000; // a frame, capped: a tab that slept catches up in hops, not a leap
|
|
481
|
+
const fastest = Math.max(1, ...g.shells.map((s) => Math.hypot(s.vx, s.vy)), ROLL_SPEED, BORE_SPEED);
|
|
482
|
+
const n = Math.max(1, Math.ceil((fastest * dt) / MAX_STEP) + 1);
|
|
483
|
+
const h = dt / n;
|
|
484
|
+
for (let i = 0; i < n && g.phase === 'flight'; i++) {
|
|
485
|
+
for (const s of [...g.shells]) flightStep(g, s, h, events);
|
|
486
|
+
if (!g.shells.length) endFlight(g);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (g.sparks.length) { events.push(...g.sparks); g.sparks = []; }
|
|
490
|
+
return events;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const weaponOf = (id) => WEAPONS[id] ?? (id === 'funkyBomblet' ? BOMBLET : WEAPONS.babyMissile);
|
|
494
|
+
const removeShell = (g, s) => { const i = g.shells.indexOf(s); if (i >= 0) g.shells.splice(i, 1); if (s.primary) g.lastPath = s.path; };
|
|
495
|
+
|
|
496
|
+
function flightStep(g, s, h, events) {
|
|
497
|
+
const w = weaponOf(s.weapon);
|
|
498
|
+
// ---- rolling along the ground (a Roller after it lands)
|
|
499
|
+
if (s.rolling) {
|
|
500
|
+
const col = Math.max(0, Math.min(COLS - 1, Math.floor(s.x)));
|
|
501
|
+
const ground = g.tops[col];
|
|
502
|
+
s.x += s.dir * ROLL_SPEED * h; s.rollLeft -= ROLL_SPEED * h; s.t += h;
|
|
503
|
+
const nc = Math.floor(s.x);
|
|
504
|
+
if (nc < 0 || nc >= COLS) { blastAt(g, s, clampX(s.x), ground); return; }
|
|
505
|
+
s.y = g.tops[nc] + 0.3;
|
|
506
|
+
if (s.path.length < 600) s.path.push({ x: s.x, y: s.y });
|
|
507
|
+
if (tankAt(g, s.x, s.y, s.owner, 0.3)) { blastAt(g, s, s.x, s.y); return; }
|
|
508
|
+
if (nc !== col) {
|
|
509
|
+
const next = g.tops[nc];
|
|
510
|
+
if (next > ground + 1) { blastAt(g, s, col + 0.5, ground); return; } // a wall in the way: it stops there
|
|
511
|
+
const ahead = g.tops[Math.max(0, Math.min(COLS - 1, nc + s.dir))];
|
|
512
|
+
if (ahead > next && ground > next) { blastAt(g, s, nc + 0.5, next); return; } // the bottom of a dip
|
|
513
|
+
}
|
|
514
|
+
if (s.rollLeft <= 0) blastAt(g, s, s.x, s.y);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
// ---- boring through dirt (a Digger or a Sandhog after it lands)
|
|
518
|
+
if (s.boring) {
|
|
519
|
+
const d = BORE_SPEED * h;
|
|
520
|
+
s.x += s.boring.dx * d; s.y += s.boring.dy * d; s.boring.left -= d; s.t += h;
|
|
521
|
+
if (s.path.length < 600) s.path.push({ x: s.x, y: s.y });
|
|
522
|
+
if (s.x >= 0 && s.x < COLS && s.y >= 0) crater(g, s.x, s.y, w.radius * 0.7);
|
|
523
|
+
if (s.x < 0 || s.x >= COLS || s.y < 0.5 || s.boring.left <= 0 || tankAt(g, s.x, s.y, s.owner, 0.2)) {
|
|
524
|
+
blastAt(g, s, clampX(s.x), Math.max(0, s.y));
|
|
525
|
+
}
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
// ---- in the air
|
|
529
|
+
s.vx += g.wind * WIND_ACCEL * h;
|
|
530
|
+
s.vy -= GRAVITY * g.gravity * h;
|
|
531
|
+
if (s.heat && s.vy < 0) {
|
|
532
|
+
const target = nearestEnemy(g, s.owner, s.x);
|
|
533
|
+
if (target) s.vx += Math.sign(centre(target).x - s.x) * HEAT_PULL * h;
|
|
534
|
+
}
|
|
535
|
+
for (const t of enemiesOf(g, s.owner)) {
|
|
536
|
+
const mag = (t.items.superMag ?? 0) > 0 ? ITEMS.superMag : (t.items.magDeflector ?? 0) > 0 ? ITEMS.magDeflector : null;
|
|
537
|
+
if (!mag) continue;
|
|
538
|
+
const c = centre(t);
|
|
539
|
+
const d = Math.hypot(s.x - c.x, s.y - c.y);
|
|
540
|
+
if (d < mag.reach && d > 0.1) { const k = mag.push * (1 - d / mag.reach) * h; s.vx += ((s.x - c.x) / d) * k; s.vy += ((s.y - c.y) / d) * k; }
|
|
541
|
+
}
|
|
542
|
+
s.x += s.vx * h; s.y += s.vy * h; s.t += h;
|
|
543
|
+
if (s.path.length < 600) s.path.push({ x: s.x, y: s.y });
|
|
544
|
+
// a MIRV splits at its apex
|
|
545
|
+
if (w.kind === 'mirv' && !s.split && s.vy <= 0 && s.t > 0.2) {
|
|
546
|
+
const n = w.heads;
|
|
547
|
+
for (let i = 0; i < n; i++) {
|
|
548
|
+
const spread = (i - (n - 1) / 2) * (n > 5 ? 2 : 3);
|
|
549
|
+
g.shells.push({ ...s, path: [{ x: s.x, y: s.y }], vx: s.vx + spread, primary: false, split: true });
|
|
550
|
+
}
|
|
551
|
+
removeShell(g, s);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
// the walls, by mode
|
|
555
|
+
if (s.x < 0 || s.x >= COLS) {
|
|
556
|
+
if (g.walls === 'rubber') { s.x = s.x < 0 ? -s.x : 2 * COLS - s.x - 1e-3; s.vx = -s.vx * 0.8; events.push({ kind: 'bounce' }); }
|
|
557
|
+
else if (g.walls === 'spring') { s.x = s.x < 0 ? -s.x : 2 * COLS - s.x - 1e-3; s.vx = -s.vx * 1.15; events.push({ kind: 'bounce' }); }
|
|
558
|
+
else if (g.walls === 'padded') { s.x = clampX(s.x); s.vx = 0; events.push({ kind: 'bounce' }); } // it stops dead and drops
|
|
559
|
+
else if (g.walls === 'wrap') { s.x = ((s.x % COLS) + COLS) % COLS; }
|
|
560
|
+
else if (g.walls === 'none') { g.sparks.push({ kind: 'lost' }); removeShell(g, s); return; }
|
|
561
|
+
else { s.x = clampX(s.x); impact(g, s, s.x, s.y); return; }
|
|
562
|
+
}
|
|
563
|
+
// a contact trigger goes off within reach of a tank
|
|
564
|
+
if (s.contact) {
|
|
565
|
+
for (const t of enemiesOf(g, s.owner)) {
|
|
566
|
+
const c = centre(t);
|
|
567
|
+
if (Math.hypot(s.x - c.x, s.y - c.y) < 2) { impact(g, s, s.x, s.y); return; }
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
if (s.y < 0) { impact(g, s, s.x, 0); return; }
|
|
571
|
+
if (s.y < ROWS && dirtAt(g, Math.floor(s.x), Math.floor(s.y))) { impact(g, s, s.x, s.y); return; }
|
|
572
|
+
if (tankAt(g, s.x, s.y, s.t < 0.12 ? s.owner : -1, 0.2)) impact(g, s, s.x, s.y);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** The living tank whose hull box (grown by `pad`) holds the point, skipping `skipId`. */
|
|
576
|
+
function tankAt(g, x, y, skipId, pad = 0) {
|
|
577
|
+
for (const t of g.tanks) {
|
|
578
|
+
if (!t.alive || t.id === skipId) continue;
|
|
579
|
+
if (x >= t.x - pad && x <= t.x + TANK_W + pad && y >= t.y - pad && y <= t.y + TANK_H + pad) return t;
|
|
580
|
+
}
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
function nearestEnemy(g, ownerId, x) {
|
|
584
|
+
return enemiesOf(g, ownerId).sort((a, b) => Math.abs(centre(a).x - x) - Math.abs(centre(b).x - x))[0] ?? null;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** A shell has arrived somewhere: what its kind does there. */
|
|
588
|
+
function impact(g, s, x, y) {
|
|
589
|
+
const w = weaponOf(s.weapon);
|
|
590
|
+
switch (w.kind) {
|
|
591
|
+
case 'tracer':
|
|
592
|
+
g.sparks.push({ kind: 'tracer', x, y, smoke: !!w.smoke });
|
|
593
|
+
removeShell(g, s);
|
|
594
|
+
return;
|
|
595
|
+
case 'riot':
|
|
596
|
+
crater(g, x, y, w.radius);
|
|
597
|
+
g.sparks.push({ kind: 'blast', x, y, radius: w.radius, weapon: w.name, riot: true });
|
|
598
|
+
settleDirt(g, Math.floor(x - w.radius - 1), Math.ceil(x + w.radius + 1));
|
|
599
|
+
landTanks(g);
|
|
600
|
+
afterBlast(g, s.owner, 0);
|
|
601
|
+
removeShell(g, s);
|
|
602
|
+
return;
|
|
603
|
+
case 'dirt': {
|
|
604
|
+
const added = addDirt(g, x, y, w.radius);
|
|
605
|
+
g.sparks.push({ kind: 'dirt', x, y, radius: w.radius, added });
|
|
606
|
+
settleDirt(g, Math.floor(x - w.radius - 1), Math.ceil(x + w.radius + 1));
|
|
607
|
+
landTanks(g);
|
|
608
|
+
afterBlast(g, s.owner, 0);
|
|
609
|
+
removeShell(g, s);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
case 'napalm':
|
|
613
|
+
flow(g, x, y, w, s.owner);
|
|
614
|
+
removeShell(g, s);
|
|
615
|
+
return;
|
|
616
|
+
case 'roller': {
|
|
617
|
+
const col = Math.max(0, Math.min(COLS - 1, Math.floor(x)));
|
|
618
|
+
const left = g.tops[Math.max(0, col - 1)], right = g.tops[Math.min(COLS - 1, col + 1)];
|
|
619
|
+
s.rolling = true; s.dir = left < right ? -1 : right < left ? 1 : (s.vx < 0 ? -1 : 1);
|
|
620
|
+
s.rollLeft = 60; s.y = g.tops[col] + 0.3; s.x = col + 0.5;
|
|
621
|
+
g.sparks.push({ kind: 'roll', x: s.x, y: s.y });
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
case 'digger':
|
|
625
|
+
s.boring = { dx: 0, dy: -1, left: w.bore }; s.vx = 0; s.vy = 0;
|
|
626
|
+
g.sparks.push({ kind: 'bore', x, y });
|
|
627
|
+
return;
|
|
628
|
+
case 'sandhog': {
|
|
629
|
+
const sp = Math.hypot(s.vx, s.vy) || 1;
|
|
630
|
+
s.boring = { dx: s.vx / sp, dy: Math.min(-0.15, s.vy / sp), left: w.bore };
|
|
631
|
+
g.sparks.push({ kind: 'bore', x, y });
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
case 'funky': {
|
|
635
|
+
blastAt(g, s, x, y, { keep: true, scale: 0.6 });
|
|
636
|
+
for (let i = 0; i < w.bomblets; i++) {
|
|
637
|
+
const vx = (g.rnd() * 2 - 1) * 10, vy = 8 + g.rnd() * 10;
|
|
638
|
+
g.shells.push({ x, y: y + 0.5, vx, vy, weapon: 'funkyBomblet', owner: s.owner, path: [{ x, y }], t: 0, primary: false, hops: 0 });
|
|
639
|
+
}
|
|
640
|
+
removeShell(g, s);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
case 'leapfrog': {
|
|
644
|
+
const hop = (w.radii?.length ?? 0) - s.hops; // 0, 1, 2
|
|
645
|
+
const r = w.radii?.[Math.max(0, Math.min((w.radii?.length ?? 1) - 1, hop))] ?? w.radius;
|
|
646
|
+
blastAt(g, s, x, y, { keep: s.hops > 1, radius: r });
|
|
647
|
+
if (s.hops > 1) {
|
|
648
|
+
s.hops -= 1;
|
|
649
|
+
const col = Math.max(0, Math.min(COLS - 1, Math.floor(x)));
|
|
650
|
+
s.y = g.tops[col] + 0.6; s.vy = Math.abs(s.vy) * 0.55 + 6; s.vx *= 0.85; s.t = 0;
|
|
651
|
+
}
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
case 'liquidDirt':
|
|
655
|
+
ooze(g, x, y, w, s.owner);
|
|
656
|
+
removeShell(g, s);
|
|
657
|
+
return;
|
|
658
|
+
default:
|
|
659
|
+
blastAt(g, s, x, y);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function blastAt(g, s, x, y, { keep = false, scale = 1, radius = null } = {}) {
|
|
664
|
+
const w = weaponOf(s.weapon);
|
|
665
|
+
explode(g, x, y, { name: w.name, radius: (radius ?? w.radius) * scale, damage: w.damage * scale }, s.owner);
|
|
666
|
+
if (!keep) removeShell(g, s);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Liquid dirt: drops that ooze downhill along the surface and set where they pool, filling the
|
|
671
|
+
* holes. Simulated at once, like napalm; the dirt it leaves is real.
|
|
672
|
+
*/
|
|
673
|
+
function ooze(g, x, y, w, ownerId) {
|
|
674
|
+
const drops = [];
|
|
675
|
+
for (let i = 0; i < w.drops; i++) drops.push({ x: Math.max(0, Math.min(COLS - 1, Math.floor(x + (g.rnd() * 2 - 1) * 1.5))), alive: true });
|
|
676
|
+
let added = 0;
|
|
677
|
+
let lo = COLS, hi = 0;
|
|
678
|
+
for (let stepN = 0; stepN < w.steps; stepN++) {
|
|
679
|
+
for (const d of drops) {
|
|
680
|
+
if (!d.alive) continue;
|
|
681
|
+
const top = g.tops[d.x];
|
|
682
|
+
const l = d.x > 0 ? g.tops[d.x - 1] : Infinity, r = d.x < COLS - 1 ? g.tops[d.x + 1] : Infinity;
|
|
683
|
+
if (l < top && l <= r) d.x -= 1;
|
|
684
|
+
else if (r < top) d.x += 1;
|
|
685
|
+
else if (top < ROWS - 1) { // pooled: it sets here
|
|
686
|
+
g.dirt[idx(d.x, top)] = 1; g.tops[d.x] = top + 1; added += 1; d.alive = false;
|
|
687
|
+
lo = Math.min(lo, d.x); hi = Math.max(hi, d.x);
|
|
688
|
+
} else d.alive = false;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
if (added) g.landVersion += 1;
|
|
692
|
+
g.sparks.push({ kind: 'dirt', x, y, radius: 1.5, added, ooze: true });
|
|
693
|
+
landTanks(g);
|
|
694
|
+
afterBlast(g, ownerId, 0);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* A wedge from the turret, at once (Riot Charge, Riot Blast; Dirt Charge with `dig` false):
|
|
699
|
+
* every cell within `radius` of the muzzle and within `spread` degrees of the barrel's line is
|
|
700
|
+
* cleared, or filled.
|
|
701
|
+
*/
|
|
702
|
+
function wedge(g, tank, w) {
|
|
703
|
+
const m = muzzle(tank);
|
|
704
|
+
const a = (tank.angle * Math.PI) / 180;
|
|
705
|
+
const spread = (w.spread * Math.PI) / 180;
|
|
706
|
+
let changed = 0;
|
|
707
|
+
for (let x = Math.max(0, Math.floor(m.x - w.radius - 1)); x <= Math.min(COLS - 1, Math.ceil(m.x + w.radius + 1)); x++) {
|
|
708
|
+
for (let y = Math.max(0, Math.floor(m.y - w.radius - 1)); y <= Math.min(ROWS - 1, Math.ceil(m.y + w.radius + 1)); y++) {
|
|
709
|
+
const dx = x + 0.5 - m.x, dy = y + 0.5 - m.y;
|
|
710
|
+
const d = Math.hypot(dx, dy);
|
|
711
|
+
if (d > w.radius || d < 0.3) continue;
|
|
712
|
+
let da = Math.atan2(dy, dx) - a;
|
|
713
|
+
while (da > Math.PI) da -= 2 * Math.PI;
|
|
714
|
+
while (da < -Math.PI) da += 2 * Math.PI;
|
|
715
|
+
if (Math.abs(da) > spread) continue;
|
|
716
|
+
if (w.dig && g.dirt[idx(x, y)]) { g.dirt[idx(x, y)] = 0; changed += 1; }
|
|
717
|
+
else if (!w.dig && !g.dirt[idx(x, y)]) { g.dirt[idx(x, y)] = 1; changed += 1; }
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
if (changed) g.landVersion += 1;
|
|
721
|
+
g.sparks.push({ kind: w.dig ? 'blast' : 'dirt', x: m.x + Math.cos(a) * w.radius * 0.5, y: m.y + Math.sin(a) * w.radius * 0.5, radius: w.radius * 0.6, weapon: w.name, riot: true, wedge: true, removed: changed, added: changed });
|
|
722
|
+
settleDirt(g, Math.floor(m.x - w.radius - 1), Math.ceil(m.x + w.radius + 1));
|
|
723
|
+
landTanks(g);
|
|
724
|
+
afterBlast(g, tank.id, 0);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** The Earth Disrupter: every hanging piece of dirt on the field settles. */
|
|
728
|
+
function disrupt(g, tank) {
|
|
729
|
+
settleDirt(g, 0, COLS - 1);
|
|
730
|
+
g.sparks.push({ kind: 'disrupt', tank: tank.id, fell: g.falling.length });
|
|
731
|
+
landTanks(g);
|
|
732
|
+
afterBlast(g, tank.id, 0);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** The Plasma Blast: energy thrown from the tank itself, its reach set by the power; the thrower is spared. */
|
|
736
|
+
function plasma(g, tank, w) {
|
|
737
|
+
const r = w.minRadius + (w.radius - w.minRadius) * (tank.power / 1000);
|
|
738
|
+
const c = centre(tank);
|
|
739
|
+
explode(g, c.x, c.y, { name: w.name, radius: r, damage: w.damage }, tank.id, { spare: tank.id, dig: false }); // energy, not a crater
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function endFlight(g) {
|
|
743
|
+
g.shells = [];
|
|
744
|
+
g.phase = 'settle';
|
|
745
|
+
if (!g.falling.length) settled(g);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** The laser: a straight line from the muzzle, at once; dirt along it goes, tanks on it burn. */
|
|
749
|
+
function laser(g, tank, w) {
|
|
750
|
+
const a = (tank.angle * Math.PI) / 180;
|
|
751
|
+
const m = muzzle(tank);
|
|
752
|
+
const dx = Math.cos(a), dy = Math.sin(a);
|
|
753
|
+
let x = m.x, y = m.y, d = 0;
|
|
754
|
+
const burnt = new Set();
|
|
755
|
+
while (x >= 0 && x < COLS && y >= 0 && y < ROWS + 2 && d < 200) {
|
|
756
|
+
crater(g, x, y, w.radius);
|
|
757
|
+
const t = tankAt(g, x, y, tank.id, 0.2);
|
|
758
|
+
if (t && !burnt.has(t.id)) { burnt.add(t.id); applyDamage(g, t, w.damage, tank.id); }
|
|
759
|
+
x += dx * 0.25; y += dy * 0.25; d += 0.25;
|
|
760
|
+
}
|
|
761
|
+
g.sparks.push({ kind: 'laser', x0: m.x, y0: m.y, x1: x, y1: y });
|
|
762
|
+
settleDirt(g, Math.floor(Math.min(m.x, x)) - 1, Math.ceil(Math.max(m.x, x)) + 1);
|
|
763
|
+
landTanks(g);
|
|
764
|
+
afterBlast(g, tank.id, 0);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Napalm: drops that run downhill along the surface and burn whatever they sit on, spent when
|
|
769
|
+
* they pool in a dip. Simulated at once; the screen animates the cells it is told about.
|
|
770
|
+
*/
|
|
771
|
+
function flow(g, x, y, w, ownerId) {
|
|
772
|
+
const cells = [];
|
|
773
|
+
const drops = [];
|
|
774
|
+
for (let i = 0; i < w.drops; i++) drops.push({ x: Math.max(0, Math.min(COLS - 1, Math.floor(x + (g.rnd() * 2 - 1) * 1.5))), alive: true });
|
|
775
|
+
const burnt = new Map();
|
|
776
|
+
for (let stepN = 0; stepN < w.steps; stepN++) {
|
|
777
|
+
for (const d of drops) {
|
|
778
|
+
if (!d.alive) continue;
|
|
779
|
+
const top = g.tops[d.x];
|
|
780
|
+
cells.push({ x: d.x, y: top, step: stepN });
|
|
781
|
+
const t = tankAt(g, d.x + 0.5, top + 0.5, -1, 0.6);
|
|
782
|
+
if (t) {
|
|
783
|
+
const so = burnt.get(t.id) ?? 0;
|
|
784
|
+
if (so < w.damage * 6) { applyDamage(g, t, w.damage, ownerId); burnt.set(t.id, so + w.damage); }
|
|
785
|
+
}
|
|
786
|
+
const l = d.x > 0 ? g.tops[d.x - 1] : Infinity, r = d.x < COLS - 1 ? g.tops[d.x + 1] : Infinity;
|
|
787
|
+
if (l < top && l <= r) d.x -= 1;
|
|
788
|
+
else if (r < top) d.x += 1;
|
|
789
|
+
else if (g.rnd() < 0.35) d.alive = false; // pooled: it burns out
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
g.sparks.push({ kind: 'napalm', cells, x, y });
|
|
793
|
+
landTanks(g);
|
|
794
|
+
afterBlast(g, ownerId, 0);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* A blast: the dirt inside the circle goes, tanks in reach take damage by distance, the dirt
|
|
799
|
+
* above the crater falls, tanks left in the air fall with it. Deaths are handled, and can chain.
|
|
800
|
+
*/
|
|
801
|
+
export function explode(g, cx, cy, weapon, ownerId, { chain = 0, spare = -1, dig = true } = {}) {
|
|
802
|
+
const r = weapon.radius;
|
|
803
|
+
const removed = dig ? crater(g, cx, cy, r) : 0;
|
|
804
|
+
g.sparks.push({ kind: 'blast', x: cx, y: cy, radius: r, weapon: weapon.name, removed });
|
|
805
|
+
// damage: full inside half the radius, falling to nothing a cell beyond the rim
|
|
806
|
+
for (const t of g.tanks) {
|
|
807
|
+
if (!t.alive || t.id === spare) continue;
|
|
808
|
+
const d = distToTank(cx, cy, t);
|
|
809
|
+
if (d > r + 1) continue;
|
|
810
|
+
const k = d <= r * 0.5 ? 1 : Math.max(0, 1 - (d - r * 0.5) / (r * 0.5 + 1));
|
|
811
|
+
const dmg = Math.round(weapon.damage * k);
|
|
812
|
+
if (dmg > 0) applyDamage(g, t, dmg, ownerId);
|
|
813
|
+
}
|
|
814
|
+
settleDirt(g, Math.floor(cx - r - 1), Math.ceil(cx + r + 1));
|
|
815
|
+
landTanks(g);
|
|
816
|
+
afterBlast(g, ownerId, chain);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** Damage through a shield first; cash to the attacker for every point of health that went. */
|
|
820
|
+
export function applyDamage(g, t, dmg, byId) {
|
|
821
|
+
let left = dmg;
|
|
822
|
+
let absorbed = 0;
|
|
823
|
+
if (t.shield) {
|
|
824
|
+
absorbed = Math.min(t.shield.hp, left);
|
|
825
|
+
t.shield.hp -= absorbed;
|
|
826
|
+
left -= absorbed;
|
|
827
|
+
if (t.shield.hp <= 0) { t.shield = null; g.sparks.push({ kind: 'shieldDown', tank: t.id }); }
|
|
828
|
+
}
|
|
829
|
+
const before = t.health;
|
|
830
|
+
t.health = Math.max(0, t.health - left);
|
|
831
|
+
const lost = before - t.health;
|
|
832
|
+
const by = g.tanks[byId] ?? null;
|
|
833
|
+
if (by && by !== t) { by.damageDealt += lost; by.cash += lost * CASH_PER_DAMAGE; if (lost > 0) t.lastHitBy = byId; }
|
|
834
|
+
g.sparks.push({ kind: 'hit', tank: t.id, damage: lost, absorbed, by: byId });
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function afterBlast(g, ownerId, chain) {
|
|
838
|
+
for (const t of g.tanks) if (t.alive && t.health <= 0) kill(g, t, ownerId, chain);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function distToTank(cx, cy, t) {
|
|
842
|
+
const nx = Math.max(t.x, Math.min(t.x + TANK_W, cx)), ny = Math.max(t.y, Math.min(t.y + TANK_H, cy));
|
|
843
|
+
return Math.hypot(cx - nx, cy - ny);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** Tanks left in the air fall to the ground under them; a fall of more than a cell hurts, unless a parachute opens. */
|
|
847
|
+
export function landTanks(g) {
|
|
848
|
+
for (const t of g.tanks) {
|
|
849
|
+
if (!t.alive) continue;
|
|
850
|
+
const ground = Math.min(g.tops[t.x], g.tops[Math.min(COLS - 1, t.x + 1)]);
|
|
851
|
+
if (ground >= t.y) {
|
|
852
|
+
if (ground > t.y) t.y = ground; // buried by dirt that landed on it: dug out to the surface
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
const drop = t.y - ground;
|
|
856
|
+
t.y = ground;
|
|
857
|
+
if (drop > 1 && (t.items.parachute ?? 0) > 0) {
|
|
858
|
+
t.items.parachute -= 1;
|
|
859
|
+
g.sparks.push({ kind: 'chute', tank: t.id, cells: drop });
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
const dmg = Math.max(0, Math.round((drop - 1) * FALL_DAMAGE));
|
|
863
|
+
if (dmg > 0) t.health = Math.max(0, t.health - dmg);
|
|
864
|
+
g.sparks.push({ kind: 'fall', tank: t.id, cells: drop, damage: dmg });
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function kill(g, t, byId, chain) {
|
|
869
|
+
t.alive = false;
|
|
870
|
+
t.health = 0;
|
|
871
|
+
t.shield = null;
|
|
872
|
+
const by = g.tanks[byId] ?? null;
|
|
873
|
+
if (by && by !== t) { by.kills += 1; by.score += 100; by.cash += KILL_BONUS; }
|
|
874
|
+
else t.score -= 50; // by its own shot, or the fall
|
|
875
|
+
g.sparks.push({ kind: 'death', tank: t.id, by: byId });
|
|
876
|
+
// the death blast, which can take a neighbour with it (chained at most as deep as there are tanks)
|
|
877
|
+
if (chain < g.tanks.length) explode(g, t.x + TANK_W / 2, t.y + 0.5, DEATH_BLAST, t.id, { chain: chain + 1 });
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** The screen has finished animating the fall (or a test skips it): the turn passes. */
|
|
881
|
+
export function settled(g) {
|
|
882
|
+
g.falling = [];
|
|
883
|
+
if (g.phase !== 'settle') return;
|
|
884
|
+
const living = alive(g);
|
|
885
|
+
if (living.length <= 1) {
|
|
886
|
+
g.phase = 'roundOver';
|
|
887
|
+
const w = living[0] ?? null;
|
|
888
|
+
if (w) { w.score += 200; w.cash += SURVIVOR_BONUS; }
|
|
889
|
+
g.sparks.push({ kind: 'roundOver', winner: w?.id ?? null, round: g.round, last: g.round >= g.rounds });
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
nextTurn(g);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
export function nextTurn(g) {
|
|
896
|
+
const n = g.order.length;
|
|
897
|
+
for (let k = 1; k <= n; k++) {
|
|
898
|
+
const i = (g.turn + k) % n;
|
|
899
|
+
if (g.tanks[g.order[i]].alive) { g.turn = i; break; }
|
|
900
|
+
}
|
|
901
|
+
// the round mode keeps this round's direction and rolls the strength again; the original's mode
|
|
902
|
+
// rolls both; the others leave it alone
|
|
903
|
+
if (g.windMode === 'turn') newWind(g);
|
|
904
|
+
else if (g.windMode === 'round') newWind(g, g.wind < 0 ? -1 : 1);
|
|
905
|
+
g.phase = 'aim';
|
|
906
|
+
const t = current(g);
|
|
907
|
+
autoDefend(g, t);
|
|
908
|
+
g.sparks.push({ kind: 'turn', tank: t.id });
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** After a round: interest is paid, then the next one, or the game is over. */
|
|
912
|
+
export function nextRound(g) {
|
|
913
|
+
if (g.phase !== 'roundOver') return false;
|
|
914
|
+
if (g.round >= g.rounds) { g.phase = 'over'; g.sparks.push({ kind: 'over', winner: leader(g)?.id ?? null }); return false; }
|
|
915
|
+
for (const t of g.tanks) payInterest(t, g.interest);
|
|
916
|
+
g.round += 1;
|
|
917
|
+
startRound(g);
|
|
918
|
+
return true;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
export function leader(g) {
|
|
922
|
+
return [...g.tanks].sort((a, b) => b.score - a.score || b.kills - a.kills || b.health - a.health)[0] ?? null;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// ------------------------------------------------------------------- the picture
|
|
926
|
+
// WHAT EACH SHELL LOOKS LIKE IN THE AIR (operator, 2026-09-16: "Everything is the same white dot
|
|
927
|
+
// effect for the shot"; then "The missile effect is the same as the baby missile. I said I wanted
|
|
928
|
+
// variance across all shot types"). One entry PER WEAPON that flies, twenty-seven of them: the
|
|
929
|
+
// core is the ball the tile layer draws, in its own colour and size; the glow, the trail's manner
|
|
930
|
+
// and length, its colour, and a ring for the heavy ones are painted over it by scorchedfx.js.
|
|
931
|
+
// Within a family the baby is small and pale, the plain one middling, the heavy one big, dark
|
|
932
|
+
// and ringed -- so a player can tell what is coming before it lands.
|
|
933
|
+
const L = (core, glow, trail, size, o = {}) => Object.freeze({ core, glow, trail, size, len: 6, count: 4, width: 0.3, col: glow, ring: null, spin: 1, ...o });
|
|
934
|
+
export const SHELL_LOOKS = Object.freeze({
|
|
935
|
+
// the blasts
|
|
936
|
+
babyMissile: L('#fff2c8', [255, 205, 130], 'flame', 0.42, { len: 4, col: [255, 180, 90] }),
|
|
937
|
+
missile: L('#ffd27a', [255, 150, 60], 'flame', 0.6, { len: 9, col: [255, 110, 40], ring: [255, 120, 40] }),
|
|
938
|
+
babyNuke: L('#e6ffc4', [150, 255, 110], 'radio', 0.6, { count: 5 }),
|
|
939
|
+
nuke: L('#ccff99', [100, 255, 70], 'radio', 0.82, { count: 11, width: 1.5, ring: [160, 255, 120] }),
|
|
940
|
+
// the ones that do something first
|
|
941
|
+
leapfrog: L('#d2ffc8', [120, 255, 150], 'comet', 0.55, { len: 8, width: 0.3 }),
|
|
942
|
+
funkyBomb: L('#ffffff', null, 'rainbow', 0.55, { len: 12, spin: 1 }),
|
|
943
|
+
funkyBomblet: L('#ffffff', null, 'rainbow', 0.34, { len: 6, spin: 2.5 }),
|
|
944
|
+
mirv: L('#ecdcff', [190, 150, 255], 'comet', 0.55, { len: 10, width: 0.38 }),
|
|
945
|
+
deathsHead: L('#2b1d3a', [255, 60, 90], 'comet', 0.72, { len: 14, width: 0.5, ring: [255, 220, 230] }),
|
|
946
|
+
napalm: L('#ffb347', [255, 120, 40], 'fire', 0.5, { len: 7, col: [255, 120, 30] }),
|
|
947
|
+
hotNapalm: L('#ffffff', [120, 180, 255], 'fire', 0.58, { len: 10, col: [90, 150, 255], ring: [200, 230, 255] }),
|
|
948
|
+
tracer: L('#c9ced8', null, 'dash', 0.3, { len: 14 }),
|
|
949
|
+
smokeTracer: L('#b8bcc4', null, 'smoke', 0.36, { len: 12 }),
|
|
950
|
+
babyRoller: L('#b5bcc6', [200, 200, 215], 'sparks', 0.45, { count: 3 }),
|
|
951
|
+
roller: L('#8f98a4', [220, 210, 200], 'sparks', 0.6, { count: 5 }),
|
|
952
|
+
heavyRoller: L('#5b6470', [255, 160, 90], 'sparks', 0.82, { count: 9, ring: [255, 140, 70] }),
|
|
953
|
+
// dirt, off and on
|
|
954
|
+
riotBomb: L('#c0f2ff', [150, 220, 255], 'wisp', 0.55, { len: 6 }),
|
|
955
|
+
heavyRiotBomb: L('#7fd0ff', [90, 180, 255], 'wisp', 0.78, { len: 11, ring: [170, 220, 255] }),
|
|
956
|
+
dirtClod: L('#b07a40', [180, 130, 80], 'clods', 0.5, { count: 3 }),
|
|
957
|
+
dirtBall: L('#8f6234', [160, 115, 70], 'clods', 0.7, { count: 5 }),
|
|
958
|
+
tonOfDirt: L('#5e4023', [130, 90, 50], 'clods', 1.05, { count: 9, ring: [110, 80, 45] }),
|
|
959
|
+
liquidDirt: L('#7c5a35', [150, 110, 60], 'drip', 0.55, { count: 5 }),
|
|
960
|
+
// the diggers
|
|
961
|
+
babyDigger: L('#ffb36b', [255, 170, 90], 'drill', 0.4, { count: 2, spin: 1 }),
|
|
962
|
+
digger: L('#ff9a3c', [255, 150, 60], 'drill', 0.52, { count: 2, spin: 1.6 }),
|
|
963
|
+
heavyDigger: L('#e06a1c', [255, 120, 50], 'drill', 0.68, { count: 3, spin: 1.3, ring: [255, 170, 100] }),
|
|
964
|
+
babySandhog: L('#ff9a7a', [255, 140, 120], 'drill', 0.45, { count: 2, spin: 2 }),
|
|
965
|
+
sandhog: L('#ff7a3c', [255, 110, 60], 'drill', 0.56, { count: 3, spin: 2 }),
|
|
966
|
+
heavySandhog: L('#c83c1c', [255, 70, 40], 'drill', 0.74, { count: 4, spin: 1.7, ring: [255, 120, 90] }),
|
|
967
|
+
});
|
|
968
|
+
/** The look for a weapon id; anything unknown flies as a baby missile. */
|
|
969
|
+
export function shellLook(weaponId) {
|
|
970
|
+
return SHELL_LOOKS[weaponId] ?? SHELL_LOOKS.babyMissile;
|
|
971
|
+
}
|
|
972
|
+
const strataColour = (y, top, x) => {
|
|
973
|
+
const f = top > 0 ? (y + 0.5) / top : 0;
|
|
974
|
+
let s = STRATA[STRATA.length - 1], i = STRATA.length - 1;
|
|
975
|
+
for (let b = 0; b < STRATA.length; b++) if (f <= STRATA[b].to) { s = STRATA[b]; i = b; break; }
|
|
976
|
+
// a nudge per column and stratum so the face is not flat colour
|
|
977
|
+
const k = 0.9 + 0.2 * hash01(x * 7 + i * 131);
|
|
978
|
+
return shade(s.color, k);
|
|
979
|
+
};
|
|
980
|
+
function shade(hex, k) {
|
|
981
|
+
const h = hex.replace('#', '');
|
|
982
|
+
const ch = (i) => Math.max(0, Math.min(255, Math.round(parseInt(h.slice(i, i + 2), 16) * k)));
|
|
983
|
+
return `#${[ch(0), ch(2), ch(4)].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* The land as tiles for the engine: A CUBE PER CELL, coloured by stratum. Not one tall tile per
|
|
988
|
+
* run: under the oblique camera a tile's height climbs the screen at 0.3 of a grid row, so a run
|
|
989
|
+
* of twelve cells stood four rows tall and the next stratum began twelve rows up -- the land drew
|
|
990
|
+
* as floating ribbons. Tetrust's well is cells for the same reason. About 1,800 cubes on a fresh
|
|
991
|
+
* field; the screen draws them on their own canvas and redraws it only when `landVersion` moves,
|
|
992
|
+
* so they cost nothing while a shell flies. `omit` (a Set of "x,y") leaves cells out -- the ones
|
|
993
|
+
* the screen is animating on its actor layer while they fall.
|
|
994
|
+
*/
|
|
995
|
+
export function landTiles(g, { omit = null } = {}) {
|
|
996
|
+
const out = [];
|
|
997
|
+
for (let x = 0; x < COLS; x++) {
|
|
998
|
+
const top = g.tops[x];
|
|
999
|
+
for (let y = 0; y < ROWS; y++) {
|
|
1000
|
+
if (!g.dirt[idx(x, y)]) continue;
|
|
1001
|
+
if (omit?.has(`${x},${y}`)) continue;
|
|
1002
|
+
out.push({ txid: `c${x}:${y}`, x, y, s: 1, tall: 1, color: strataColour(y, top, x) });
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return out;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* The actors: the tanks as a hull, a turret and a barrel (and a shield as a wire cube round them),
|
|
1010
|
+
* every shell as a ball, and the last shot's trace as a string of dim beads. Ids are stable, so a
|
|
1011
|
+
* tank that has not moved is the same tile frame after frame.
|
|
1012
|
+
*/
|
|
1013
|
+
export function actorTiles(g, { trace = true, tankY = null, chutes = null } = {}) {
|
|
1014
|
+
const out = [];
|
|
1015
|
+
for (const t of g.tanks) {
|
|
1016
|
+
if (!t.alive) continue;
|
|
1017
|
+
const c = t.colour;
|
|
1018
|
+
const y = tankY?.get(t.id) ?? t.y; // the screen may still be lowering it after a fall
|
|
1019
|
+
out.push({ txid: `track${t.id}`, x: t.x - 0.15, y: y - 0.05, s: 1.15, tall: 0.35, color: shade(c, 0.45) });
|
|
1020
|
+
out.push({ txid: `track${t.id}b`, x: t.x + 1, y: y - 0.05, s: 1.15, tall: 0.35, color: shade(c, 0.45) });
|
|
1021
|
+
out.push({ txid: `hull${t.id}a`, x: t.x, y: y + 0.3, s: 1, tall: 0.9, color: c, label: t.name });
|
|
1022
|
+
out.push({ txid: `hull${t.id}b`, x: t.x + 1, y: y + 0.3, s: 1, tall: 0.9, color: c, label: t.name });
|
|
1023
|
+
out.push({ txid: `turret${t.id}`, x: t.x + 0.65, y: y + 0.55, s: 0.7, tall: 1.6, color: shade(c, 1.15) });
|
|
1024
|
+
// the barrel: a bar in unit space pointing right, turned by the angle (screen y is down, so
|
|
1025
|
+
// a counter-clockwise angle on the field is a negative rotation on the screen)
|
|
1026
|
+
out.push({
|
|
1027
|
+
txid: `barrel${t.id}`, x: t.x + TANK_W / 2 - 0.5, y: y + 0.6, s: 1, tall: 1.4, color: shade(c, 0.85),
|
|
1028
|
+
poly: [[0, -0.06], [0.75, -0.06], [0.75, 0.06], [0, 0.06]], rot: -(t.angle * Math.PI) / 180,
|
|
1029
|
+
});
|
|
1030
|
+
// a pennant on the turret, streaming downwind and lifting with the strength (the wind made visible)
|
|
1031
|
+
const w = g.wind;
|
|
1032
|
+
if (Math.abs(w) > 0.05) {
|
|
1033
|
+
const len = 0.45 + 0.5 * Math.min(1, Math.abs(w) / WIND_MAX), lift = 0.35 * (1 - Math.min(1, Math.abs(w) / WIND_MAX));
|
|
1034
|
+
const dir = w > 0 ? 1 : -1;
|
|
1035
|
+
out.push({
|
|
1036
|
+
txid: `flag${t.id}`, x: t.x + TANK_W / 2 - 0.5, y: y + 1.9, s: 1, tall: 0.01, color: shade(c, 1.2),
|
|
1037
|
+
poly: [[0, 0], [dir * len, -lift * 0.5 - 0.06], [dir * len * 0.85, -lift * 0.5 + 0.06]], rot: 0,
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
if (chutes?.has(t.id)) {
|
|
1041
|
+
// a canopy over a tank coming down under its parachute: a half-disc, and the lines to the hull
|
|
1042
|
+
const pts = [];
|
|
1043
|
+
for (let i = 0; i <= 8; i++) { const a = Math.PI + (i / 8) * Math.PI; pts.push([Math.cos(a) * 0.5, Math.sin(a) * 0.5 - 0.1]); }
|
|
1044
|
+
pts.push([0.5, -0.1], [0.15, 0.45], [-0.15, 0.45], [-0.5, -0.1]);
|
|
1045
|
+
out.push({ txid: `chute${t.id}`, x: t.x + TANK_W / 2 - 1.4, y: y + 2.4, s: 2.8, tall: 0.01, color: '#f2efe6', poly: pts, rot: 0 });
|
|
1046
|
+
}
|
|
1047
|
+
if (t.shield) {
|
|
1048
|
+
const k = t.shield.id === 'heavyShield' ? '#7ad7ff' : t.shield.id === 'forceShield' ? '#8fe0ff' : '#9ce8ff';
|
|
1049
|
+
out.push({ txid: `shield${t.id}`, x: t.x - 0.6, y: y - 0.4, s: TANK_W + 1.2, tall: 1.6, wire: k, color: k });
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
g.shells.forEach((s, i) => {
|
|
1053
|
+
const look = shellLook(s.weapon);
|
|
1054
|
+
const sz = look.size;
|
|
1055
|
+
out.push({ txid: `shell${i}`, x: s.x - sz / 2, y: s.y - sz / 2, s: sz, tall: sz, sphere: true, color: s.boring ? '#ffb347' : look.core });
|
|
1056
|
+
});
|
|
1057
|
+
if (trace && g.lastPath.length > 2) {
|
|
1058
|
+
const every = Math.max(1, Math.floor(g.lastPath.length / 40));
|
|
1059
|
+
for (let i = 0; i < g.lastPath.length; i += every) {
|
|
1060
|
+
const p = g.lastPath[i];
|
|
1061
|
+
if (p.y < 0 || p.y > ROWS + 4) continue;
|
|
1062
|
+
out.push({ txid: `trace${i}`, x: p.x - 0.1, y: p.y - 0.1, s: 0.2, tall: 0.2, sphere: true, color: '#7a8699' });
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
return out;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/** The whole picture: the land and the actors (the screen draws them on two canvases; a test wants both). */
|
|
1069
|
+
export function tiles(g, opts = {}) {
|
|
1070
|
+
return [...landTiles(g, opts), ...actorTiles(g, opts)];
|
|
1071
|
+
}
|