blockyard 0.0.9 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +359 -1
- package/README.md +48 -27
- package/SECURITY.md +2 -2
- package/bin/blockyard.js +2 -1
- package/docs/API.md +17 -15
- package/docs/ARCHITECTURE.md +128 -10
- package/docs/CONFIGURATION.md +39 -30
- package/docs/DEFECTS.md +4 -1
- package/docs/GETTING-STARTED.md +18 -8
- package/docs/INSTALL.md +97 -37
- package/docs/MEASUREMENTS.md +147 -0
- package/docs/PLAN-SCORCHED-YARD.md +456 -0
- package/docs/PLAN-SKIES.md +142 -0
- package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
- package/docs/SECURITY.md +58 -22
- package/docs/TROUBLESHOOTING.md +44 -5
- package/docs/USER-GUIDE.md +505 -35
- package/package.json +4 -2
- package/public/404.html +1 -1
- package/public/css/app.css +393 -82
- package/public/donate-qr.png +0 -0
- package/public/index.html +353 -109
- package/public/js/agents.js +228 -51
- package/public/js/app.js +131 -16
- package/public/js/blockanoid.js +15 -7
- package/public/js/blockout.js +15 -7
- package/public/js/blockscene3d.js +230 -38
- package/public/js/charts.js +21 -21
- package/public/js/depthchart.js +31 -27
- package/public/js/details3d.js +1481 -73
- package/public/js/doom.js +31 -0
- package/public/js/dosaudio.js +48 -0
- package/public/js/dosgame.js +389 -0
- package/public/js/dosio.js +186 -0
- package/public/js/dospc.js +1353 -0
- package/public/js/dosworker.js +196 -0
- package/public/js/explorer.js +7 -1
- package/public/js/livingsky.js +494 -0
- package/public/js/login.js +8 -2
- package/public/js/markets.js +46 -8
- package/public/js/mining.js +314 -36
- package/public/js/panels.js +41 -28
- package/public/js/pricechart.js +14 -13
- package/public/js/quake.js +20 -0
- package/public/js/safenext.js +14 -0
- package/public/js/scorched.js +1051 -0
- package/public/js/scorchedai.js +227 -0
- package/public/js/scorchedair.js +286 -0
- package/public/js/scorchedfx.js +376 -0
- package/public/js/scorchedshop.js +105 -0
- package/public/js/scorchedwind.js +69 -0
- package/public/js/scorchedyard.js +1338 -0
- package/public/js/settings.js +368 -100
- package/public/js/soundcard.js +459 -0
- package/public/js/tetrust.js +15 -6
- package/public/js/tetsound.js +35 -5
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/scripts/check.js +46 -0
- package/scripts/donate-qr.py +12 -9
- package/scripts/dos-bench.js +56 -0
- package/scripts/index-build.js +9 -2
- package/scripts/pool-map.js +152 -36
- package/scripts/setup.js +142 -22
- package/scripts/shots.mjs +27 -0
- package/scripts/smoke.sh +7 -6
- package/scripts/tls.js +31 -0
- package/scripts/ui.js +4 -2
- package/server/auth/sessions.js +33 -13
- package/server/chain/blockfile.js +64 -5
- package/server/chain/index/build.js +451 -58
- package/server/chain/index/heights.js +29 -3
- package/server/chain/index/live.js +13 -7
- package/server/chain/index/rows.js +6 -1
- package/server/chain/index/store.js +28 -5
- package/server/chain/index/worker.js +23 -11
- package/server/collect/logparse.js +65 -18
- package/server/collect/markets.js +76 -7
- package/server/collect/mining.js +32 -0
- package/server/collect/monitor.js +54 -12
- package/server/collect/network.js +305 -0
- package/server/config.js +53 -22
- package/server/http/api.js +119 -18
- package/server/http/games.js +77 -0
- package/server/http/server.js +30 -5
- package/server/http/sse.js +53 -7
- package/server/main.js +66 -11
- package/server/rpc/allowlist.js +26 -0
- package/server/rpc/client.js +30 -2
- package/server/store/audit.js +6 -1
- package/server/store/history.js +19 -3
- package/server/store/ledger.js +15 -4
- package/server/tls/selfsigned.js +160 -0
- package/systemd/blockyard.service +41 -8
- package/docs/PRIVATE-LEADERBOARD.md +0 -230
- package/docs/STATE-2026-09-09.md +0 -200
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// SCORCHED YARD, the computer players (docs/PLAN-SCORCHED-YARD.md §6; the manual's own words
|
|
2
|
+
// for each are quoted there). Each personality is a function of the game and its tank that
|
|
3
|
+
// answers `{ angle, power, weapon }`; the screen applies it through `aim()` and `fire()` like a
|
|
4
|
+
// key press, so the AI shares nothing with the DOM and a test can hold each one to its habit.
|
|
5
|
+
//
|
|
6
|
+
// They plan with `simulateShot`, the rules' own physics run without side effects: the walls, the
|
|
7
|
+
// wind, the dirt and the tanks in the way are all in it, so a Poolshark's rebound and a
|
|
8
|
+
// Spoiler's "perfect shot almost every time" are the same search over different angles.
|
|
9
|
+
import { WEAPON_ORDER, WEAPONS, ITEMS, alive, simulateShot, buy, useItem, COLS, TANK_W, MAX_HEALTH } from './scorched.js';
|
|
10
|
+
import { SHOP } from './scorchedshop.js';
|
|
11
|
+
|
|
12
|
+
const centreX = (t) => t.x + TANK_W / 2;
|
|
13
|
+
const toward = (tank, target, a) => (centreX(target) > centreX(tank) ? a : 180 - a);
|
|
14
|
+
|
|
15
|
+
/** The nearest living tank other than this one. */
|
|
16
|
+
export function nearest(g, tank) {
|
|
17
|
+
return alive(g).filter((t) => t !== tank).sort((a, b) => Math.abs(a.x - tank.x) - Math.abs(b.x - tank.x))[0] ?? null;
|
|
18
|
+
}
|
|
19
|
+
/** The tank with the highest score (ties by health), other than this one. */
|
|
20
|
+
export function leaderOf(g, tank) {
|
|
21
|
+
return alive(g).filter((t) => t !== tank).sort((a, b) => b.score - a.score || b.health - a.health)[0] ?? null;
|
|
22
|
+
}
|
|
23
|
+
/** The weakest living tank other than this one. */
|
|
24
|
+
export function weakest(g, tank) {
|
|
25
|
+
return alive(g).filter((t) => t !== tank).sort((a, b) => a.health - b.health)[0] ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* How far a simulated shot ends from the target: the horizontal miss, with a hit on the target
|
|
30
|
+
* counted as zero and a hit on someone else, or a lost shell, as a poor result.
|
|
31
|
+
*/
|
|
32
|
+
function missOf(g, tank, target, angle, power) {
|
|
33
|
+
const r = simulateShot(g, tank, angle, power);
|
|
34
|
+
if (r.hit === target.id) return 0;
|
|
35
|
+
if (r.lost) return 1000;
|
|
36
|
+
const dx = Math.abs(r.x - centreX(target));
|
|
37
|
+
const dy = Math.abs(r.y - target.y);
|
|
38
|
+
return dx + dy * 0.5 + (r.hit != null ? 10 : 0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The search: every angle in `angles` (relative to the target's side) at every power step, the
|
|
43
|
+
* shot with the smallest miss. `step` is the power grid; the Spoiler searches finer.
|
|
44
|
+
*/
|
|
45
|
+
export function solve(g, tank, target, angles = [35, 45, 55, 65, 75], step = 25) {
|
|
46
|
+
let best = null;
|
|
47
|
+
for (const a0 of angles) {
|
|
48
|
+
const angle = toward(tank, target, a0);
|
|
49
|
+
for (let power = 100; power <= 1000; power += step) {
|
|
50
|
+
const miss = missOf(g, tank, target, angle, power);
|
|
51
|
+
if (!best || miss < best.miss) best = { angle, power, miss };
|
|
52
|
+
if (miss === 0) return best;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return best;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Refine a solution around it: a few degrees and a few units of power either way. */
|
|
59
|
+
function refine(g, tank, target, best) {
|
|
60
|
+
let out = best;
|
|
61
|
+
for (const da of [-3, -1.5, 0, 1.5, 3]) {
|
|
62
|
+
for (const dp of [-20, -10, 0, 10, 20]) {
|
|
63
|
+
const angle = Math.max(0, Math.min(180, best.angle + da)), power = Math.max(0, Math.min(1000, best.power + dp));
|
|
64
|
+
const miss = missOf(g, tank, target, angle, power);
|
|
65
|
+
if (miss < out.miss) out = { angle, power, miss };
|
|
66
|
+
if (miss === 0) return out;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The biggest blast the tank owns that suits the target's health, or the baby missile. */
|
|
73
|
+
function pickWeapon(tank, target, { thrifty = false } = {}) {
|
|
74
|
+
const owned = WEAPON_ORDER.filter((w) => (tank.inventory[w] ?? 0) > 0 && WEAPONS[w].kind === 'blast');
|
|
75
|
+
if (!owned.length) return 'babyMissile';
|
|
76
|
+
if (thrifty || target.health <= 30) return owned[0];
|
|
77
|
+
return owned.sort((a, b) => WEAPONS[b].damage - WEAPONS[a].damage)[0];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ------------------------------------------------------------------- the personalities
|
|
81
|
+
/** The Moron: "pick an angle and power, and shoot". Whatever it happens to own. */
|
|
82
|
+
export function moron(g, tank, rnd = g.rnd) {
|
|
83
|
+
// random, but toward SOMEONE: it used to fire toward the middle of the field whatever was there,
|
|
84
|
+
// so once the tanks on that side were gone it (and every player falling back on it) kept shelling
|
|
85
|
+
// the empty half of the board
|
|
86
|
+
const foe = nearest(g, tank);
|
|
87
|
+
const left = foe ? centreX(foe) < centreX(tank) : tank.x > COLS / 2;
|
|
88
|
+
const angle = left ? 95 + Math.round(rnd() * 65) : 20 + Math.round(rnd() * 65);
|
|
89
|
+
const power = 300 + Math.round(rnd() * 700);
|
|
90
|
+
const owned = WEAPON_ORDER.filter((w) => (tank.inventory[w] ?? 0) > 0);
|
|
91
|
+
const weapon = owned[Math.floor(rnd() * owned.length)] ?? 'babyMissile';
|
|
92
|
+
return { angle, power, weapon };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The Shooter: "significantly deadlier … only if they have a straight line of fire": low, direct shots. */
|
|
96
|
+
export function shooter(g, tank, rnd = g.rnd) {
|
|
97
|
+
const target = nearest(g, tank);
|
|
98
|
+
if (!target) return moron(g, tank, rnd);
|
|
99
|
+
const best = solve(g, tank, target, [12, 20, 28, 36], 25);
|
|
100
|
+
if (!best || best.miss > 12) return { ...moron(g, tank, rnd), weapon: 'babyMissile', fallback: true }; // no line of fire: a Moron's shot
|
|
101
|
+
return { angle: best.angle, power: best.power, weapon: pickWeapon(tank, target, { thrifty: best.miss > 4 }) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The Poolshark: a Shooter, unless the walls rebound -- then it looks for the bank shot as well. */
|
|
105
|
+
export function poolshark(g, tank, rnd = g.rnd) {
|
|
106
|
+
if (g.walls !== 'rubber' && g.walls !== 'spring') return shooter(g, tank, rnd);
|
|
107
|
+
const target = nearest(g, tank);
|
|
108
|
+
if (!target) return moron(g, tank, rnd);
|
|
109
|
+
// the direct low shots and the ones off the wall behind it (angles past 90 face the wall)
|
|
110
|
+
const direct = solve(g, tank, target, [12, 20, 28, 36], 25);
|
|
111
|
+
const bank = solve(g, tank, target, [110, 125, 140, 155], 25);
|
|
112
|
+
const best = [direct, bank].filter(Boolean).sort((a, b) => a.miss - b.miss)[0];
|
|
113
|
+
if (!best || best.miss > 12) return { ...moron(g, tank, rnd), weapon: 'babyMissile', fallback: true };
|
|
114
|
+
return { angle: best.angle, power: best.power, weapon: pickWeapon(tank, target, { thrifty: best.miss > 4 }) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The Tosser: "start out like Morons, but they'll refine their aim … until they hit". A high lob
|
|
119
|
+
* at the nearest tank, then the power corrected from the last shot's miss, by halves.
|
|
120
|
+
*/
|
|
121
|
+
export function tosser(g, tank, rnd = g.rnd) {
|
|
122
|
+
const target = nearest(g, tank);
|
|
123
|
+
if (!target) return moron(g, tank, rnd);
|
|
124
|
+
const m = tank.memory;
|
|
125
|
+
let angle, power;
|
|
126
|
+
if (!m || m.target !== target.id) {
|
|
127
|
+
angle = toward(tank, target, 60 + Math.round(rnd() * 15));
|
|
128
|
+
power = 350 + Math.round(rnd() * 400);
|
|
129
|
+
} else {
|
|
130
|
+
angle = m.angle;
|
|
131
|
+
const r = simulateShot(g, tank, m.angle, m.power);
|
|
132
|
+
const short = Math.abs(r.x - centreX(tank)) < Math.abs(centreX(target) - centreX(tank));
|
|
133
|
+
const stepP = Math.max(8, Math.round((m.step ?? 160) / 2));
|
|
134
|
+
power = Math.max(50, Math.min(1000, m.power + (short ? stepP : -stepP)));
|
|
135
|
+
tank.memory = { ...m, step: stepP };
|
|
136
|
+
}
|
|
137
|
+
tank.memory = { ...(tank.memory ?? {}), target: target.id, angle, power, step: tank.memory?.step ?? 160 };
|
|
138
|
+
return { angle, power, weapon: pickWeapon(tank, target, { thrifty: true }) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The Chooser: "all the above methods available … decide which one will be most effective". */
|
|
142
|
+
export function chooser(g, tank, rnd = g.rnd) {
|
|
143
|
+
const target = nearest(g, tank);
|
|
144
|
+
if (!target) return moron(g, tank, rnd);
|
|
145
|
+
const low = solve(g, tank, target, [12, 20, 28, 36], 25);
|
|
146
|
+
const lob = solve(g, tank, target, [55, 65, 75], 25);
|
|
147
|
+
const bank = (g.walls === 'rubber' || g.walls === 'spring') ? solve(g, tank, target, [115, 135, 155], 25) : null;
|
|
148
|
+
const best = [low, lob, bank].filter(Boolean).sort((a, b) => a.miss - b.miss)[0];
|
|
149
|
+
if (!best) return moron(g, tank, rnd);
|
|
150
|
+
return { angle: best.angle, power: best.power, weapon: pickWeapon(tank, target) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The Spoiler: "taking into account the wind factor and gravity, they will get a perfect shot almost every time". */
|
|
154
|
+
export function spoiler(g, tank, rnd = g.rnd, target = nearest(g, tank)) {
|
|
155
|
+
if (!target) return moron(g, tank, rnd);
|
|
156
|
+
const coarse = solve(g, tank, target, [15, 25, 35, 45, 55, 65, 75], 20);
|
|
157
|
+
const best = coarse ? refine(g, tank, target, coarse) : null;
|
|
158
|
+
if (!best) return moron(g, tank, rnd);
|
|
159
|
+
// "almost": a degree or two of error, drawn from the game's own stream
|
|
160
|
+
const wobble = rnd() < 0.15 ? (rnd() - 0.5) * 4 : 0;
|
|
161
|
+
return { angle: Math.max(0, Math.min(180, best.angle + wobble)), power: best.power, weapon: pickWeapon(tank, target) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The Cyborg: a Spoiler that "attacks tanks who are weakened, winning, or have attacked them". */
|
|
165
|
+
export function cyborg(g, tank, rnd = g.rnd) {
|
|
166
|
+
const grudge = tank.lastHitBy != null ? g.tanks[tank.lastHitBy] : null;
|
|
167
|
+
const weak = weakest(g, tank);
|
|
168
|
+
const lead = leaderOf(g, tank);
|
|
169
|
+
const target = (grudge?.alive && grudge !== tank) ? grudge : (weak && weak.health <= 40) ? weak : lead ?? nearest(g, tank);
|
|
170
|
+
return spoiler(g, tank, rnd, target);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const PERSONALITIES = { moron, shooter, poolshark, tosser, chooser, spoiler, cyborg };
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* What this tank does this turn. The persona is what the rules drew for it this round (an Unknown
|
|
177
|
+
* plays as one of the others); unknown kinds play as the Moron. A battery is used first when it
|
|
178
|
+
* is worth it.
|
|
179
|
+
*/
|
|
180
|
+
export function decide(g, tank) {
|
|
181
|
+
const play = PERSONALITIES[tank.persona] ?? PERSONALITIES[tank.kind] ?? moron;
|
|
182
|
+
const d = play(g, tank);
|
|
183
|
+
if (!WEAPONS[d.weapon] || (tank.inventory[d.weapon] ?? 0) <= 0) d.weapon = 'babyMissile';
|
|
184
|
+
return d;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Before firing: the items a computer player uses on its turn (a battery when hurt, a shield when threatened). */
|
|
188
|
+
export function prepare(g, tank) {
|
|
189
|
+
const used = [];
|
|
190
|
+
if (tank.persona !== 'moron' && tank.health <= 50 && (tank.items.battery ?? 0) > 0 && useItem(g, tank, 'battery')) used.push('battery');
|
|
191
|
+
if (tank.persona !== 'moron' && !tank.shield && tank.health < MAX_HEALTH && useItem(g, tank, 'shield')) used.push('shield');
|
|
192
|
+
return used;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ------------------------------------------------------------------- the shop, by taste
|
|
196
|
+
const TASTES = {
|
|
197
|
+
moron: null, // random
|
|
198
|
+
shooter: ['missile', 'babyNuke', 'nuke', 'battery', 'shield'],
|
|
199
|
+
poolshark: ['missile', 'babyNuke', 'heavyRoller', 'battery', 'shield'],
|
|
200
|
+
tosser: ['mirv', 'funkyBomb', 'babyNuke', 'parachute', 'battery'],
|
|
201
|
+
chooser: ['nuke', 'babyNuke', 'mirv', 'shield', 'parachute', 'battery', 'missile'],
|
|
202
|
+
spoiler: ['nuke', 'deathsHead', 'heatGuidance', 'shield', 'forceShield', 'battery', 'contactTrigger'],
|
|
203
|
+
cyborg: ['deathsHead', 'nuke', 'heavyShield', 'forceShield', 'heatGuidance', 'battery', 'parachute'],
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Between rounds the computer shops. The Moron buys at random until it cannot afford the next
|
|
208
|
+
* thing it points at; the others go down their list of tastes, buying what they can, up to
|
|
209
|
+
* `picks` packs. Returns what was bought, in order.
|
|
210
|
+
*/
|
|
211
|
+
export function shop(g, tank, rnd = g.rnd, picks = 3) {
|
|
212
|
+
const bought = [];
|
|
213
|
+
if (tank.kind === 'human') return bought;
|
|
214
|
+
const taste = TASTES[tank.persona] ?? TASTES[tank.kind] ?? null;
|
|
215
|
+
for (let i = 0; i < picks; i++) {
|
|
216
|
+
let pick = null;
|
|
217
|
+
if (taste) {
|
|
218
|
+
pick = taste.map((id) => SHOP.find((e) => e.id === id)).find((e) => e && tank.cash >= e.price && ((e.item ? tank.items[e.id] : tank.inventory[e.id]) ?? 0) < e.pack * 2) ?? null;
|
|
219
|
+
} else {
|
|
220
|
+
const affordable = SHOP.filter((e) => tank.cash >= e.price);
|
|
221
|
+
pick = affordable.length ? affordable[Math.floor(rnd() * affordable.length)] : null;
|
|
222
|
+
}
|
|
223
|
+
if (!pick || !buy(tank, pick.id)) break;
|
|
224
|
+
bought.push(pick.id);
|
|
225
|
+
}
|
|
226
|
+
return bought;
|
|
227
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// SCORCHED YARD'S AIR, SIMULATED (operator, 2026-09-16, with a night capture: "There is no
|
|
2
|
+
// simulation movement. It's just a squiggly line moving across the screen").
|
|
3
|
+
//
|
|
4
|
+
// (It first showed the flow with a dye -- smoke carried by the air -- which read as smoke blowing
|
|
5
|
+
// out of the scene. The dye is gone; the flow lines at the bottom of this module are the picture.)
|
|
6
|
+
//
|
|
7
|
+
// That was fair. Every earlier cut was kinematic: sprites, dashes and finally sine curves, each
|
|
8
|
+
// told where to be at time t. None of them could flow round anything, because nothing in them knew
|
|
9
|
+
// what was there. This is an actual fluid: a small grid solved the way Jos Stam's "Stable Fluids"
|
|
10
|
+
// does it -- velocity advected semi-Lagrangian, made divergence-free by a pressure projection,
|
|
11
|
+
// with vorticity confinement to keep the eddies alive -- and weightless tracers carried by that
|
|
12
|
+
// velocity, whose paths are what you see.
|
|
13
|
+
//
|
|
14
|
+
// * THE WIND is a body force pulling the air toward the gauge's speed and direction. A change of
|
|
15
|
+
// wind is not a fade: the air has momentum and turns round.
|
|
16
|
+
// * THE LAND IS SOLID. Cells under the terrain are walls, so the air rises over the hills, speeds
|
|
17
|
+
// up across the crests and rolls into eddies in their lee. That is the bowing.
|
|
18
|
+
// * TURBULENCE is a few small vortex impulses a step, stronger in a strong wind, which the solver
|
|
19
|
+
// carries, stretches and tears apart on its own.
|
|
20
|
+
//
|
|
21
|
+
// Cheap by construction: 96 x 48 cells, fourteen Jacobi iterations a step, all typed arrays. Nothing
|
|
22
|
+
// here reads the DOM; the caller hands over the size, the wind and the solid mask.
|
|
23
|
+
|
|
24
|
+
const IX = (f, x, y) => x + y * f.nx;
|
|
25
|
+
const clampI = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
|
26
|
+
const hash = (n) => { const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); };
|
|
27
|
+
const smooth = (t) => t * t * (3 - 2 * t);
|
|
28
|
+
|
|
29
|
+
/** A fluid of `nx` by `ny` cells, still. */
|
|
30
|
+
export function makeFluid(nx = 96, ny = 48, seed = 1) {
|
|
31
|
+
const n = nx * ny;
|
|
32
|
+
return {
|
|
33
|
+
nx, ny, t: 0, seed,
|
|
34
|
+
u: new Float32Array(n), v: new Float32Array(n), u0: new Float32Array(n), v0: new Float32Array(n),
|
|
35
|
+
p: new Float32Array(n), div: new Float32Array(n), w: new Float32Array(n), solid: new Uint8Array(n),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Mark the cells the land fills. `topOf(x)` answers the first free row, from the top, of column x. */
|
|
40
|
+
export function setSolid(f, topOf) {
|
|
41
|
+
f.solid.fill(0);
|
|
42
|
+
for (let x = 0; x < f.nx; x++) {
|
|
43
|
+
const top = clampI(Math.round(topOf(x)), 0, f.ny);
|
|
44
|
+
for (let y = top; y < f.ny; y++) f.solid[IX(f, x, y)] = 1;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sample(f, a, x, y, wrap = true) {
|
|
49
|
+
// x wraps (the field is a strip of sky) for the solver; a tracer beyond the edge reads the edge
|
|
50
|
+
x = wrap ? ((x % f.nx) + f.nx) % f.nx : (x < 0 ? 0 : x > f.nx - 1.001 ? f.nx - 1.001 : x);
|
|
51
|
+
y = y < 0 ? 0 : y > f.ny - 1.001 ? f.ny - 1.001 : y;
|
|
52
|
+
const x0 = Math.floor(x), y0 = Math.floor(y), x1 = (x0 + 1) % f.nx, y1 = y0 + 1;
|
|
53
|
+
const sx = x - x0, sy = y - y0;
|
|
54
|
+
return (a[x0 + y0 * f.nx] * (1 - sx) + a[x1 + y0 * f.nx] * sx) * (1 - sy) + (a[x0 + y1 * f.nx] * (1 - sx) + a[x1 + y1 * f.nx] * sx) * sy;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One step of the solver: `dt` in ms, `wind` the gauge value (-10..10). */
|
|
58
|
+
export function stepFluid(f, dt, { wind = 0 } = {}) {
|
|
59
|
+
const s = Math.min(0.05, Math.max(0, dt / 1000));
|
|
60
|
+
if (!s) return f;
|
|
61
|
+
f.t += s;
|
|
62
|
+
const { nx, ny, u, v, u0, v0, p, div, w, solid } = f;
|
|
63
|
+
const n = nx * ny;
|
|
64
|
+
const strength = Math.min(1, Math.abs(wind) / 10);
|
|
65
|
+
const dir = wind < 0 ? -1 : 1;
|
|
66
|
+
const U = dir * (2.5 + 17 * strength); // cells a second at the gauge's speed: a gale crosses in five seconds
|
|
67
|
+
|
|
68
|
+
// --- forces: the wind pulls the air toward its speed; a few vortex impulses stir it
|
|
69
|
+
const pull = Math.min(1, 2.2 * s);
|
|
70
|
+
for (let i = 0; i < n; i++) { if (solid[i]) { u[i] = 0; v[i] = 0; continue; } u[i] += (U - u[i]) * pull; v[i] *= 1 - Math.min(1, 0.35 * s); }
|
|
71
|
+
const kicks = 1 + Math.round(3 * strength);
|
|
72
|
+
for (let k = 0; k < kicks; k++) {
|
|
73
|
+
const h = (q) => hash(f.t * 97.3 + k * 13.1 + q + f.seed);
|
|
74
|
+
if (h(1) > 0.55) continue; // not every step
|
|
75
|
+
const cx = h(2) * nx, cy = h(3) * ny * 0.85, r = 2.5 + h(4) * 4;
|
|
76
|
+
const spin = (h(5) - 0.5) * (6 + 22 * strength);
|
|
77
|
+
for (let y = Math.max(0, Math.floor(cy - r)); y <= Math.min(ny - 1, Math.ceil(cy + r)); y++) {
|
|
78
|
+
for (let xx = Math.floor(cx - r); xx <= Math.ceil(cx + r); xx++) {
|
|
79
|
+
const x = ((xx % nx) + nx) % nx;
|
|
80
|
+
const dx = xx - cx, dy = y - cy, q = dx * dx + dy * dy;
|
|
81
|
+
if (q > r * r) continue;
|
|
82
|
+
const fall = (1 - q / (r * r)) * spin * s;
|
|
83
|
+
const i = x + y * nx;
|
|
84
|
+
if (solid[i]) continue;
|
|
85
|
+
u[i] += -dy * fall; v[i] += dx * fall;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- advect the velocity by itself
|
|
91
|
+
u0.set(u); v0.set(v);
|
|
92
|
+
for (let y = 0; y < ny; y++) for (let x = 0; x < nx; x++) {
|
|
93
|
+
const i = x + y * nx;
|
|
94
|
+
if (solid[i]) { u[i] = 0; v[i] = 0; continue; }
|
|
95
|
+
const bx = x - u0[i] * s, by = y - v0[i] * s;
|
|
96
|
+
u[i] = sample(f, u0, bx, by); v[i] = sample(f, v0, bx, by);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- vorticity confinement: find where the air spins and push it to keep spinning
|
|
100
|
+
for (let y = 1; y < ny - 1; y++) for (let x = 0; x < nx; x++) {
|
|
101
|
+
const xl = (x - 1 + nx) % nx, xr = (x + 1) % nx;
|
|
102
|
+
w[x + y * nx] = (v[xr + y * nx] - v[xl + y * nx] - u[x + (y + 1) * nx] + u[x + (y - 1) * nx]) * 0.5;
|
|
103
|
+
}
|
|
104
|
+
const eps = 1.4 + 2.2 * strength;
|
|
105
|
+
for (let y = 2; y < ny - 2; y++) for (let x = 0; x < nx; x++) {
|
|
106
|
+
const i = x + y * nx;
|
|
107
|
+
if (solid[i]) continue;
|
|
108
|
+
const xl = (x - 1 + nx) % nx, xr = (x + 1) % nx;
|
|
109
|
+
const gx = (Math.abs(w[xr + y * nx]) - Math.abs(w[xl + y * nx])) * 0.5;
|
|
110
|
+
const gy = (Math.abs(w[x + (y + 1) * nx]) - Math.abs(w[x + (y - 1) * nx])) * 0.5;
|
|
111
|
+
const len = Math.hypot(gx, gy) + 1e-5;
|
|
112
|
+
u[i] += (gy / len) * w[i] * eps * s;
|
|
113
|
+
v[i] -= (gx / len) * w[i] * eps * s;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- project: make it divergence-free, so it flows round the land instead of into it
|
|
117
|
+
for (let y = 0; y < ny; y++) for (let x = 0; x < nx; x++) {
|
|
118
|
+
const i = x + y * nx;
|
|
119
|
+
const xl = (x - 1 + nx) % nx, xr = (x + 1) % nx;
|
|
120
|
+
const vu = y + 1 < ny ? v[x + (y + 1) * nx] : 0, vd = y > 0 ? v[x + (y - 1) * nx] : 0;
|
|
121
|
+
div[i] = solid[i] ? 0 : -0.5 * (u[xr + y * nx] - u[xl + y * nx] + vu - vd);
|
|
122
|
+
p[i] = 0;
|
|
123
|
+
}
|
|
124
|
+
for (let it = 0; it < 14; it++) {
|
|
125
|
+
for (let y = 0; y < ny; y++) for (let x = 0; x < nx; x++) {
|
|
126
|
+
const i = x + y * nx;
|
|
127
|
+
if (solid[i]) continue;
|
|
128
|
+
const xl = (x - 1 + nx) % nx, xr = (x + 1) % nx;
|
|
129
|
+
const pc = p[i];
|
|
130
|
+
const pl = solid[xl + y * nx] ? pc : p[xl + y * nx];
|
|
131
|
+
const pr = solid[xr + y * nx] ? pc : p[xr + y * nx];
|
|
132
|
+
const pu = y + 1 < ny && !solid[x + (y + 1) * nx] ? p[x + (y + 1) * nx] : pc;
|
|
133
|
+
const pd = y > 0 && !solid[x + (y - 1) * nx] ? p[x + (y - 1) * nx] : pc;
|
|
134
|
+
p[i] = (div[i] + pl + pr + pu + pd) * 0.25;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (let y = 0; y < ny; y++) for (let x = 0; x < nx; x++) {
|
|
138
|
+
const i = x + y * nx;
|
|
139
|
+
if (solid[i]) { u[i] = 0; v[i] = 0; continue; }
|
|
140
|
+
const xl = (x - 1 + nx) % nx, xr = (x + 1) % nx;
|
|
141
|
+
const pc = p[i];
|
|
142
|
+
const pl = solid[xl + y * nx] ? pc : p[xl + y * nx];
|
|
143
|
+
const pr = solid[xr + y * nx] ? pc : p[xr + y * nx];
|
|
144
|
+
const pu = y + 1 < ny && !solid[x + (y + 1) * nx] ? p[x + (y + 1) * nx] : pc;
|
|
145
|
+
const pd = y > 0 && !solid[x + (y - 1) * nx] ? p[x + (y - 1) * nx] : pc;
|
|
146
|
+
u[i] -= 0.5 * (pr - pl);
|
|
147
|
+
v[i] -= 0.5 * (pu - pd);
|
|
148
|
+
if ((y === 0 && v[i] < 0) || (y === ny - 1 && v[i] > 0)) v[i] = 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return f;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The mean horizontal speed of the free air, for tests and for anyone curious. */
|
|
155
|
+
export function meanFlow(f) {
|
|
156
|
+
let s = 0, k = 0;
|
|
157
|
+
for (let i = 0; i < f.u.length; i++) if (!f.solid[i]) { s += f.u[i]; k += 1; }
|
|
158
|
+
return k ? s / k : 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Run the solver on its own for `seconds` of simulated time, so a new field -- a new game, a new
|
|
163
|
+
* round's land -- opens on air that is already flowing round the hills rather than on a front of
|
|
164
|
+
* smoke crawling in from one edge. About a quarter of a millisecond a step.
|
|
165
|
+
*/
|
|
166
|
+
export function warmFluid(f, seconds, wind) {
|
|
167
|
+
const steps = Math.ceil((seconds * 1000) / 33);
|
|
168
|
+
for (let i = 0; i < steps; i++) stepFluid(f, 33, { wind });
|
|
169
|
+
return steps;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ------------------------------------------------------------------ what the eye follows
|
|
173
|
+
/**
|
|
174
|
+
* STREAKLINES THROUGH THE SIMULATED FLOW (operator, 2026-09-16: "looks too much like smoke blowing
|
|
175
|
+
* out the scene"). Any soft density drifting across the sky reads as smoke, however it is tuned.
|
|
176
|
+
* What reads as AIR is the wind map: many thin lines, each the recent path of a weightless tracer
|
|
177
|
+
* carried by the simulated velocity, so they curve where the air curves -- up over a hill, into the
|
|
178
|
+
* eddy behind it. Earlier lines read as shooting stars for two reasons, both absent here: they were
|
|
179
|
+
* straight (told where to go, not carried), and bright at the head. These taper to nothing at BOTH
|
|
180
|
+
* ends, brightest in the middle of their length, and there is no head to catch the eye.
|
|
181
|
+
*/
|
|
182
|
+
const TRAIL = 22;
|
|
183
|
+
const EDGE_RUN = 10; // cells a tracer keeps going beyond the field before it is reborn
|
|
184
|
+
const EDGE_FADE = 0.09; // the share of the width, and of the height at the top, over which lines fade out
|
|
185
|
+
export function makeTracers(f, n = 320, seed = 1) {
|
|
186
|
+
const tr = { n, x: new Float32Array(n), y: new Float32Array(n), age: new Float32Array(n), life: new Float32Array(n), len: new Uint8Array(n), hx: new Float32Array(n * TRAIL), hy: new Float32Array(n * TRAIL), head: new Uint8Array(n), seed, k: 0 };
|
|
187
|
+
for (let i = 0; i < n; i++) respawn(f, tr, i, true);
|
|
188
|
+
return tr;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function respawn(f, tr, i, anywhere) {
|
|
192
|
+
// a free cell, anywhere on the field: density stays even, nothing enters as a front
|
|
193
|
+
for (let tries = 0; tries < 24; tries++) {
|
|
194
|
+
tr.k += 1;
|
|
195
|
+
const x = hash(tr.seed * 31.7 + tr.k * 1.618) * f.nx, y = hash(tr.seed * 17.3 + tr.k * 2.414) * f.ny;
|
|
196
|
+
if (!f.solid[Math.floor(x) + Math.floor(y) * f.nx]) { tr.x[i] = x; tr.y[i] = y; break; }
|
|
197
|
+
}
|
|
198
|
+
tr.age[i] = 0;
|
|
199
|
+
tr.life[i] = 2.5 + hash(tr.seed + tr.k * 0.77) * 3.5;
|
|
200
|
+
tr.len[i] = 0;
|
|
201
|
+
tr.head[i] = 0;
|
|
202
|
+
if (anywhere) tr.age[i] = hash(tr.k * 3.3) * tr.life[i] * 0.6;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Carry every tracer `dt` ms through the fluid's velocity, keeping its recent path. */
|
|
206
|
+
export function stepTracers(f, tr, dt) {
|
|
207
|
+
const s = Math.min(0.05, Math.max(0, dt / 1000));
|
|
208
|
+
if (!s) return tr;
|
|
209
|
+
for (let i = 0; i < tr.n; i++) {
|
|
210
|
+
const ux = sample(f, f.u, tr.x[i], tr.y[i], false), vy = sample(f, f.v, tr.x[i], tr.y[i], false);
|
|
211
|
+
let x = tr.x[i] + ux * s, y = tr.y[i] + vy * s;
|
|
212
|
+
tr.age[i] += s;
|
|
213
|
+
// A TRACER RUNS ON PAST THE EDGE (operator, 2026-09-16, with a capture of the right edge: "It's
|
|
214
|
+
// not fading out cleanly at the edges, it just sorta disappears"). It was removed the moment it
|
|
215
|
+
// crossed, so its whole line vanished at once. It now carries on for a stretch beyond the field,
|
|
216
|
+
// where paintTracers has already faded it to nothing, and is only reborn once it is out of sight.
|
|
217
|
+
const gone = x < -EDGE_RUN || x >= f.nx + EDGE_RUN || y < -EDGE_RUN;
|
|
218
|
+
const inside = x >= 0 && x < f.nx && y >= 0 && y < f.ny;
|
|
219
|
+
const cell = inside && f.solid[Math.floor(x) + Math.floor(y) * f.nx];
|
|
220
|
+
if (gone || y >= f.ny || cell || tr.age[i] > tr.life[i]) { respawn(f, tr, i, false); continue; }
|
|
221
|
+
tr.x[i] = x; tr.y[i] = y;
|
|
222
|
+
const h = (tr.head[i] + 1) % TRAIL;
|
|
223
|
+
tr.head[i] = h;
|
|
224
|
+
tr.hx[i * TRAIL + h] = x; tr.hy[i * TRAIL + h] = y;
|
|
225
|
+
if (tr.len[i] < TRAIL) tr.len[i] += 1;
|
|
226
|
+
}
|
|
227
|
+
return tr;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Draw the paths as thin polylines. Segments are bucketed into four opacities by where they sit
|
|
232
|
+
* along the path -- faint at both ends, full in the middle -- so the whole field is four strokes,
|
|
233
|
+
* not thousands. A tracer fades in over its first half second and out over its last.
|
|
234
|
+
*/
|
|
235
|
+
export function paintTracers(ctx, f, tr, w, h, { bright = 0, wind = 0, colour = '240,246,255' } = {}) {
|
|
236
|
+
const strength = Math.min(1, Math.abs(wind) / 10);
|
|
237
|
+
if (strength < 0.03) return 0;
|
|
238
|
+
const sx = w / f.nx, sy = h / f.ny;
|
|
239
|
+
const lit = Math.max(0, Math.min(1, bright));
|
|
240
|
+
const base = (0.2 + 0.2 * lit) * (0.7 + 0.3 * strength);
|
|
241
|
+
const levels = [0.25, 0.5, 0.75, 1];
|
|
242
|
+
// ONE CONTINUOUS PATH PER RUN, EVERY OTHER POINT, FLAT CAPS (operator, 2026-09-16: "performance
|
|
243
|
+
// freezing and jittering when the cubes are being blown up"). Measured with each layer switched
|
|
244
|
+
// off in turn during a nuke, these lines were the largest single cost: 5,500 separate round-capped
|
|
245
|
+
// segments a frame. A tracer's consecutive points in the same opacity band are now one polyline,
|
|
246
|
+
// at half the points -- the paths are smooth, so nothing shows -- with butt caps and round joins.
|
|
247
|
+
const paths = levels.map(() => []);
|
|
248
|
+
for (let i = 0; i < tr.n; i++) {
|
|
249
|
+
const L = tr.len[i];
|
|
250
|
+
if (L < 5) continue;
|
|
251
|
+
const fade = Math.min(1, tr.age[i] / 0.5, (tr.life[i] - tr.age[i]) / 0.8);
|
|
252
|
+
if (fade <= 0.05) continue;
|
|
253
|
+
let run = null, runLv = -1;
|
|
254
|
+
// point p of the path, 0 the oldest and L-1 the newest, lives at this slot of the ring
|
|
255
|
+
const slot = (q) => (tr.head[i] - (L - 1) + q + TRAIL * 2) % TRAIL;
|
|
256
|
+
for (let p0 = 0; p0 < L - 1; p0 += 2) {
|
|
257
|
+
const p1 = Math.min(p0 + 2, L - 1);
|
|
258
|
+
const a = slot(p0), b = slot(p1);
|
|
259
|
+
const ax = tr.hx[i * TRAIL + a], ay = tr.hy[i * TRAIL + a], bx = tr.hx[i * TRAIL + b], by = tr.hy[i * TRAIL + b];
|
|
260
|
+
const along = (p0 + p1) / 2 / (L - 1); // 0 at the tail, 1 at the head
|
|
261
|
+
// and nothing at the edges of the field: each segment fades by how near it is to the left, the
|
|
262
|
+
// right or the top, smoothly, so a line going out of the picture thins away rather than ending
|
|
263
|
+
const mx = (ax + bx) * 0.5, my = (ay + by) * 0.5;
|
|
264
|
+
const ex = Math.min(mx / (f.nx * EDGE_FADE), (f.nx - mx) / (f.nx * EDGE_FADE)), ey = my / (f.ny * EDGE_FADE);
|
|
265
|
+
const edge = smooth(Math.max(0, Math.min(1, Math.min(ex, ey))));
|
|
266
|
+
const taper = Math.sin(Math.PI * along) * fade * edge; // nothing at either end, nothing at the edges
|
|
267
|
+
if (taper < 0.08) { run = null; runLv = -1; continue; }
|
|
268
|
+
const lv = taper < 0.3 ? 0 : taper < 0.55 ? 1 : taper < 0.8 ? 2 : 3;
|
|
269
|
+
if (lv !== runLv || !run) { run = [ax * sx, ay * sy]; paths[lv].push(run); runLv = lv; }
|
|
270
|
+
run.push(bx * sx, by * sy);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
ctx.lineCap = 'butt';
|
|
274
|
+
ctx.lineJoin = 'round';
|
|
275
|
+
ctx.lineWidth = 1.3;
|
|
276
|
+
let strokes = 0;
|
|
277
|
+
paths.forEach((runs, lv) => {
|
|
278
|
+
if (!runs.length) return;
|
|
279
|
+
ctx.strokeStyle = `rgba(${colour},${(base * levels[lv]).toFixed(3)})`;
|
|
280
|
+
ctx.beginPath();
|
|
281
|
+
for (const r of runs) { ctx.moveTo(r[0], r[1]); for (let j = 2; j < r.length; j += 2) ctx.lineTo(r[j], r[j + 1]); }
|
|
282
|
+
ctx.stroke();
|
|
283
|
+
strokes += 1;
|
|
284
|
+
});
|
|
285
|
+
return strokes;
|
|
286
|
+
}
|