blockyard 0.1.0 → 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 +108 -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 +239 -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 +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 +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 +21 -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,376 @@
|
|
|
1
|
+
// SCORCHED YARD'S EXPLOSIONS, PAINTED (operator, 2026-09-16: "We have fucking firework and nebula
|
|
2
|
+
// effects and you're doing shitty block and sprite explosions?").
|
|
3
|
+
//
|
|
4
|
+
// The blast used to be tiles -- first spheres, which the engine gives a rim and a glint so a cloud
|
|
5
|
+
// of them read as bubbles, then cubes, which are honest but are not an explosion. Meanwhile the
|
|
6
|
+
// renderer already knows how to draw a firework: a white core, a shockwave, sparks that curve and
|
|
7
|
+
// trail and twinkle, and a nebula of smoke built from flat discs that add up to a soft cloud
|
|
8
|
+
// (softStops, because gradients band on this rasteriser). That machinery is what a shell landing
|
|
9
|
+
// deserves, so this module draws with it.
|
|
10
|
+
//
|
|
11
|
+
// Everything here is painted through the BOARD'S OWN PROJECTION: `P(gx, gy, gz)` is the renderer's
|
|
12
|
+
// projector, handed to the overlay hook in details3d, so a crater at cell 40,12 and the fire over
|
|
13
|
+
// it are the same place on the screen. `U` is how many pixels a cell is, across and up, which is
|
|
14
|
+
// how a radius in cells becomes a radius in pixels.
|
|
15
|
+
//
|
|
16
|
+
// Nothing in here reads the DOM or the clock: the caller passes `now`, so a test can hold any
|
|
17
|
+
// moment of any blast and a replay is exact.
|
|
18
|
+
|
|
19
|
+
/** A line of the spark's recent path, fading from its head. */
|
|
20
|
+
function trail(ctx, pts, colour, w) {
|
|
21
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
22
|
+
const a = 1 - i / pts.length;
|
|
23
|
+
ctx.strokeStyle = colour(a);
|
|
24
|
+
ctx.lineWidth = Math.max(0.6, w * a);
|
|
25
|
+
ctx.beginPath();
|
|
26
|
+
ctx.moveTo(pts[i].x, pts[i].y);
|
|
27
|
+
ctx.lineTo(pts[i + 1].x, pts[i + 1].y);
|
|
28
|
+
ctx.stroke();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// HOW MANY NESTED DISCS A SOFT FILL GETS (operator, 2026-09-16: "performance freezing and jittering
|
|
33
|
+
// when the cubes are being blown up"). softStops picks about one ring a pixel of radius when it is not
|
|
34
|
+
// told, up to 220 -- right for a still sky, ruinous for a nuke, whose smoke is fifty-odd large puffs
|
|
35
|
+
// every frame: thousands of big translucent fills, measured at 50 ms frames. Smoke that is moving,
|
|
36
|
+
// thinning and overlapping its neighbours shows no steps at a dozen rings; a fireball's core, which
|
|
37
|
+
// is brighter and briefer, gets a few more.
|
|
38
|
+
export const RINGS = Object.freeze({ core: 36, glow: 18, smoke: 12 });
|
|
39
|
+
const disc = (ctx, x, y, r, fill) => { ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(x, y, Math.max(0.4, r), 0, Math.PI * 2); ctx.fill(); };
|
|
40
|
+
const h01 = (n) => { const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* THE FIREBALL, THE SHOCKWAVE, THE SPARKS AND THE SMOKE.
|
|
44
|
+
*
|
|
45
|
+
* 0.00-0.25 the core: white, then gold, then orange, growing
|
|
46
|
+
* 0.00-0.50 the shockwave, an ellipse racing out and thinning (a circle on the ground is an
|
|
47
|
+
* ellipse on an oblique board, so it is drawn from the two cell sizes)
|
|
48
|
+
* 0.00-1.00 sparks on ballistic paths, each a curved trail of its last positions, twinkling
|
|
49
|
+
* 0.05-1.00 the smoke: a nebula of soft lumps leaving the burst, rising, drifting downwind,
|
|
50
|
+
* growing and thinning -- the fireworks' own dissipation, in dirt colours
|
|
51
|
+
*/
|
|
52
|
+
export function paintBlasts(ctx, P, U, blasts, now, { wind = 0, softStops, ms = 650, smokeMs = 2400 } = {}) {
|
|
53
|
+
const lw = ctx.lineWidth;
|
|
54
|
+
for (const b of blasts) {
|
|
55
|
+
const age = now - b.t0;
|
|
56
|
+
const t = Math.min(1, Math.max(0, age / ms));
|
|
57
|
+
const R = Math.max(2, b.r * U.x);
|
|
58
|
+
const c = P(b.x, b.y, 1.2);
|
|
59
|
+
const riot = !!b.riot;
|
|
60
|
+
|
|
61
|
+
if (t < 1) {
|
|
62
|
+
// --- the core
|
|
63
|
+
const fade = t < 0.22 ? 1 : Math.max(0, 1 - (t - 0.22) / 0.78);
|
|
64
|
+
const fr = R * (0.5 + 0.9 * t);
|
|
65
|
+
const core = riot
|
|
66
|
+
? [[0, `rgba(240,252,255,${(0.95 * fade).toFixed(3)})`], [0.2, `rgba(160,220,255,${(0.85 * fade).toFixed(3)})`], [0.5, `rgba(80,160,230,${(0.45 * fade).toFixed(3)})`], [1, 'rgba(40,90,150,0)']]
|
|
67
|
+
: [[0, `rgba(255,255,240,${(0.98 * fade).toFixed(3)})`], [0.14, `rgba(255,236,170,${(0.92 * fade).toFixed(3)})`], [0.36, `rgba(255,158,64,${(0.66 * fade).toFixed(3)})`], [0.66, `rgba(196,66,26,${(0.3 * fade).toFixed(3)})`], [1, 'rgba(120,30,12,0)']];
|
|
68
|
+
softStops(ctx, c.x, c.y, fr * 1.9, core, RINGS.core);
|
|
69
|
+
|
|
70
|
+
// --- the shockwave
|
|
71
|
+
// it belongs to the crater, not to the panel: a ring that races three radii out crosses the
|
|
72
|
+
// whole field and reads as a stray circle drawn over the picture
|
|
73
|
+
if (t < 0.38) {
|
|
74
|
+
const k = t / 0.38;
|
|
75
|
+
const rr = b.r * (0.5 + 1.3 * k);
|
|
76
|
+
const gone = (1 - k) * (1 - k);
|
|
77
|
+
ctx.strokeStyle = riot ? `rgba(200,240,255,${(0.7 * gone).toFixed(3)})` : `rgba(255,236,196,${(0.75 * gone).toFixed(3)})`;
|
|
78
|
+
ctx.lineWidth = Math.max(lw, U.x * 0.3 * (1 - k));
|
|
79
|
+
ctx.beginPath();
|
|
80
|
+
ctx.ellipse(c.x, c.y, rr * U.x, Math.max(1, rr * U.y), 0, 0, Math.PI * 2);
|
|
81
|
+
ctx.stroke();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// --- the sparks: ballistic in cells, drawn as curved trails
|
|
85
|
+
// NOT A STARBURST OF RAYS: every spark leaving the centre at the same speed down the same
|
|
86
|
+
// straight line draws a sun, not an explosion. Each one gets its own speed, its own weight
|
|
87
|
+
// and a curl across its flight, and the slow ones stay near the crater where the fire is.
|
|
88
|
+
const n = Math.min(80, Math.round(16 + b.r * 5));
|
|
89
|
+
for (let i = 0; i < n; i++) {
|
|
90
|
+
const H = (k) => h01(b.id * 97 + i * 13 + k);
|
|
91
|
+
const a = (i / n) * Math.PI * 2 + H(1) * 0.9;
|
|
92
|
+
const slow = H(5) * H(5); // most are slow, a few fly
|
|
93
|
+
const sp = b.r * (0.7 + 2.2 * slow);
|
|
94
|
+
const curl = (H(6) - 0.5) * 1.6;
|
|
95
|
+
const drop = 5 + H(3) * 9;
|
|
96
|
+
const at = (u) => {
|
|
97
|
+
const aa = a + curl * u;
|
|
98
|
+
const x = b.x + Math.cos(aa) * sp * u;
|
|
99
|
+
const y = b.y + Math.sin(aa) * sp * u * 0.85 - drop * u * u;
|
|
100
|
+
return P(x, y, 1.2);
|
|
101
|
+
};
|
|
102
|
+
const fadeS = Math.max(0, 1 - t * (0.7 + H(7) * 0.6));
|
|
103
|
+
if (fadeS <= 0.02) continue;
|
|
104
|
+
const twinkle = 0.55 + 0.45 * Math.sin(now / 42 + i * 2.1);
|
|
105
|
+
const pts = [];
|
|
106
|
+
const step = 0.02 + H(2) * 0.03;
|
|
107
|
+
for (let k = 0; k <= 5; k++) pts.push(at(Math.max(0, t - k * step)));
|
|
108
|
+
const warm = H(4) > 0.55;
|
|
109
|
+
trail(ctx, pts, (al) => (riot
|
|
110
|
+
? `rgba(170,225,255,${(0.55 * al * fadeS).toFixed(3)})`
|
|
111
|
+
: warm ? `rgba(255,190,90,${(0.6 * al * fadeS).toFixed(3)})` : `rgba(255,120,50,${(0.5 * al * fadeS).toFixed(3)})`), U.x * 0.2);
|
|
112
|
+
const head = pts[0];
|
|
113
|
+
disc(ctx, head.x, head.y, U.x * 0.075 * (0.6 + 0.6 * twinkle) * fadeS, riot ? `rgba(235,250,255,${(0.9 * fadeS).toFixed(3)})` : `rgba(255,246,214,${(0.9 * fadeS * twinkle).toFixed(3)})`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- the smoke, a nebula in dirt colours
|
|
118
|
+
if (!riot && b.r >= 1.2 && age > 60 && age < smokeMs) {
|
|
119
|
+
const u = age / smokeMs;
|
|
120
|
+
const lumps = Math.min(26, 8 + Math.round(b.r * 2.2));
|
|
121
|
+
for (let i = 0; i < lumps; i++) {
|
|
122
|
+
const H = (k) => h01(b.id * 31 + i * 7 + k);
|
|
123
|
+
const life = 0.72 + H(5) * 0.28;
|
|
124
|
+
if (u > life) continue;
|
|
125
|
+
const uu = u / life;
|
|
126
|
+
const ang = Math.PI * (0.1 + 0.8 * H(1));
|
|
127
|
+
const out = b.r * (0.5 + 1.4 * H(2)) * Math.sqrt(uu);
|
|
128
|
+
const x = b.x + Math.cos(ang) * out * 0.8 + wind * 0.3 * (age / 1000);
|
|
129
|
+
const y = b.y + Math.sin(ang) * out + 0.4 + uu * b.r * 0.5;
|
|
130
|
+
const p = P(x, y, 1.2);
|
|
131
|
+
const rr = U.x * b.r * (0.22 + 0.5 * uu) * (0.6 + H(3) * 0.8);
|
|
132
|
+
const dark = 1 - 0.45 * uu;
|
|
133
|
+
const al = 0.42 * (1 - uu) * (0.7 + H(4) * 0.5);
|
|
134
|
+
const g0 = Math.round(126 * dark), g1 = Math.round(96 * dark);
|
|
135
|
+
// two offset discs per lump, so a cloud is not a row of circles
|
|
136
|
+
softStops(ctx, p.x, p.y, rr, [[0, `rgba(${g0 + 24},${g0 + 18},${g0 + 12},${al.toFixed(3)})`], [0.55, `rgba(${g1},${g1 - 4},${g1 - 10},${(al * 0.55).toFixed(3)})`], [1, `rgba(${g1},${g1},${g1},0)`]], RINGS.smoke);
|
|
137
|
+
softStops(ctx, p.x + rr * 0.45, p.y - rr * 0.3, rr * 0.7, [[0, `rgba(${g0 + 12},${g0 + 8},${g0},${(al * 0.7).toFixed(3)})`], [1, `rgba(${g1},${g1},${g1},0)`]], RINGS.smoke);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
ctx.lineWidth = lw;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A tank going up: a white flash, a burst of sparks in its own colour, and a puff of smoke. */
|
|
145
|
+
export function paintDeaths(ctx, P, U, deaths, now, { softStops, ms = 1100 } = {}) {
|
|
146
|
+
const lw = ctx.lineWidth;
|
|
147
|
+
for (const d of deaths) {
|
|
148
|
+
const t = Math.min(1, Math.max(0, (now - d.t0) / ms));
|
|
149
|
+
if (t >= 1) continue;
|
|
150
|
+
const c = P(d.x, d.y, 1.2);
|
|
151
|
+
const fade = 1 - t;
|
|
152
|
+
const col = String(d.colour || '#ffd27a').replace('#', '');
|
|
153
|
+
const rgb = [0, 2, 4].map((i) => parseInt(col.slice(i, i + 2), 16) || 200).join(',');
|
|
154
|
+
softStops(ctx, c.x, c.y, U.x * 4.5 * (0.5 + t), [[0, `rgba(255,255,245,${(0.9 * fade * fade).toFixed(3)})`], [0.25, `rgba(${rgb},${(0.6 * fade).toFixed(3)})`], [1, `rgba(${rgb},0)`]], RINGS.core);
|
|
155
|
+
for (let i = 0; i < 34; i++) {
|
|
156
|
+
const H = (k) => h01(d.id * 53 + i * 11 + k);
|
|
157
|
+
const a = (i / 34) * Math.PI * 2 + H(1);
|
|
158
|
+
const sp = 3 + H(2) * 7;
|
|
159
|
+
const at = (u) => P(d.x + Math.cos(a) * sp * u, d.y + Math.sin(a) * sp * u * 0.9 - 9 * u * u, 1.2);
|
|
160
|
+
const pts = [];
|
|
161
|
+
for (let k = 0; k <= 4; k++) pts.push(at(Math.max(0, t - k * 0.04)));
|
|
162
|
+
trail(ctx, pts, (al) => `rgba(${rgb},${(0.7 * al * fade).toFixed(3)})`, U.x * 0.18);
|
|
163
|
+
const head = pts[0];
|
|
164
|
+
disc(ctx, head.x, head.y, U.x * 0.11 * fade, `rgba(255,250,230,${(0.85 * fade).toFixed(3)})`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
ctx.lineWidth = lw;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Where fallen dirt lands: a low puff that spreads and settles. */
|
|
171
|
+
export function paintDust(ctx, P, U, dusts, now, { softStops, ms = 700 } = {}) {
|
|
172
|
+
for (const d of dusts) {
|
|
173
|
+
const t = Math.min(1, Math.max(0, (now - d.t0) / ms));
|
|
174
|
+
if (t >= 1) continue;
|
|
175
|
+
for (let i = 0; i < 4; i++) {
|
|
176
|
+
const H = (k) => h01(d.id * 17 + i * 5 + k);
|
|
177
|
+
const p = P(d.x + (H(1) - 0.5) * 2.2, d.y + 0.4 + t * 1.2 + H(2) * 0.6, 1.2);
|
|
178
|
+
const r = U.x * (0.5 + 1.5 * t) * (0.6 + H(3) * 0.7);
|
|
179
|
+
const al = 0.34 * (1 - t);
|
|
180
|
+
softStops(ctx, p.x, p.y, r, [[0, `rgba(196,172,132,${al.toFixed(3)})`], [0.6, `rgba(150,130,100,${(al * 0.5).toFixed(3)})`], [1, 'rgba(140,122,94,0)']], RINGS.smoke);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* THE AIM GAUGE (operator, 2026-09-16: "seeing the turret moving on the tank is still not clear
|
|
187
|
+
* and intuitive ... a simple way of visualizing the current angle").
|
|
188
|
+
*
|
|
189
|
+
* A protractor over the tank whose turn it is: a half-circle of ticks every 15 degrees with 0, 45,
|
|
190
|
+
* 90, 135 and 180 written in, a bright needle along the current angle whose LENGTH is the power,
|
|
191
|
+
* and the two numbers at the needle's tip. It is the whole of the aim in one picture: where the
|
|
192
|
+
* barrel points, how hard, and how far from the round numbers you are.
|
|
193
|
+
*/
|
|
194
|
+
export function paintAim(ctx, P, U, { x, y, angle, power, colour = '#ffb347', show = true, dim = false } = {}) {
|
|
195
|
+
if (!show) return;
|
|
196
|
+
const lw = ctx.lineWidth;
|
|
197
|
+
const o = P(x, y + 0.9, 1.2);
|
|
198
|
+
const rad = U.x * 4.2;
|
|
199
|
+
const a = (angle * Math.PI) / 180;
|
|
200
|
+
const on = dim ? 0.35 : 1;
|
|
201
|
+
// the arc
|
|
202
|
+
ctx.strokeStyle = `rgba(226,232,240,${(0.3 * on).toFixed(3)})`;
|
|
203
|
+
ctx.lineWidth = Math.max(lw, U.x * 0.05);
|
|
204
|
+
ctx.beginPath();
|
|
205
|
+
ctx.ellipse(o.x, o.y, rad, rad * (U.y / U.x), 0, Math.PI, 2 * Math.PI);
|
|
206
|
+
ctx.stroke();
|
|
207
|
+
// the ticks
|
|
208
|
+
for (let deg = 0; deg <= 180; deg += 15) {
|
|
209
|
+
const ar = (deg * Math.PI) / 180;
|
|
210
|
+
const big = deg % 45 === 0;
|
|
211
|
+
const r0 = rad * (big ? 0.86 : 0.93), r1 = rad * 1.04;
|
|
212
|
+
const sx = Math.cos(ar), sy = Math.sin(ar) * (U.y / U.x);
|
|
213
|
+
ctx.strokeStyle = `rgba(226,232,240,${((big ? 0.55 : 0.3) * on).toFixed(3)})`;
|
|
214
|
+
ctx.lineWidth = Math.max(lw, U.x * (big ? 0.07 : 0.04));
|
|
215
|
+
ctx.beginPath();
|
|
216
|
+
ctx.moveTo(o.x + sx * r0, o.y - sy * r0);
|
|
217
|
+
ctx.lineTo(o.x + sx * r1, o.y - sy * r1);
|
|
218
|
+
ctx.stroke();
|
|
219
|
+
}
|
|
220
|
+
// the needle: its length is the power
|
|
221
|
+
const len = rad * (0.45 + 0.75 * Math.min(1, Math.max(0, power / 1000)));
|
|
222
|
+
const tipX = o.x + Math.cos(a) * len, tipY = o.y - Math.sin(a) * len * (U.y / U.x);
|
|
223
|
+
ctx.strokeStyle = `rgba(20,24,32,${(0.55 * on).toFixed(3)})`;
|
|
224
|
+
ctx.lineWidth = Math.max(lw, U.x * 0.3);
|
|
225
|
+
ctx.beginPath(); ctx.moveTo(o.x, o.y); ctx.lineTo(tipX, tipY); ctx.stroke();
|
|
226
|
+
ctx.strokeStyle = colour;
|
|
227
|
+
ctx.lineWidth = Math.max(lw, U.x * 0.16);
|
|
228
|
+
ctx.beginPath(); ctx.moveTo(o.x, o.y); ctx.lineTo(tipX, tipY); ctx.stroke();
|
|
229
|
+
disc(ctx, tipX, tipY, U.x * 0.22, 'rgba(255,250,235,0.95)');
|
|
230
|
+
disc(ctx, o.x, o.y, U.x * 0.16, colour);
|
|
231
|
+
// the numbers, at the tip, on the side the needle leans away from
|
|
232
|
+
const label = `${Math.round(angle)}° ${Math.round(power)}`;
|
|
233
|
+
ctx.font = `${Math.max(8, Math.round(U.x * 0.7))}px ui-monospace, SFMono-Regular, Menlo, monospace`;
|
|
234
|
+
ctx.textAlign = angle > 90 ? 'right' : 'left';
|
|
235
|
+
ctx.textBaseline = 'bottom';
|
|
236
|
+
const off = (angle > 90 ? -1 : 1) * U.x * 0.35;
|
|
237
|
+
ctx.fillStyle = 'rgba(12,16,22,0.72)';
|
|
238
|
+
ctx.fillText(label, tipX + off + 1, tipY - U.x * 0.35 + 1);
|
|
239
|
+
ctx.fillStyle = 'rgba(245,249,255,0.96)';
|
|
240
|
+
ctx.fillText(label, tipX + off, tipY - U.x * 0.35);
|
|
241
|
+
ctx.textAlign = 'left';
|
|
242
|
+
ctx.lineWidth = lw;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* CHEAT MODE (operator, 2026-09-16: "a 'Cheat Mode' enable/disable where it plots the firing
|
|
247
|
+
* solution accounting for wind and power in realtime, so the player can see the arcs adjusting as
|
|
248
|
+
* the target location shifts").
|
|
249
|
+
*
|
|
250
|
+
* The shell's own path, under the round's wind and the game's gravity, recomputed every time the
|
|
251
|
+
* aim moves and clipped where it would meet the dirt: dim beads along the flight, brighter toward
|
|
252
|
+
* the end, and a ring on the ground where it lands. It is the rules' own arithmetic, not an
|
|
253
|
+
* approximation of it, so what it draws is what the shot does.
|
|
254
|
+
*/
|
|
255
|
+
export function paintSolution(ctx, P, U, pts, { colour = '255,216,120', impact = null } = {}) {
|
|
256
|
+
if (!pts || pts.length < 2) return;
|
|
257
|
+
const step = Math.max(1, Math.round(pts.length / 90));
|
|
258
|
+
for (let i = 0; i < pts.length; i += step) {
|
|
259
|
+
const k = i / pts.length;
|
|
260
|
+
const p = P(pts[i].x, pts[i].y, 1.2);
|
|
261
|
+
const r = U.x * (0.07 + 0.06 * k);
|
|
262
|
+
disc(ctx, p.x, p.y, r, `rgba(${colour},${(0.2 + 0.5 * k).toFixed(3)})`);
|
|
263
|
+
}
|
|
264
|
+
if (!impact) return;
|
|
265
|
+
const c = P(impact.x, impact.y, 1.2);
|
|
266
|
+
const lw = ctx.lineWidth;
|
|
267
|
+
ctx.strokeStyle = `rgba(${colour},0.85)`;
|
|
268
|
+
ctx.lineWidth = Math.max(1.2, U.x * 0.1);
|
|
269
|
+
ctx.beginPath();
|
|
270
|
+
ctx.ellipse(c.x, c.y, U.x * 0.75, Math.max(1, U.y * 0.75), 0, 0, Math.PI * 2);
|
|
271
|
+
ctx.stroke();
|
|
272
|
+
ctx.beginPath();
|
|
273
|
+
ctx.moveTo(c.x - U.x * 0.42, c.y);
|
|
274
|
+
ctx.lineTo(c.x + U.x * 0.42, c.y);
|
|
275
|
+
ctx.moveTo(c.x, c.y - U.y * 0.42);
|
|
276
|
+
ctx.lineTo(c.x, c.y + U.y * 0.42);
|
|
277
|
+
ctx.stroke();
|
|
278
|
+
ctx.lineWidth = lw;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* THE SHELLS IN FLIGHT (operator, 2026-09-16: "Everything is the same white dot effect for the
|
|
283
|
+
* shot", then "I said I wanted variance across all shot types"). Over each shell's ball: a glow in
|
|
284
|
+
* the weapon's colour, a trail in the weapon's manner -- at the weapon's own length, count, width
|
|
285
|
+
* and colour (scorched.js SHELL_LOOKS, one entry per weapon) -- and a ring for the heavy ones.
|
|
286
|
+
* The trail is the last stretch of the shell's own path, which the rules keep for the trace.
|
|
287
|
+
*
|
|
288
|
+
* flame exhaust: hot beads, `len` of them, in `col`
|
|
289
|
+
* radio a nuke: a pulsing glow (`width` scales it) and `count` falling sparks
|
|
290
|
+
* comet a tapering tail, `len` long and `width` wide, in the glow colour
|
|
291
|
+
* rainbow the hue runs round the wheel along the trail, `spin` times as fast for a bomblet
|
|
292
|
+
* dash a thin dotted line, `len` long, and no glow -- the point of a tracer
|
|
293
|
+
* smoke grey puffs shed along the path
|
|
294
|
+
* sparks `count` sparks thrown behind, for the rollers
|
|
295
|
+
* wisp pale soft wisps, `len` of them
|
|
296
|
+
* clods `count` lumps of earth shed behind
|
|
297
|
+
* drip droplets falling from a shell of liquid dirt
|
|
298
|
+
* fire a flickering flame tail `len` long in `col`, and dripping embers
|
|
299
|
+
* drill `count` spinning arms of glint, at `spin`
|
|
300
|
+
*/
|
|
301
|
+
const hsl = (h, sat, l, a) => `hsla(${Math.round(h)},${sat}%,${l}%,${a.toFixed(3)})`;
|
|
302
|
+
const rgbaOf = (c, a) => `rgba(${c[0]},${c[1]},${c[2]},${a.toFixed(3)})`;
|
|
303
|
+
export function paintShells(ctx, P, U, shells, now, { softStops, looks } = {}) {
|
|
304
|
+
const lw = ctx.lineWidth;
|
|
305
|
+
shells.forEach((s, idx) => {
|
|
306
|
+
const look = looks(s.weapon);
|
|
307
|
+
const c = P(s.x, s.y, 1.2);
|
|
308
|
+
const tail = (s.path ?? []).slice(-Math.max(2, look.len));
|
|
309
|
+
const pts = tail.map((q) => P(q.x, q.y, 1.2));
|
|
310
|
+
const n = pts.length;
|
|
311
|
+
const g = look.glow;
|
|
312
|
+
if (g) {
|
|
313
|
+
const pulse = look.trail === 'radio' ? 0.75 + 0.25 * Math.sin(now / 90 + idx) : 1;
|
|
314
|
+
const reach = U.x * look.size * (2.6 + (look.trail === 'radio' ? 1.4 * look.width : 0)) * pulse;
|
|
315
|
+
softStops(ctx, c.x, c.y, reach, [[0, rgbaOf(g, 0.55)], [0.35, rgbaOf(g, 0.22)], [1, rgbaOf(g, 0)]], RINGS.glow);
|
|
316
|
+
}
|
|
317
|
+
// the ring of the heavy ones: a thin bright hoop round the ball, turning with the flight
|
|
318
|
+
if (look.ring) {
|
|
319
|
+
ctx.strokeStyle = rgbaOf(look.ring, 0.85);
|
|
320
|
+
ctx.lineWidth = Math.max(1, U.x * 0.09);
|
|
321
|
+
ctx.beginPath();
|
|
322
|
+
ctx.ellipse(c.x, c.y, U.x * look.size * 0.95, U.y * look.size * (0.55 + 0.4 * Math.abs(Math.sin(now / 240 + idx))), 0, 0, Math.PI * 2);
|
|
323
|
+
ctx.stroke();
|
|
324
|
+
}
|
|
325
|
+
if (n < 2) return;
|
|
326
|
+
ctx.lineCap = 'round';
|
|
327
|
+
const col = look.col ?? g ?? [255, 255, 255];
|
|
328
|
+
switch (look.trail) {
|
|
329
|
+
case 'flame':
|
|
330
|
+
for (let i = 0; i < n; i++) { const k = (i + 1) / n; disc(ctx, pts[i].x, pts[i].y, U.x * 0.16 * (0.4 + k) * (0.8 + look.size * 0.4), `rgba(${col[0]},${Math.round(col[1] * (0.7 + 0.3 * k))},${col[2]},${(0.2 + 0.55 * k).toFixed(3)})`); }
|
|
331
|
+
break;
|
|
332
|
+
case 'radio':
|
|
333
|
+
for (let i = 0; i < look.count; i++) { const h = h01(idx * 31 + i * 7 + Math.floor(now / 120)); const q = pts[Math.max(0, n - 1 - Math.floor(h * (n - 1)))]; disc(ctx, q.x + (h01(i * 13 + idx) - 0.5) * U.x * 1.8 * look.size, q.y + h * U.y * 1.6, U.x * 0.11 * (0.8 + look.size * 0.5), rgbaOf(g, 0.4 + 0.5 * h)); }
|
|
334
|
+
break;
|
|
335
|
+
case 'comet':
|
|
336
|
+
for (let i = 0; i < n - 1; i++) { const k = (i + 1) / n; ctx.strokeStyle = rgbaOf(g, 0.08 + 0.55 * k * k); ctx.lineWidth = Math.max(0.6, U.x * look.width * 1.2 * k); ctx.beginPath(); ctx.moveTo(pts[i].x, pts[i].y); ctx.lineTo(pts[i + 1].x, pts[i + 1].y); ctx.stroke(); }
|
|
337
|
+
break;
|
|
338
|
+
case 'rainbow': {
|
|
339
|
+
const rate = now / 4 * look.spin;
|
|
340
|
+
for (let i = 0; i < n - 1; i++) { const k = (i + 1) / n; ctx.strokeStyle = hsl((rate + i * (360 / n)) % 360, 100, 60, 0.15 + 0.6 * k); ctx.lineWidth = Math.max(0.6, U.x * look.width * k); ctx.beginPath(); ctx.moveTo(pts[i].x, pts[i].y); ctx.lineTo(pts[i + 1].x, pts[i + 1].y); ctx.stroke(); }
|
|
341
|
+
softStops(ctx, c.x, c.y, U.x * look.size * 2.4, [[0, hsl(rate % 360, 100, 70, 0.5)], [1, hsl(rate % 360, 100, 70, 0)]], RINGS.glow);
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
case 'dash':
|
|
345
|
+
for (let i = 0; i < n; i += 2) disc(ctx, pts[i].x, pts[i].y, U.x * 0.07, 'rgba(200,206,216,0.55)');
|
|
346
|
+
break;
|
|
347
|
+
case 'smoke':
|
|
348
|
+
for (let i = 0; i < n; i += 2) { const k = (i + 1) / n; softStops(ctx, pts[i].x + (h01(i + idx) - 0.5) * U.x * 0.4, pts[i].y - (1 - k) * U.y * 0.6, U.x * (0.25 + 0.45 * (1 - k)), [[0, `rgba(170,176,186,${(0.12 + 0.2 * k).toFixed(3)})`], [1, 'rgba(170,176,186,0)']], 6); }
|
|
349
|
+
break;
|
|
350
|
+
case 'sparks':
|
|
351
|
+
for (let i = 0; i < look.count; i++) { const h = h01(idx * 17 + i * 5 + Math.floor(now / 60)); const q = pts[n - 1]; disc(ctx, q.x - (h * U.x * 2 * look.size) * Math.sign(s.vx || 1), q.y - h01(i + idx) * U.y * 0.9, U.x * 0.07, `rgba(255,${Math.round(180 + 60 * h)},120,${(0.9 - h * 0.6).toFixed(3)})`); }
|
|
352
|
+
break;
|
|
353
|
+
case 'wisp':
|
|
354
|
+
for (let i = 0; i < n; i++) { const k = (i + 1) / n; softStops(ctx, pts[i].x, pts[i].y, U.x * 0.5 * look.size * 1.4 * (1 - k * 0.5), [[0, rgbaOf(g, 0.3 * k)], [1, rgbaOf(g, 0)]], 6); }
|
|
355
|
+
break;
|
|
356
|
+
case 'clods':
|
|
357
|
+
for (let i = 0; i < look.count; i++) { const h = h01(idx * 23 + i * 9 + Math.floor(now / 150)); const q = pts[Math.max(0, n - 1 - i)]; ctx.fillStyle = `rgba(${140 + Math.round(40 * h)},${95 + Math.round(30 * h)},50,${(0.5 + 0.4 * h).toFixed(3)})`; const sz = U.x * (0.12 + 0.1 * look.size); ctx.fillRect(q.x + (h - 0.5) * U.x * look.size * 1.4, q.y + h * U.y * 0.7, sz, sz); }
|
|
358
|
+
break;
|
|
359
|
+
case 'drip':
|
|
360
|
+
for (let i = 0; i < look.count; i++) { const h = h01(idx * 19 + i * 11 + Math.floor(now / 90)); const q = pts[Math.max(0, n - 1 - i)]; disc(ctx, q.x + (h - 0.5) * U.x * 0.6, q.y + h * U.y * 1.8, U.x * (0.06 + 0.05 * (1 - h)), `rgba(120,85,45,${(0.9 - 0.6 * h).toFixed(3)})`); }
|
|
361
|
+
break;
|
|
362
|
+
case 'fire':
|
|
363
|
+
for (let i = 0; i < n; i++) { const k = (i + 1) / n; const fl = 0.7 + 0.3 * Math.sin(now / 35 + i); disc(ctx, pts[i].x + (h01(i + Math.floor(now / 50)) - 0.5) * U.x * 0.4, pts[i].y, U.x * 0.2 * (0.3 + k) * fl * (0.8 + look.size * 0.5), `rgba(${col[0]},${Math.round(col[1] * (0.6 + 0.4 * k))},${col[2]},${(0.3 + 0.6 * k).toFixed(3)})`); }
|
|
364
|
+
for (let i = 0; i < 3; i++) { const h = h01(idx * 7 + i * 3 + Math.floor(now / 100)); disc(ctx, c.x + (h - 0.5) * U.x * 1.2, c.y + h * U.y * 1.6, U.x * 0.08, rgbaOf(col, 0.8 - h * 0.5)); }
|
|
365
|
+
break;
|
|
366
|
+
case 'drill': {
|
|
367
|
+
const a = now / 60 * look.spin + idx;
|
|
368
|
+
ctx.strokeStyle = rgbaOf(g ?? [255, 220, 170], 0.85); ctx.lineWidth = Math.max(1, U.x * 0.1);
|
|
369
|
+
for (let i = 0; i < look.count; i++) { const ang = a + i * Math.PI / look.count; const r = U.x * look.size * 1.1; ctx.beginPath(); ctx.moveTo(c.x - Math.cos(ang) * r, c.y - Math.sin(ang) * r); ctx.lineTo(c.x + Math.cos(ang) * r, c.y + Math.sin(ang) * r); ctx.stroke(); }
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
default: break;
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
ctx.lineWidth = lw;
|
|
376
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// SCORCHED YARD, the roster and the shop (docs/PLAN-SCORCHED-YARD.md §4, §5). Data, and the
|
|
2
|
+
// arithmetic of buying. Prices, pack sizes and blast radii are Scorched Earth 1.5's, from the
|
|
3
|
+
// manual's weapons and accessories tables (SCORCH.DOC, read 2026-09-16 at abandonwaredos.com);
|
|
4
|
+
// a radius in the manual is pixels on a 640-wide screen, and a cell here is 640/96 of them, so
|
|
5
|
+
// every radius is the manual's over 6.67. Damage the manual does not number; ours grows with the
|
|
6
|
+
// radius. Anything found wrong against the manual is a one-line fix here and nowhere else.
|
|
7
|
+
//
|
|
8
|
+
// A weapon is a shell with a BEHAVIOUR (`kind`): what it does on impact, at its apex, on a bounce,
|
|
9
|
+
// every step -- or at once from the turret. The physics in scorched.js is shared; this table is
|
|
10
|
+
// the whole roster.
|
|
11
|
+
const cells = (px) => Math.round((px / 6.667) * 100) / 100;
|
|
12
|
+
|
|
13
|
+
export const WEAPONS = Object.freeze({
|
|
14
|
+
// the blasts
|
|
15
|
+
babyMissile: Object.freeze({ name: 'Baby Missile', kind: 'blast', radius: cells(10), damage: 30, price: 0, pack: 99, note: 'bottomless: you always have 99' }),
|
|
16
|
+
missile: Object.freeze({ name: 'Missile', kind: 'blast', radius: cells(20), damage: 55, price: 1875, pack: 5 }),
|
|
17
|
+
babyNuke: Object.freeze({ name: 'Baby Nuke', kind: 'blast', radius: cells(40), damage: 90, price: 10000, pack: 3 }),
|
|
18
|
+
nuke: Object.freeze({ name: 'Nuke', kind: 'blast', radius: cells(75), damage: 130, price: 12000, pack: 1 }),
|
|
19
|
+
// the ones that do something first
|
|
20
|
+
leapfrog: Object.freeze({ name: 'Leap Frog', kind: 'leapfrog', radius: cells(20), radii: [cells(20), cells(25), cells(30)], damage: 55, hops: 3, price: 10000, pack: 2, note: 'three warheads, one after another, each bigger' }),
|
|
21
|
+
funkyBomb: Object.freeze({ name: 'Funky Bomb', kind: 'funky', radius: cells(20), damage: 45, bomblets: 6, spread: cells(80), price: 7000, pack: 2, note: 'a multi-coloured chain reaction' }),
|
|
22
|
+
mirv: Object.freeze({ name: 'MIRV', kind: 'mirv', radius: cells(20), damage: 55, heads: 5, price: 10000, pack: 3, note: 'five warheads, splitting at the apogee' }),
|
|
23
|
+
deathsHead: Object.freeze({ name: "Death's Head", kind: 'mirv', radius: cells(35), damage: 90, heads: 9, price: 20000, pack: 1, note: 'nine large warheads, splitting at the apogee' }),
|
|
24
|
+
napalm: Object.freeze({ name: 'Napalm', kind: 'napalm', radius: 0.5, damage: 4, drops: 12, steps: 40, price: 10000, pack: 10, note: 'splashes and bursts into flame that runs downhill' }),
|
|
25
|
+
hotNapalm: Object.freeze({ name: 'Hot Napalm', kind: 'napalm', radius: 0.5, damage: 7, drops: 20, steps: 60, price: 20000, pack: 2, note: 'much hotter and more powerful' }),
|
|
26
|
+
tracer: Object.freeze({ name: 'Tracer', kind: 'tracer', radius: 0, damage: 0, price: 10, pack: 20, note: 'no blast; shows the wind' }),
|
|
27
|
+
smokeTracer: Object.freeze({ name: 'Smoke Tracer', kind: 'tracer', radius: 0, damage: 0, smoke: true, price: 500, pack: 10, note: 'a tracer with a coloured smoke trail' }),
|
|
28
|
+
babyRoller: Object.freeze({ name: 'Baby Roller', kind: 'roller', radius: cells(10), damage: 30, price: 5000, pack: 10, note: 'rolls downhill until it meets a valley or a tank' }),
|
|
29
|
+
roller: Object.freeze({ name: 'Roller', kind: 'roller', radius: cells(20), damage: 55, price: 6000, pack: 5, note: 'the same, with a stronger warhead' }),
|
|
30
|
+
heavyRoller: Object.freeze({ name: 'Heavy Roller', kind: 'roller', radius: cells(45), damage: 95, price: 6750, pack: 2, note: 'more explosive than a Baby Nuke' }),
|
|
31
|
+
// dirt, off and on
|
|
32
|
+
riotCharge: Object.freeze({ name: 'Riot Charge', kind: 'wedge', dig: true, radius: cells(36), spread: 22, damage: 0, price: 2000, pack: 10, note: 'a wedge of dirt cut from the turret, at once' }),
|
|
33
|
+
riotBlast: Object.freeze({ name: 'Riot Blast', kind: 'wedge', dig: true, radius: cells(60), spread: 32, damage: 0, price: 5000, pack: 5, note: 'a larger wedge, wider' }),
|
|
34
|
+
riotBomb: Object.freeze({ name: 'Riot Bomb', kind: 'riot', radius: cells(30), damage: 0, price: 5000, pack: 5, note: 'a shell that clears a sphere of dirt and hurts no one' }),
|
|
35
|
+
heavyRiotBomb: Object.freeze({ name: 'Heavy Riot Bomb', kind: 'riot', radius: cells(45), damage: 0, price: 4750, pack: 2 }),
|
|
36
|
+
dirtClod: Object.freeze({ name: 'Dirt Clod', kind: 'dirt', radius: cells(20), damage: 0, price: 5000, pack: 10, note: 'a shell that bursts into a ball of dirt' }),
|
|
37
|
+
dirtBall: Object.freeze({ name: 'Dirt Ball', kind: 'dirt', radius: cells(35), damage: 0, price: 5000, pack: 5 }),
|
|
38
|
+
tonOfDirt: Object.freeze({ name: 'Ton of Dirt', kind: 'dirt', radius: cells(70), damage: 0, price: 6750, pack: 2, note: 'enough to bury a tank' }),
|
|
39
|
+
liquidDirt: Object.freeze({ name: 'Liquid Dirt', kind: 'liquidDirt', radius: 0.5, damage: 0, drops: 16, steps: 50, price: 5000, pack: 10, note: 'oozes downhill and fills the holes' }),
|
|
40
|
+
dirtCharge: Object.freeze({ name: 'Dirt Charge', kind: 'wedge', dig: false, radius: cells(40), spread: 26, damage: 0, price: 5000, pack: 5, note: 'a wedge of dirt thrown from the turret, at once' }),
|
|
41
|
+
earthDisrupter: Object.freeze({ name: 'Earth Disrupter', kind: 'disrupter', radius: 0, damage: 0, price: 5000, pack: 10, note: 'every hanging piece of dirt on the field settles' }),
|
|
42
|
+
// the diggers
|
|
43
|
+
babyDigger: Object.freeze({ name: 'Baby Digger', kind: 'digger', radius: cells(8), damage: 20, bore: 6, price: 3000, pack: 10, note: 'tunnels straight down, a little' }),
|
|
44
|
+
digger: Object.freeze({ name: 'Digger', kind: 'digger', radius: cells(10), damage: 30, bore: 10, price: 2500, pack: 5 }),
|
|
45
|
+
heavyDigger: Object.freeze({ name: 'Heavy Digger', kind: 'digger', radius: cells(14), damage: 40, bore: 16, price: 6750, pack: 2 }),
|
|
46
|
+
babySandhog: Object.freeze({ name: 'Baby Sandhog', kind: 'sandhog', radius: cells(8), damage: 30, bore: 8, price: 10000, pack: 10, note: 'burrows on under a shield and goes off there' }),
|
|
47
|
+
sandhog: Object.freeze({ name: 'Sandhog', kind: 'sandhog', radius: cells(10), damage: 40, bore: 12, price: 16750, pack: 5 }),
|
|
48
|
+
heavySandhog: Object.freeze({ name: 'Heavy Sandhog', kind: 'sandhog', radius: cells(16), damage: 60, bore: 20, price: 25000, pack: 2, note: 'can potentially destroy the world' }),
|
|
49
|
+
// energy
|
|
50
|
+
plasmaBlast: Object.freeze({ name: 'Plasma Blast', kind: 'plasma', radius: cells(75), minRadius: cells(10), damage: 90, price: 9000, pack: 5, note: 'radioactive energy thrown from the tank itself; the power sets its reach' }),
|
|
51
|
+
laser: Object.freeze({ name: 'Laser', kind: 'laser', radius: 0.6, damage: 50, price: 5000, pack: 5, note: 'a high-intensity beam, cutting through at once' }),
|
|
52
|
+
});
|
|
53
|
+
export const WEAPON_ORDER = Object.freeze(Object.keys(WEAPONS));
|
|
54
|
+
|
|
55
|
+
export const ITEMS = Object.freeze({
|
|
56
|
+
shield: Object.freeze({ name: 'Shield', kind: 'shield', hp: 60, price: 20000, pack: 3, note: 'absorbs 60 damage' }),
|
|
57
|
+
forceShield: Object.freeze({ name: 'Force Shield', kind: 'shield', hp: 100, price: 25000, pack: 3, note: 'absorbs 100' }),
|
|
58
|
+
heavyShield: Object.freeze({ name: 'Heavy Shield', kind: 'shield', hp: 150, price: 30000, pack: 2, note: 'absorbs 150' }),
|
|
59
|
+
magDeflector: Object.freeze({ name: 'Mag Deflector', kind: 'passive', reach: 5, push: 30, price: 10000, pack: 2, note: 'pushes passing shells away' }),
|
|
60
|
+
superMag: Object.freeze({ name: 'Super Mag', kind: 'passive', reach: 8, push: 60, price: 40000, pack: 2, note: 'pushes them away harder, from further' }),
|
|
61
|
+
parachute: Object.freeze({ name: 'Parachute', kind: 'parachute', price: 10000, pack: 8, note: 'opens when you fall; one per fall' }),
|
|
62
|
+
battery: Object.freeze({ name: 'Battery', kind: 'battery', heal: 30, price: 5000, pack: 10, note: 'restores 30 health, used on your turn' }),
|
|
63
|
+
autoDefense: Object.freeze({ name: 'Auto Defense', kind: 'passive', price: 1500, pack: 1, note: 'raises a shield for you as your turn begins' }),
|
|
64
|
+
fuel: Object.freeze({ name: 'Fuel Tank', kind: 'fuel', price: 10000, pack: 10, note: 'drive a cell per unit, A and D' }),
|
|
65
|
+
contactTrigger: Object.freeze({ name: 'Contact Trigger', kind: 'arm', price: 1000, pack: 25, note: 'the shell goes off within reach of a tank, before it buries' }),
|
|
66
|
+
heatGuidance: Object.freeze({ name: 'Heat Guidance', kind: 'arm', price: 10000, pack: 6, note: 'the shell bends toward the nearest tank as it falls' }),
|
|
67
|
+
});
|
|
68
|
+
export const ITEM_ORDER = Object.freeze(Object.keys(ITEMS));
|
|
69
|
+
|
|
70
|
+
export const START_INVENTORY = Object.freeze({ babyMissile: 99 });
|
|
71
|
+
export const START_ITEMS = Object.freeze({});
|
|
72
|
+
// the manual's default is $0 to start with 5% interest; ours starts with a little so the first
|
|
73
|
+
// shop has something to do (Display settings → Scorched Yard → Starting cash goes down to 0)
|
|
74
|
+
export const START_CASH = 10000;
|
|
75
|
+
export const CASH_PER_DAMAGE = 10;
|
|
76
|
+
export const KILL_BONUS = 2000;
|
|
77
|
+
export const SURVIVOR_BONUS = 1000;
|
|
78
|
+
|
|
79
|
+
/** Everything on sale, in the shop's order: the weapons, then the items. */
|
|
80
|
+
export const SHOP = Object.freeze([
|
|
81
|
+
...WEAPON_ORDER.filter((id) => WEAPONS[id].price > 0).map((id) => ({ id, item: false, ...WEAPONS[id] })),
|
|
82
|
+
...ITEM_ORDER.map((id) => ({ id, item: true, ...ITEMS[id] })),
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
export const canBuy = (tank, entry) => tank.cash >= entry.price;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Buy one pack. Returns false if the tank cannot afford it or the entry is unknown; the cash is
|
|
89
|
+
* taken and the pack added otherwise.
|
|
90
|
+
*/
|
|
91
|
+
export function buy(tank, id) {
|
|
92
|
+
const entry = SHOP.find((e) => e.id === id);
|
|
93
|
+
if (!entry || !canBuy(tank, entry)) return false;
|
|
94
|
+
tank.cash -= entry.price;
|
|
95
|
+
const bag = entry.item ? tank.items : tank.inventory;
|
|
96
|
+
bag[id] = (bag[id] ?? 0) + entry.pack;
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Interest on what is left, between rounds. */
|
|
101
|
+
export function payInterest(tank, rate) {
|
|
102
|
+
const gain = Math.round(tank.cash * rate);
|
|
103
|
+
tank.cash += gain;
|
|
104
|
+
return gain;
|
|
105
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// SCORCHED YARD'S AIR, SEEN THROUGH (the sky's ripple). The air itself is simulated in
|
|
2
|
+
// scorchedair.js and shown by the flow lines it carries; this module keeps the two small pieces
|
|
3
|
+
// that sit beside it: the faint refraction of the sky behind the moving air, and a reading of how
|
|
4
|
+
// bright that sky is, which the flow lines use to hold their contrast by day.
|
|
5
|
+
//
|
|
6
|
+
// (Four earlier wind pictures lived here -- marching particles, dashed currents, a plasma wash and
|
|
7
|
+
// bowing bands -- each told where to be at a moment rather than carried by anything. They are gone:
|
|
8
|
+
// docs/PLAN-SCORCHED-YARD.md and the changelog keep what was learned from each.)
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* AIR YOU SEE BY WHAT IT DOES (operator, 2026-09-16, with a night capture: "Looks too much like
|
|
12
|
+
* shooting stars instead of air movement. Is there some sort of more impressive ripple or bowing
|
|
13
|
+
* effect we might be able to simulate air as fluid dynamics?").
|
|
14
|
+
*
|
|
15
|
+
* Every streak we drew had a bright head and a fading tail, which is precisely what a meteor looks
|
|
16
|
+
* like, so on a starry sky each one read as a meteor. Air itself is invisible: what you see is the
|
|
17
|
+
* world behind it bending. So the wind now REFRACTS the sky. The sky canvas is copied onto the wind
|
|
18
|
+
* plane in narrow columns, each lifted or lowered by a travelling wave, so the stars, the moon and
|
|
19
|
+
* the clouds bow gently in a ripple that rolls downwind at the wind's speed. It sits under the land,
|
|
20
|
+
* so only the air ripples, never the hills. Nothing is drawn at all in still air.
|
|
21
|
+
*
|
|
22
|
+
* The phase is a SIGNED distance the air has travelled, integrated by the caller from the eased
|
|
23
|
+
* wind, so the ripple only ever moves the way the wind blows and a change of wind bends it round
|
|
24
|
+
* without a jump.
|
|
25
|
+
*/
|
|
26
|
+
export function rippleOffset(x, travel, wind, h) {
|
|
27
|
+
const strength = Math.min(1, Math.abs(wind ?? 0) / 10);
|
|
28
|
+
if (strength < 0.03) return 0;
|
|
29
|
+
const A = h * (0.0015 + 0.005 * strength); // a sway, not a bob: about four pixels in a gale
|
|
30
|
+
const u = x - travel;
|
|
31
|
+
const gust = 0.65 + 0.35 * Math.sin(u * 0.0021 + 0.7); // a slow swell in the swell
|
|
32
|
+
return A * gust * (Math.sin(u * 0.0118) * 0.7 + Math.sin(u * 0.0263 + 1.9) * 0.3);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Copy the sky across the wind plane, column by column, each shifted by the ripple. `src` is the
|
|
37
|
+
* sky canvas and `map` how the wind plane lies over it: { sx, sy, scale } in the sky's pixels.
|
|
38
|
+
* Columns overlap by a pixel so no seam shows; the source is overscanned top and bottom so a lifted
|
|
39
|
+
* column never uncovers an edge.
|
|
40
|
+
*/
|
|
41
|
+
export function paintRipple(ctx, src, map, w, h, travel, wind, col = 6) {
|
|
42
|
+
const strength = Math.min(1, Math.abs(wind ?? 0) / 10);
|
|
43
|
+
if (!src || strength < 0.03 || typeof ctx.drawImage !== 'function') return 0;
|
|
44
|
+
const pad = Math.ceil(h * 0.012) + 2;
|
|
45
|
+
let n = 0;
|
|
46
|
+
for (let x = 0; x < w; x += col) {
|
|
47
|
+
const dy = rippleOffset(x + col / 2, travel, wind, h);
|
|
48
|
+
const sx = map.sx + x * map.scale, sy = map.sy + (-pad) * map.scale;
|
|
49
|
+
const sw = (col + 1) * map.scale, sh = (h + pad * 2) * map.scale;
|
|
50
|
+
ctx.drawImage(src, sx, sy, sw, sh, x, -pad + dy, col + 1, h + pad * 2);
|
|
51
|
+
n += 1;
|
|
52
|
+
}
|
|
53
|
+
return n;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* How light a sky canvas is, 0 to 1, from a coarse sample of its upper half. Cheap enough to take
|
|
58
|
+
* every second or two: the sky changes its light over minutes, not frames.
|
|
59
|
+
*/
|
|
60
|
+
export function skyBrightness(sky) {
|
|
61
|
+
try {
|
|
62
|
+
const cx = sky?.getContext?.('2d', { willReadFrequently: true });
|
|
63
|
+
if (!cx || !sky.width || !sky.height) return 0;
|
|
64
|
+
const d = cx.getImageData(0, 0, sky.width, Math.max(1, Math.round(sky.height * 0.5))).data;
|
|
65
|
+
let sum = 0, n = 0;
|
|
66
|
+
for (let i = 0; i < d.length; i += 4 * 97) { sum += (0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]) / 255; n += 1; }
|
|
67
|
+
return n ? Math.max(0, Math.min(1, (sum / n - 0.12) / 0.45)) : 0;
|
|
68
|
+
} catch { return 0; }
|
|
69
|
+
}
|