blockyard 0.0.1 → 0.0.9
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 +679 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +172 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +40 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1575 -0
- package/docs/ARCHITECTURE.md +1307 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +840 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +202 -0
- package/docs/INSTALL.md +490 -0
- package/docs/MEASUREMENTS.md +1254 -0
- package/docs/PRIVATE-LEADERBOARD.md +230 -0
- package/docs/RULES.md +681 -0
- package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
- package/docs/SECURITY-AUDIT.md +258 -0
- package/docs/SECURITY.md +195 -0
- package/docs/STATE-2026-09-09.md +200 -0
- package/docs/TROUBLESHOOTING.md +298 -0
- package/docs/USER-GUIDE.md +1022 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +1785 -0
- package/public/index.html +893 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +964 -0
- package/public/js/app.js +1312 -0
- package/public/js/arkanoid.js +806 -0
- package/public/js/blockanoid.js +347 -0
- package/public/js/blockout.js +347 -0
- package/public/js/blockpack.js +428 -0
- package/public/js/blockscene3d.js +2678 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +311 -0
- package/public/js/details3d.js +2957 -0
- package/public/js/explorer.js +405 -0
- package/public/js/feepalette.js +149 -0
- package/public/js/fmt.js +162 -0
- package/public/js/goggles.js +886 -0
- package/public/js/kiosk.js +41 -0
- package/public/js/login.js +83 -0
- package/public/js/markets.js +357 -0
- package/public/js/mining.js +1138 -0
- package/public/js/panels.js +966 -0
- package/public/js/pricechart.js +188 -0
- package/public/js/settings.js +1014 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -0
- package/public/login.html +33 -0
- package/scripts/blockfile-measure.js +156 -0
- package/scripts/browser-check.mjs +286 -0
- package/scripts/check.js +173 -0
- package/scripts/decode-check.js +81 -0
- package/scripts/doc-counts.js +109 -0
- package/scripts/donate-qr.py +20 -0
- package/scripts/fake-node.js +534 -0
- package/scripts/index-bench.js +216 -0
- package/scripts/index-benchmark.js +117 -0
- package/scripts/index-build.js +40 -0
- package/scripts/live-render-check.mjs +89 -0
- package/scripts/manage-users.js +132 -0
- package/scripts/motion-check.mjs +138 -0
- package/scripts/pool-map.js +157 -0
- package/scripts/setup.js +410 -0
- package/scripts/shots.mjs +272 -0
- package/scripts/smoke.sh +327 -0
- package/scripts/ui.js +174 -0
- package/server/auth/sessions.js +221 -0
- package/server/auth/users.js +243 -0
- package/server/chain/blockfile.js +234 -0
- package/server/chain/index/build.js +193 -0
- package/server/chain/index/heights.js +36 -0
- package/server/chain/index/live.js +276 -0
- package/server/chain/index/rows.js +145 -0
- package/server/chain/index/store.js +154 -0
- package/server/chain/index/worker.js +109 -0
- package/server/chain/tx.js +310 -0
- package/server/collect/gbt.js +229 -0
- package/server/collect/logparse.js +765 -0
- package/server/collect/logtail.js +189 -0
- package/server/collect/markets.js +333 -0
- package/server/collect/mining.js +333 -0
- package/server/collect/monitor.js +2516 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +620 -0
- package/server/http/api.js +1275 -0
- package/server/http/explorer.js +418 -0
- package/server/http/server.js +412 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +628 -0
- package/server/netinfo.js +253 -0
- package/server/rpc/allowlist.js +130 -0
- package/server/rpc/client.js +414 -0
- package/server/store/audit.js +148 -0
- package/server/store/history.js +220 -0
- package/server/store/ledger.js +290 -0
- package/server/store/ring.js +173 -0
- package/server/util/fmt.js +29 -0
- package/systemd/blockyard.service +100 -0
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
// AGENTS: the idle effects that are SOMETHING HAPPENING rather than a pattern.
|
|
2
|
+
//
|
|
3
|
+
// (operator, 2026-09-13: "no more per-tile stuff. Think more tron lightcycles stuff", then
|
|
4
|
+
// "Build all of them with amazing effects", and "we can even add flying ships or other
|
|
5
|
+
// pixel-graphic inspired art".)
|
|
6
|
+
//
|
|
7
|
+
// THE DISTINCTION THIS MODULE EXISTS FOR. Seventeen of the board's effects are FIELDS: pure
|
|
8
|
+
// functions of a tile's position and the effect's clock, evaluated everywhere at once
|
|
9
|
+
// (`fxAt` in blockscene3d.js). A field is wallpaper -- plasma, aurora, checker, ripple. Three
|
|
10
|
+
// were AGENTS: a thing with a position, a route it has already travelled, and a future. You can
|
|
11
|
+
// watch an agent and wonder what it will do, and that is the whole difference.
|
|
12
|
+
//
|
|
13
|
+
// Those three (`lightcycle`, `ball`, and the retired `packets`) were built as `if` branches inside
|
|
14
|
+
// details3d.js's startFx and fxNow, with their drawing hard-wired into paintFrame. Fifty of them
|
|
15
|
+
// cannot be fifty `if`s in a 2,400-line renderer, so this module holds the registry and every
|
|
16
|
+
// agent's three parts, and details3d.js keeps only the seams.
|
|
17
|
+
//
|
|
18
|
+
// THE CONTRACT. An agent is `{ ms, build, frame, draw }`:
|
|
19
|
+
//
|
|
20
|
+
// build(ctx) once, when the effect starts, with { st, seed, W, H, tops, tiles, rnd }.
|
|
21
|
+
// The board is STILL while an effect runs, so a route, a formation or a sprite's
|
|
22
|
+
// path is decided here and a frame only moves along it. Returns whatever the
|
|
23
|
+
// agent wants to keep; it lands on `fx.agent`.
|
|
24
|
+
// frame(a, u) every frame, with the built state and progress 0..1. Returns the per-frame
|
|
25
|
+
// shape: `{ heads, ... }`. `heads` is how an agent LIGHTS THE BOARD -- see below.
|
|
26
|
+
// draw(ctx, view, lw) the agent's own geometry over the cubes, reading `view.fx`.
|
|
27
|
+
//
|
|
28
|
+
// EVERY AGENT MUST PUBLISH `heads`. effects.test.js plays every registered kind through fxAt and
|
|
29
|
+
// demands it light something (`worst > 0.3`); only `pulse` is exempt, because it draws on the
|
|
30
|
+
// price line. That test is not an obstacle to work around -- it is what stops an effect being
|
|
31
|
+
// painted OVER the board rather than interacting with it. `heads` is
|
|
32
|
+
// `[{ x, y, color: [r,g,b], alpha }]` in grid units, and the cubes near one flash in its colour.
|
|
33
|
+
//
|
|
34
|
+
// DETERMINISM. Board-level choices come from `fxHash(seed)` or the seeded `rnd` handed to
|
|
35
|
+
// `build`, never Math.random, so an effect replays identically and a test can assert on it.
|
|
36
|
+
// Per-frame sparkle (a crackle re-rolled every frame) may use Math.random because it is never
|
|
37
|
+
// asserted frame to frame -- electrical precisely because it never draws twice.
|
|
38
|
+
//
|
|
39
|
+
// CANVAS RULES. No ctx.clip(), no globalAlpha, no composite modes, no shadowBlur
|
|
40
|
+
// (viewer-canvas-rules.test.js scans this file's siblings; every glow here is layered plain rgba).
|
|
41
|
+
import {
|
|
42
|
+
project, fxHash, cubeHeight,
|
|
43
|
+
cyclePath, ballPath, cycleCrashes, cellTops, pathHeights,
|
|
44
|
+
// the board's OWN gravity, for the tractor beam's release. A dropped cube must fall the way every
|
|
45
|
+
// other cube on this board falls -- a second hand-rolled parabola beside the real one would land
|
|
46
|
+
// differently from the transition happening next to it.
|
|
47
|
+
bounceDrop, bouncesUntil, landingMs, fallShare,
|
|
48
|
+
} from './blockscene3d.js';
|
|
49
|
+
// The tetris drop points the real rules at the board instead of a well: pure, and already
|
|
50
|
+
// tested in its own right, so the piece shapes and their rotations are not reinvented here.
|
|
51
|
+
import { PIECES, cellsOf } from './tetris.js';
|
|
52
|
+
|
|
53
|
+
// a cheap deterministic 0..1 from an integer. details3d.js keeps its own copy for the price-line
|
|
54
|
+
// pulse; duplicating six lines is better than widening that module's public surface for a helper.
|
|
55
|
+
export const hash01 = (n) => { const x = Math.sin(n * 12.9898) * 43758.5453; return x - Math.floor(x); };
|
|
56
|
+
|
|
57
|
+
/** A seeded stream, so a build() makes the same board every replay. */
|
|
58
|
+
export function rng(seed) {
|
|
59
|
+
let s = (seed >>> 0) || 1;
|
|
60
|
+
return () => ((s = (Math.imul(s, 1103515245) + 12345) >>> 0) / 4294967296);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------- helpers shared by agents
|
|
64
|
+
|
|
65
|
+
/** Point `d` units along a polyline of grid points, and the index it sits in. */
|
|
66
|
+
export function alongPath(pts, d) {
|
|
67
|
+
const end = pts.length - 1;
|
|
68
|
+
const k = Math.max(0, Math.min(end - 1, Math.floor(d)));
|
|
69
|
+
const f = Math.max(0, Math.min(1, d - k));
|
|
70
|
+
const a = pts[k], b = pts[k + 1] ?? a;
|
|
71
|
+
return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f, k };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The tallest resting cube on the board, and its centre. What a data-aware agent aims at.
|
|
76
|
+
*
|
|
77
|
+
* The board is transactions: height is vbytes and `rate` is sat/vB. An agent that heads for the
|
|
78
|
+
* tallest cube is eating the biggest transaction in the block, which is worth more than an agent
|
|
79
|
+
* that wanders -- the picture it draws is then a reading of the data, the way `cascade` is.
|
|
80
|
+
*/
|
|
81
|
+
export function tallestTile(tiles) {
|
|
82
|
+
let best = null, bh = -1;
|
|
83
|
+
for (const t of tiles ?? []) {
|
|
84
|
+
const h = (t.floor ?? 0) + cubeHeight(t);
|
|
85
|
+
if (h > bh) { bh = h; best = t; }
|
|
86
|
+
}
|
|
87
|
+
return best ? { x: best.x + best.s / 2, y: best.y + best.s / 2, h: bh, tile: best } : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The richest resting cube (highest feerate), for agents that read the fee structure. */
|
|
91
|
+
export function richestTile(tiles) {
|
|
92
|
+
let best = null, br = -Infinity;
|
|
93
|
+
for (const t of tiles ?? []) {
|
|
94
|
+
const r = Number(t.rate);
|
|
95
|
+
if (Number.isFinite(r) && r > br) { br = r; best = t; }
|
|
96
|
+
}
|
|
97
|
+
return best ? { x: best.x + best.s / 2, y: best.y + best.s / 2, rate: br, tile: best } : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------- the registry
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Every agent, by kind. details3d.js reads this in three places and nowhere else:
|
|
104
|
+
* startFx (build), fxNow (frame) and paintFrame (draw).
|
|
105
|
+
*
|
|
106
|
+
* `lightcycle` and `ball` are migrated here first, unchanged in behaviour, so the contract is
|
|
107
|
+
* proven against the tests that already exist (lightcycle-crash.test.js calls drawCycles with a
|
|
108
|
+
* hand-built view; details3d.test.js asserts triggerIdle accepts both and still REFUSES
|
|
109
|
+
* `packets`, the retired one). New agents are added as data below them.
|
|
110
|
+
*/
|
|
111
|
+
export const AGENTS = Object.create(null);
|
|
112
|
+
|
|
113
|
+
/** Register one agent. Kinds are added to FX_MS, settings and the panel separately, in order. */
|
|
114
|
+
export function defineAgent(kind, spec) {
|
|
115
|
+
AGENTS[kind] = spec;
|
|
116
|
+
return spec;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Is this kind an agent (rather than a per-tile field)? */
|
|
120
|
+
export const isAgent = (kind) => Object.prototype.hasOwnProperty.call(AGENTS, kind);
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------- light cycles
|
|
123
|
+
//
|
|
124
|
+
// (operator, 2026-09-11: "add an energy pulse that travels along from one side of the board to
|
|
125
|
+
// another via block outlines. Be creative and varied", then 2026-09-12 "the lightcycles shouldn't
|
|
126
|
+
// have the electricity effect" -- a rider whose point is a clean light wall is not a storm.)
|
|
127
|
+
//
|
|
128
|
+
// Two riders from opposite edges, each laying a wall along the grid LINES, until one crosses the
|
|
129
|
+
// other's wall and de-reses. The routes and the height of every stretch are fixed at build time
|
|
130
|
+
// because the board is still; a frame only advances the heads.
|
|
131
|
+
defineAgent('lightcycle', {
|
|
132
|
+
draw(ctx, view, lw, api) { api.drawCycles(ctx, view, lw); },
|
|
133
|
+
build({ st, seed, W, H, tiles, rnd }) {
|
|
134
|
+
const tops = cellTops(tiles, W, H);
|
|
135
|
+
// on the price board the riders come in from the left and the right, never from the front
|
|
136
|
+
// (operator, 2026-09-14: "it all needs to be left/right or right/left movement")
|
|
137
|
+
const priceBoard = st?.axes?.line?.length > 1;
|
|
138
|
+
const sides = priceBoard || rnd() < 0.5 ? ['left', 'right'] : ['bottom', 'top'];
|
|
139
|
+
// ...and their walls run the candles' rows (axes.y is the middle of the candle row), so they
|
|
140
|
+
// ride the candle tops rather than the empty floor in front
|
|
141
|
+
// (axes.y 3.7 and a body 1.4 deep: grid lines 3 to 5, whose stretches all ride a candle cell)
|
|
142
|
+
const lanes = priceBoard && Number.isFinite(st.axes.y) ? [Math.floor(st.axes.y - 0.7), Math.ceil(st.axes.y + 0.7)] : null;
|
|
143
|
+
const paths = sides.map((side, i) => {
|
|
144
|
+
const pts = cyclePath(seed + i * 7919, W, H, side, lanes);
|
|
145
|
+
return { pts, hs: pathHeights(pts, tops, W, H), color: i ? [255, 150, 40] : [80, 220, 255], lag: i * 0.06 };
|
|
146
|
+
});
|
|
147
|
+
return { paths, crashes: cycleCrashes(paths) };
|
|
148
|
+
},
|
|
149
|
+
frame(a, u, { ms, derezMs }) {
|
|
150
|
+
const cycles = a.paths.map((p, i) => {
|
|
151
|
+
const len = p.pts.length - 1;
|
|
152
|
+
const c = a.crashes?.[i];
|
|
153
|
+
if (c && u >= c.u) {
|
|
154
|
+
// DE-RES: stopped dead where it hit; the draw shatters its wall and fades it
|
|
155
|
+
return { ...p, d: c.d, from: 0, alpha: 0, trail: Infinity, derez: Math.min(1, ((u - c.u) * ms) / derezMs), crash: c };
|
|
156
|
+
}
|
|
157
|
+
const v = Math.max(0, (u - p.lag) / (1 - p.lag));
|
|
158
|
+
const run = Math.min(1, v / 0.8), after = Math.max(0, (v - 0.8) / 0.2);
|
|
159
|
+
// the whole wall, from the start of the route: it is what the cycle has drawn on the board,
|
|
160
|
+
// and it stands until the effect is over (alpha fades it out at the very end)
|
|
161
|
+
return { ...p, d: run * len, from: 0, alpha: 1 - 0.75 * after, trail: Infinity };
|
|
162
|
+
});
|
|
163
|
+
const heads = cycles
|
|
164
|
+
.filter((c) => c.alpha > 0.05 && c.d > 0 && c.d < c.pts.length - 1)
|
|
165
|
+
.map((c) => { const p = alongPath(c.pts, c.d); return { x: p.x, y: p.y, color: c.color, alpha: c.alpha }; });
|
|
166
|
+
// the crash flashes white and lights the cubes round it
|
|
167
|
+
for (const c of cycles) {
|
|
168
|
+
if (c.derez != null && c.derez < 0.6) heads.push({ x: c.crash.at.x, y: c.crash.at.y, color: [255, 255, 255], alpha: 1 - c.derez / 0.6 });
|
|
169
|
+
}
|
|
170
|
+
return { cycles, heads };
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------- the lightning ball
|
|
175
|
+
//
|
|
176
|
+
// (operator, 2026-09-11, of the data packets that were here: "It comes off more as wandering
|
|
177
|
+
// lights. I was hoping for something that comes from off-screen along a grid line, and then starts
|
|
178
|
+
// tracing through the grid to the opposite side. Some high-effects version of a lightning ball
|
|
179
|
+
// moving along the grid, illuminating everything it comes near".)
|
|
180
|
+
defineAgent('ball', {
|
|
181
|
+
draw(ctx, view, lw, api) { api.drawBall(ctx, view, lw); },
|
|
182
|
+
build({ st, W, H, seed, tiles, rnd }) {
|
|
183
|
+
const tops = cellTops(tiles, W, H);
|
|
184
|
+
const line = st?.axes?.line;
|
|
185
|
+
if (line?.length > 1) {
|
|
186
|
+
// ON THE PRICE BOARD IT TRAVELS THE PRICE LINE (operator, 2026-09-14: "The plasma ball needs
|
|
187
|
+
// to move left to right on the markets. not back to front (or right to left). It needs to
|
|
188
|
+
// move across the entire chart randomly", then "make the lightning ball travel along the
|
|
189
|
+
// price line"). A route picked from any of four sides ran the depth axis half the time,
|
|
190
|
+
// which on eight units of depth is a ball crossing the board's thickness. Now: in from off
|
|
191
|
+
// the left edge, out past the right, along the grid line under the candles, and its height
|
|
192
|
+
// at every step is the line's own -- the ball rides the price, and its burn runs the line.
|
|
193
|
+
const y = Math.max(1, Math.min(H - 1, Math.round(Number.isFinite(st.axes.y) ? st.axes.y : H / 2)));
|
|
194
|
+
const E = 6;
|
|
195
|
+
const pts = [];
|
|
196
|
+
for (let x = -E; x <= W + E; x++) pts.push({ x, y });
|
|
197
|
+
const zAt = (x) => {
|
|
198
|
+
if (x <= line[0].x) return line[0].z;
|
|
199
|
+
for (let i = 1; i < line.length; i++) {
|
|
200
|
+
if (x <= line[i].x) { const a = line[i - 1], b = line[i]; return a.z + (b.z - a.z) * ((x - a.x) / Math.max(1e-9, b.x - a.x)); }
|
|
201
|
+
}
|
|
202
|
+
return line[line.length - 1].z;
|
|
203
|
+
};
|
|
204
|
+
const hs = [];
|
|
205
|
+
for (let k = 0; k + 1 < pts.length; k++) hs.push(zAt((pts[k].x + pts[k + 1].x) / 2));
|
|
206
|
+
return { paths: [{ pts, hs, color: [150, 215, 255] }] };
|
|
207
|
+
}
|
|
208
|
+
const side = ['left', 'right', 'bottom', 'top'][(rnd() * 4) | 0];
|
|
209
|
+
const pts = ballPath(seed, W, H, side, Math.ceil(Math.max(W, H) * 0.35) + 8);
|
|
210
|
+
return { paths: [{ pts, hs: pathHeights(pts, tops, W, H), color: [150, 215, 255] }] };
|
|
211
|
+
},
|
|
212
|
+
frame(a, u) {
|
|
213
|
+
// the ball runs the whole route at one speed; it carries its own light, so no sweep envelope
|
|
214
|
+
const p = a.paths[0], len = p.pts.length - 1;
|
|
215
|
+
const d = Math.max(0, Math.min(1, (u - 0.02) / 0.96)) * len;
|
|
216
|
+
const at = alongPath(p.pts, d);
|
|
217
|
+
const ball = { x: at.x, y: at.y, z: (p.hs[at.k] ?? 0) + 0.9, d, pts: p.pts, hs: p.hs };
|
|
218
|
+
return { ball, amp: 1, heads: [{ x: at.x, y: at.y, color: p.color, alpha: 1 }] };
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
export { project, fxHash };
|
|
223
|
+
|
|
224
|
+
// ================================================================= BATCH ONE
|
|
225
|
+
//
|
|
226
|
+
// Six agents, chosen to cover five different motion vocabularies rather than six variations on
|
|
227
|
+
// one: a strider, a ricochet, a seeker, a hopper, a formation, and an explosion shaped by the
|
|
228
|
+
// board. Three of them READ THE DATA -- the board is transactions, and an effect that answers
|
|
229
|
+
// "which is biggest" or "how dense is this block" is worth more than one that ignores what it
|
|
230
|
+
// crosses (docs/EFFECTS-AGENTS.md).
|
|
231
|
+
|
|
232
|
+
// --- shared drawing -------------------------------------------------------
|
|
233
|
+
// Plain rgba fills and strokes, layered. No clip, no globalAlpha, no composite modes, no
|
|
234
|
+
// shadowBlur -- same rules as the renderer, and viewer-canvas-rules.test.js now scans this file.
|
|
235
|
+
|
|
236
|
+
/** A filled polygon in screen space. */
|
|
237
|
+
function poly(ctx, pts, fill) {
|
|
238
|
+
if (pts.length < 3) return;
|
|
239
|
+
ctx.fillStyle = fill;
|
|
240
|
+
ctx.beginPath();
|
|
241
|
+
ctx.moveTo(pts[0].x, pts[0].y);
|
|
242
|
+
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
|
|
243
|
+
ctx.closePath();
|
|
244
|
+
ctx.fill();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** A polyline in screen space. */
|
|
248
|
+
function line(ctx, pts, col, w) {
|
|
249
|
+
if (pts.length < 2) return;
|
|
250
|
+
ctx.strokeStyle = col; ctx.lineWidth = w;
|
|
251
|
+
ctx.beginPath();
|
|
252
|
+
ctx.moveTo(pts[0].x, pts[0].y);
|
|
253
|
+
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
|
|
254
|
+
ctx.stroke();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Nested discs: this renderer's only way to make something glow.
|
|
259
|
+
*
|
|
260
|
+
* `r` is in PIXELS -- callers size it from view.unit, never from lw. lw is about one device pixel,
|
|
261
|
+
* so a sprite measured in line-widths comes out the same tiny size on every board; measured in
|
|
262
|
+
* grid units it is the same size relative to the BOARD, which is what reads.
|
|
263
|
+
*/
|
|
264
|
+
function bloom(ctx, x, y, r, rgb, a = 1) {
|
|
265
|
+
for (const [k, m] of [[1, 0.10], [0.62, 0.22], [0.34, 0.55], [0.16, 0.95]]) {
|
|
266
|
+
ctx.fillStyle = `rgba(${rgb.join(',')},${(m * a).toFixed(3)})`;
|
|
267
|
+
ctx.beginPath(); ctx.arc(x, y, r * k, 0, Math.PI * 2); ctx.fill();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** A square standing at grid (x, y) at height z, as screen points. */
|
|
272
|
+
const quad = (view, x, y, z, r) => [
|
|
273
|
+
project(x - r, y - r, z, view), project(x + r, y - r, z, view),
|
|
274
|
+
project(x + r, y + r, z, view), project(x - r, y + r, z, view),
|
|
275
|
+
];
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
// ================================================================= BATCH TWO
|
|
290
|
+
//
|
|
291
|
+
// Six more, from the same catalogue: a splitter, four personalities, a formation that breaks up,
|
|
292
|
+
// a thief, two populations that fight, and drifting debris. Between them they add the three things
|
|
293
|
+
// batch one had no example of -- an agent that DIVIDES, agents with different rules from each
|
|
294
|
+
// other, and two populations that interact.
|
|
295
|
+
|
|
296
|
+
/** A closed wireframe outline in screen space -- Asteroids' whole visual language. */
|
|
297
|
+
function wire(ctx, pts, col, w) {
|
|
298
|
+
if (pts.length < 3) return;
|
|
299
|
+
ctx.strokeStyle = col; ctx.lineWidth = w;
|
|
300
|
+
ctx.beginPath();
|
|
301
|
+
ctx.moveTo(pts[0].x, pts[0].y);
|
|
302
|
+
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
|
|
303
|
+
ctx.closePath();
|
|
304
|
+
ctx.stroke();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** An expanding ring, for a detonation. Cheap: one stroke, no fill. */
|
|
308
|
+
function ring(ctx, x, y, r, col, w) {
|
|
309
|
+
ctx.strokeStyle = col; ctx.lineWidth = w;
|
|
310
|
+
ctx.beginPath(); ctx.arc(x, y, Math.max(0.5, r), 0, Math.PI * 2); ctx.stroke();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// --- 7. CENTIPEDE ---------------------------------------------------------
|
|
314
|
+
//
|
|
315
|
+
// A column of segments weaving down the board, dropping a row each time it reaches an edge, and
|
|
316
|
+
// SPLITTING in two partway through -- the split is the thing everyone remembers about Centipede,
|
|
317
|
+
// and no other agent here divides. Our board is a grid of obstacles, which is the mushroom field
|
|
318
|
+
// the original needs.
|
|
319
|
+
defineAgent('centipede', {
|
|
320
|
+
build({ W, H, rnd }) {
|
|
321
|
+
// a boustrophedon: across, drop, back across -- the route the whole body follows in file
|
|
322
|
+
const pts = [];
|
|
323
|
+
const rows = 6;
|
|
324
|
+
const dropEvery = Math.max(2, Math.floor(H / (rows + 1)));
|
|
325
|
+
let dir = rnd() < 0.5 ? 1 : -1;
|
|
326
|
+
let x = dir > 0 ? 1 : W - 1;
|
|
327
|
+
let y = H - 2;
|
|
328
|
+
pts.push({ x, y });
|
|
329
|
+
for (let r = 0; r < rows; r++) {
|
|
330
|
+
const end = dir > 0 ? W - 1 : 1;
|
|
331
|
+
const steps = Math.max(1, Math.abs(end - x));
|
|
332
|
+
for (let i = 0; i < steps; i++) { x += dir; pts.push({ x, y }); }
|
|
333
|
+
for (let d = 0; d < dropEvery && y > 1; d++) { y -= 1; pts.push({ x, y }); }
|
|
334
|
+
dir = -dir;
|
|
335
|
+
}
|
|
336
|
+
return { pts, len: 11, splitAt: 0.45 + rnd() * 0.2, splitSeg: 4 + Math.floor(rnd() * 3) };
|
|
337
|
+
},
|
|
338
|
+
frame(a, u) {
|
|
339
|
+
const total = a.pts.length - 1;
|
|
340
|
+
const lead = Math.max(0, Math.min(1, u)) * total;
|
|
341
|
+
const split = u >= a.splitAt;
|
|
342
|
+
const heads = [];
|
|
343
|
+
const body = [];
|
|
344
|
+
for (let i = 0; i < a.len; i++) {
|
|
345
|
+
// after the split the tail half drops back and travels on its own, a little behind
|
|
346
|
+
const lag = i * 1.6 + (split && i >= a.splitSeg ? 6 + (u - a.splitAt) * 26 : 0);
|
|
347
|
+
const d = lead - lag;
|
|
348
|
+
if (d < 0) continue;
|
|
349
|
+
const p = alongPath(a.pts, d);
|
|
350
|
+
const isHead = i === 0 || (split && i === a.splitSeg);
|
|
351
|
+
body.push({ x: p.x, y: p.y, i, head: isHead });
|
|
352
|
+
heads.push({
|
|
353
|
+
x: p.x, y: p.y, r: isHead ? 1.9 : 1.4,
|
|
354
|
+
color: isHead ? [255, 230, 120] : [120, 235, 140],
|
|
355
|
+
alpha: isHead ? 1 : 0.85,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
return { centipede: { body, split }, heads };
|
|
359
|
+
},
|
|
360
|
+
draw(ctx, view, lw) {
|
|
361
|
+
const c = view.fx?.centipede;
|
|
362
|
+
if (!c) return;
|
|
363
|
+
const U = view.unit ?? 8;
|
|
364
|
+
// BIG ENOUGH TO BE A BODY, not a row of lit cubes. The first cut drew each segment at about
|
|
365
|
+
// one cube wide in the board's own green, so on a field of 3,700 green cubes it read as
|
|
366
|
+
// highlighting rather than as a creature crawling over them -- verified on the live board.
|
|
367
|
+
// Now: segments half again as large, a SPINE joining them so the body is one thing, and a
|
|
368
|
+
// magenta-red that nothing else on this board wears.
|
|
369
|
+
const pts = c.body.map((b) => project(b.x, b.y, 1.2, view));
|
|
370
|
+
for (let i = 1; i < pts.length; i++) {
|
|
371
|
+
// the spine only joins segments that are actually adjacent -- after the split the two halves
|
|
372
|
+
// are far apart, and a line between them would draw the creature back together
|
|
373
|
+
const gap = Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
|
|
374
|
+
if (gap > U * 3.5) continue;
|
|
375
|
+
line(ctx, [pts[i - 1], pts[i]], 'rgba(180,60,120,0.55)', U * 1.15);
|
|
376
|
+
}
|
|
377
|
+
for (let i = c.body.length - 1; i >= 0; i--) {
|
|
378
|
+
const b = c.body[i];
|
|
379
|
+
const p = pts[i];
|
|
380
|
+
const r = U * (b.head ? 1.75 : 1.25);
|
|
381
|
+
bloom(ctx, p.x, p.y, r, b.head ? [255, 210, 90] : [235, 90, 150], b.head ? 1 : 0.9);
|
|
382
|
+
// a hard rim, so a segment has an edge against a bright cube instead of dissolving into it
|
|
383
|
+
ring(ctx, p.x, p.y, r * 0.55, b.head ? 'rgba(255,245,210,0.9)' : 'rgba(255,190,225,0.75)', lw * 2);
|
|
384
|
+
if (b.head) {
|
|
385
|
+
for (const o of [-0.34, 0.34]) {
|
|
386
|
+
ctx.fillStyle = 'rgba(25,10,20,0.95)';
|
|
387
|
+
ctx.beginPath(); ctx.arc(p.x + o * U * 0.62, p.y - U * 0.26, U * 0.22, 0, Math.PI * 2); ctx.fill();
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
// --- 10. TRACTOR BEAM -----------------------------------------------------
|
|
399
|
+
//
|
|
400
|
+
// DATA-AWARE, and the only agent that uses `lift`: a ship stops over the TALLEST cube on the
|
|
401
|
+
// board, opens a beam, and draws it up. The cube rises, thins and vanishes -- then comes back when
|
|
402
|
+
// the effect ends, because nothing here mutates the board (see the hide/scale note in
|
|
403
|
+
// blockscene3d.js).
|
|
404
|
+
defineAgent('tractor', {
|
|
405
|
+
build({ tiles, W, H, rnd }) {
|
|
406
|
+
const target = tallestTile(tiles) ?? { x: W / 2, y: H / 2, h: 1, tile: null };
|
|
407
|
+
// where it leaves for, picked now so the exit replays identically (operator, 2026-09-14: "After
|
|
408
|
+
// it drops them, have it fly up and away in a random direction")
|
|
409
|
+
const away = rnd() * Math.PI * 2;
|
|
410
|
+
return { target, from: { x: rnd() < 0.5 ? -4 : W + 4, y: target.y }, away: { dx: Math.cos(away), dy: Math.sin(away) }, W, H };
|
|
411
|
+
},
|
|
412
|
+
// IT PUTS THE BLOCK DOWN AND LEAVES (operator, 2026-09-13: "rather than just abruptly finish,
|
|
413
|
+
// have the UFO drop the blocks causing them to slightly bounce, and then quickly flies off
|
|
414
|
+
// screen"). Before this the beam simply stopped: the cube was still four and a half units in the
|
|
415
|
+
// air, the saucer still parked over it, and the whole thing blinked out mid-abduction.
|
|
416
|
+
//
|
|
417
|
+
// HIGHER STILL, AND A REAL FALL (operator, 2026-09-14: "have it flying even higher off the board.
|
|
418
|
+
// and lift the blocks higher. When it lets go of the blocks, allow gravity to make them fall, and
|
|
419
|
+
// bounce before coming to rest"). The drop used to be squeezed into a fixed share of the effect,
|
|
420
|
+
// so its speed came from the effect's length, not from gravity. It is now timed by the board's
|
|
421
|
+
// own GRAVITY -- the constant every refresh landing falls under -- so a 24-unit fall takes
|
|
422
|
+
// fallMs(24) = 849 ms whatever the effect's duration, and the cube bounces with a coefficient of
|
|
423
|
+
// restitution of 0.5: hops of 6, 1.5 and 0.4 units, each shorter by sqrt(0.25), before it rests.
|
|
424
|
+
// Livelier than a refresh landing (e 0.15-0.36, hops of a few percent) on purpose: those barely
|
|
425
|
+
// leave the floor, and the ask was for a bounce you can see.
|
|
426
|
+
//
|
|
427
|
+
// IN 0.00-0.22 the saucer flies in from one side
|
|
428
|
+
// HOLD 0.22-0.50 the beam comes on and draws the cube up, easing to a stop at the top
|
|
429
|
+
// RELEASE 0.50 the beam cuts; the cube falls from rest under gravity and bounces
|
|
430
|
+
// AWAY first impact -> 1.00 the saucer climbs off along its own heading while it bounces
|
|
431
|
+
//
|
|
432
|
+
// Still grounded well before the end: `lift` is a per-frame OVERRIDE on a copy of the tile, so a
|
|
433
|
+
// cube still in the air when the effect stops would snap to the floor instead of landing on it.
|
|
434
|
+
// If the effect is ever made too short to hold the whole landing, the landing is compressed to
|
|
435
|
+
// fit rather than cut -- an honest fall that ends in a snap is worse than a quicker one.
|
|
436
|
+
frame(a, u, ctx = {}) {
|
|
437
|
+
const ms = ctx.ms ?? 8000;
|
|
438
|
+
const IN = 0.22, HOLD = 0.5;
|
|
439
|
+
const LIFT = 24;
|
|
440
|
+
// three times the old 5-over-the-top altitude, and never less than 6 over the lifted cube
|
|
441
|
+
const alt = Math.max(3 * (a.target.h + 5), a.target.h + LIFT + 6);
|
|
442
|
+
const E2 = 0.25; // restitution 0.5, squared: each hop's share of the last
|
|
443
|
+
const hops = bouncesUntil(E2, E2);
|
|
444
|
+
const landU = Math.min(0.9 - HOLD, landingMs(LIFT, E2, hops, E2) / ms);
|
|
445
|
+
const impactU = HOLD + landU * fallShare(E2, hops, E2);
|
|
446
|
+
const dir = a.away ?? { dx: a.from.x < a.target.x ? -1 : 1, dy: 0 };
|
|
447
|
+
const reach = Math.max(a.W, a.H) + 8;
|
|
448
|
+
|
|
449
|
+
// the saucer: in, parked over the cube, then away from the moment the cube first hits the floor
|
|
450
|
+
let ship, leaving = false, shipAlpha = 0.9;
|
|
451
|
+
if (u < IN) {
|
|
452
|
+
const v = u / IN;
|
|
453
|
+
ship = { x: a.from.x + (a.target.x - a.from.x) * v, y: a.target.y, z: alt };
|
|
454
|
+
} else if (u < impactU) {
|
|
455
|
+
ship = { x: a.target.x, y: a.target.y, z: alt };
|
|
456
|
+
} else {
|
|
457
|
+
// up and off along its own heading, t^2 so it pulls away rather than drifting
|
|
458
|
+
const v = Math.min(1, (u - impactU) / Math.max(1e-6, 1 - impactU));
|
|
459
|
+
ship = { x: a.target.x + dir.dx * reach * v * v, y: a.target.y + dir.dy * reach * v * v, z: alt + 30 * v * v };
|
|
460
|
+
leaving = true;
|
|
461
|
+
shipAlpha = Math.max(0, 1 - v);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// the cube
|
|
465
|
+
let lift = 0, beam = 0, dropped = false;
|
|
466
|
+
if (u >= IN && u < HOLD) {
|
|
467
|
+
const v = (u - IN) / (HOLD - IN);
|
|
468
|
+
beam = Math.sin(Math.min(1, v * 1.2) * Math.PI) * 0.6 + 0.4;
|
|
469
|
+
lift = LIFT * (1 - Math.cos(Math.PI * v)) / 2; // eases to a stop at the top, so the fall starts from rest
|
|
470
|
+
} else if (u >= HOLD && u < HOLD + landU) {
|
|
471
|
+
// RELEASED. The beam is off the instant it lets go -- a beam still drawn over a falling cube
|
|
472
|
+
// reads as the ship dropping something it is still holding.
|
|
473
|
+
lift = LIFT * bounceDrop((u - HOLD) / landU, E2, hops, E2);
|
|
474
|
+
dropped = true;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const heads = [{ x: ship.x, y: ship.y, color: [180, 220, 255], alpha: shipAlpha, r: 1.6 }];
|
|
478
|
+
if (u >= IN && u < HOLD + landU) {
|
|
479
|
+
heads.push({ x: a.target.x, y: a.target.y, color: [190, 235, 255], alpha: 1, r: 2.4, lift });
|
|
480
|
+
if (beam > 0) heads.push({ x: a.target.x, y: a.target.y, color: [140, 200, 255], alpha: 0.5 * beam, r: 4 });
|
|
481
|
+
// a flash of dust on each impact, and only then
|
|
482
|
+
if (dropped && lift < 0.05) heads.push({ x: a.target.x, y: a.target.y, color: [255, 240, 200], alpha: 0.7, r: 3 });
|
|
483
|
+
}
|
|
484
|
+
return {
|
|
485
|
+
tractor: { ship, beam, lift, ...(u >= IN && !leaving ? { target: a.target } : {}), ...(dropped ? { dropped: true } : {}), ...(leaving ? { leaving: true } : {}) },
|
|
486
|
+
heads,
|
|
487
|
+
};
|
|
488
|
+
},
|
|
489
|
+
draw(ctx, view, lw) {
|
|
490
|
+
const t = view.fx?.tractor;
|
|
491
|
+
if (!t) return;
|
|
492
|
+
const U = view.unit ?? 8;
|
|
493
|
+
const c = project(t.ship.x, t.ship.y, t.ship.z, view);
|
|
494
|
+
if (t.beam > 0.02) {
|
|
495
|
+
// the cone, as stacked translucent quads: no gradients needed, and none allowed on a fill
|
|
496
|
+
const base = project(t.ship.x, t.ship.y, 0, view);
|
|
497
|
+
const w0 = U * 0.5, w1 = U * 2.2 * t.beam;
|
|
498
|
+
poly(ctx, [
|
|
499
|
+
{ x: c.x - w0, y: c.y }, { x: c.x + w0, y: c.y },
|
|
500
|
+
{ x: base.x + w1, y: base.y }, { x: base.x - w1, y: base.y },
|
|
501
|
+
], `rgba(150,210,255,${(0.18 * t.beam).toFixed(3)})`);
|
|
502
|
+
poly(ctx, [
|
|
503
|
+
{ x: c.x - w0 * 0.4, y: c.y }, { x: c.x + w0 * 0.4, y: c.y },
|
|
504
|
+
{ x: base.x + w1 * 0.45, y: base.y }, { x: base.x - w1 * 0.45, y: base.y },
|
|
505
|
+
], `rgba(220,245,255,${(0.3 * t.beam).toFixed(3)})`);
|
|
506
|
+
}
|
|
507
|
+
// the saucer: a flattened dome with a lit rim
|
|
508
|
+
const r = U * 1.6;
|
|
509
|
+
const dome = [];
|
|
510
|
+
for (let i = 0; i <= 20; i++) { const ang = Math.PI + (i / 20) * Math.PI; dome.push({ x: c.x + Math.cos(ang) * r, y: c.y + Math.sin(ang) * r * 0.42 }); }
|
|
511
|
+
poly(ctx, dome, 'rgba(120,180,255,0.9)');
|
|
512
|
+
poly(ctx, [
|
|
513
|
+
{ x: c.x - r, y: c.y }, { x: c.x + r, y: c.y },
|
|
514
|
+
{ x: c.x + r * 0.7, y: c.y + r * 0.25 }, { x: c.x - r * 0.7, y: c.y + r * 0.25 },
|
|
515
|
+
], 'rgba(200,230,255,0.95)');
|
|
516
|
+
bloom(ctx, c.x, c.y + r * 0.2, r * 0.8, [180, 230, 255], 0.5);
|
|
517
|
+
void lw;
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// --- 11. MISSILE COMMAND --------------------------------------------------
|
|
522
|
+
//
|
|
523
|
+
// TWO POPULATIONS THAT INTERACT, which nothing else here does: arcs rain toward the board while
|
|
524
|
+
// interceptors rise to meet them, and each interception is an expanding ring that stops what it
|
|
525
|
+
// catches.
|
|
526
|
+
defineAgent('missile', {
|
|
527
|
+
build({ W, H, rnd }) {
|
|
528
|
+
const n = 5;
|
|
529
|
+
const incoming = [];
|
|
530
|
+
for (let i = 0; i < n; i++) {
|
|
531
|
+
const from = { x: rnd() * W, y: H + 3 };
|
|
532
|
+
const to = { x: rnd() * W, y: 1 + rnd() * (H * 0.4) };
|
|
533
|
+
const t0 = rnd() * 0.35;
|
|
534
|
+
// the interceptor leaves later and meets it partway: worked out now, so a frame only moves
|
|
535
|
+
const meet = 0.45 + rnd() * 0.3;
|
|
536
|
+
incoming.push({ from, to, t0, meet, up: { x: to.x + (rnd() - 0.5) * 8, y: 0 } });
|
|
537
|
+
}
|
|
538
|
+
return { incoming, W, H };
|
|
539
|
+
},
|
|
540
|
+
frame(a, u) {
|
|
541
|
+
const heads = [];
|
|
542
|
+
const shots = [];
|
|
543
|
+
const bursts = [];
|
|
544
|
+
for (const m of a.incoming) {
|
|
545
|
+
const v = (u - m.t0) / 0.7;
|
|
546
|
+
if (v <= 0) continue;
|
|
547
|
+
const caught = v >= m.meet;
|
|
548
|
+
const k = Math.min(caught ? m.meet : v, 1);
|
|
549
|
+
const x = m.from.x + (m.to.x - m.from.x) * k;
|
|
550
|
+
const y = m.from.y + (m.to.y - m.from.y) * k;
|
|
551
|
+
if (!caught) {
|
|
552
|
+
shots.push({ x, y, from: m.from, k, kind: 'down' });
|
|
553
|
+
heads.push({ x, y, color: [255, 120, 90], alpha: 1, r: 1.2 });
|
|
554
|
+
} else {
|
|
555
|
+
const age = (v - m.meet) / 0.35;
|
|
556
|
+
if (age < 1) {
|
|
557
|
+
bursts.push({ x, y, age });
|
|
558
|
+
heads.push({ x, y, color: [255, 240, 180], alpha: 1 - age, r: 1.5 + age * 5 });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
// the interceptor climbing to the meeting point
|
|
562
|
+
const iv = Math.min(1, Math.max(0, (v - m.meet * 0.45) / (m.meet * 0.55)));
|
|
563
|
+
if (iv > 0 && !caught) {
|
|
564
|
+
const ix = m.up.x + (x - m.up.x) * iv, iy = m.up.y + (y - m.up.y) * iv;
|
|
565
|
+
shots.push({ x: ix, y: iy, from: m.up, k: iv, kind: 'up' });
|
|
566
|
+
heads.push({ x: ix, y: iy, color: [140, 255, 200], alpha: 1, r: 1 });
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return { missile: { shots, bursts }, heads };
|
|
570
|
+
},
|
|
571
|
+
draw(ctx, view, lw) {
|
|
572
|
+
const m = view.fx?.missile;
|
|
573
|
+
if (!m) return;
|
|
574
|
+
const U = view.unit ?? 8;
|
|
575
|
+
for (const s of m.shots) {
|
|
576
|
+
const a0 = project(s.from.x, s.from.y, 0.8, view);
|
|
577
|
+
const a1 = project(s.x, s.y, 0.8, view);
|
|
578
|
+
const col = s.kind === 'down' ? '255,120,90' : '140,255,200';
|
|
579
|
+
line(ctx, [a0, a1], `rgba(${col},0.35)`, lw * 3);
|
|
580
|
+
line(ctx, [a0, a1], `rgba(${col},0.9)`, lw * 1.2);
|
|
581
|
+
bloom(ctx, a1.x, a1.y, U * 0.45, s.kind === 'down' ? [255, 150, 110] : [170, 255, 210], 0.9);
|
|
582
|
+
}
|
|
583
|
+
for (const b of m.bursts) {
|
|
584
|
+
const c = project(b.x, b.y, 0.9, view);
|
|
585
|
+
const r = U * (0.5 + b.age * 3.4);
|
|
586
|
+
const fade = 1 - b.age;
|
|
587
|
+
ring(ctx, c.x, c.y, r, `rgba(255,220,150,${(0.8 * fade).toFixed(3)})`, lw * 3);
|
|
588
|
+
ring(ctx, c.x, c.y, r * 0.65, `rgba(255,255,240,${(0.9 * fade).toFixed(3)})`, lw * 1.6);
|
|
589
|
+
bloom(ctx, c.x, c.y, r * 0.5, [255, 235, 190], fade * 0.7);
|
|
590
|
+
}
|
|
591
|
+
},
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
// ================================================================ BATCH THREE
|
|
597
|
+
//
|
|
598
|
+
// Six that change the board rather than crossing it, and two that ADD INFORMATION -- which is the
|
|
599
|
+
// rarest thing an effect here can do. Every one is still a per-frame override: nothing below
|
|
600
|
+
// mutates a tile (see hide/scale in blockscene3d.js).
|
|
601
|
+
|
|
602
|
+
/** Flood fill over the grid from a seed, stopping at cells a predicate refuses. Pure. */
|
|
603
|
+
export function floodFrom(x0, y0, W, H, blocked, limit = 900) {
|
|
604
|
+
const seen = new Set();
|
|
605
|
+
const order = [];
|
|
606
|
+
const key = (x, y) => y * W + x;
|
|
607
|
+
const q = [[x0 | 0, y0 | 0]];
|
|
608
|
+
seen.add(key(x0 | 0, y0 | 0));
|
|
609
|
+
while (q.length && order.length < limit) {
|
|
610
|
+
const [x, y] = q.shift();
|
|
611
|
+
order.push({ x, y, d: order.length });
|
|
612
|
+
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
|
613
|
+
const nx = x + dx, ny = y + dy;
|
|
614
|
+
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
|
|
615
|
+
const k = key(nx, ny);
|
|
616
|
+
if (seen.has(k)) continue;
|
|
617
|
+
seen.add(k);
|
|
618
|
+
if (blocked(nx, ny)) continue; // seen, so it is never revisited, but not entered
|
|
619
|
+
q.push([nx, ny]);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return order;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
// ================================================================= BATCH FOUR
|
|
638
|
+
//
|
|
639
|
+
// The family that could not be built until heads carried `hide` and `scale` (see the commit that
|
|
640
|
+
// wired them through). Three of these ALTER THE BOARD -- absorbing, collapsing, digging -- which
|
|
641
|
+
// the operator allowed on the condition that it snaps back: "Allow it, it snaps back". It does,
|
|
642
|
+
// by construction, because none of them touches a tile. They publish an override per frame and
|
|
643
|
+
// the renderer applies it to a copy; when the effect ends, or a transition takes the stage, or
|
|
644
|
+
// the tab is hidden, the override simply stops being computed.
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
// --- 20. BOULDER DASH -----------------------------------------------------
|
|
649
|
+
//
|
|
650
|
+
// A column gives way and the cubes above it COLLAPSE, each shrinking to nothing in turn, the
|
|
651
|
+
// cascade spreading outward from where it started.
|
|
652
|
+
defineAgent('boulderdash', {
|
|
653
|
+
build({ W, H, rnd, tiles }) {
|
|
654
|
+
const at = { x: Math.floor(rnd() * W), y: Math.floor(rnd() * H) };
|
|
655
|
+
void tiles;
|
|
656
|
+
return { at, W, H, reach: 7 + Math.floor(rnd() * 5) };
|
|
657
|
+
},
|
|
658
|
+
frame(a, u) {
|
|
659
|
+
// the cascade front, spreading out from the collapse
|
|
660
|
+
const front = u * a.reach * 1.5;
|
|
661
|
+
const heads = [];
|
|
662
|
+
for (let r = 0; r <= Math.min(a.reach, Math.ceil(front)); r++) {
|
|
663
|
+
const age = (front - r) / 2.2;
|
|
664
|
+
if (age < 0 || age > 1.6) continue;
|
|
665
|
+
const shrink = Math.max(0, 1 - age); // full height at the front, gone behind it
|
|
666
|
+
const n = Math.max(1, r * 4);
|
|
667
|
+
for (let i = 0; i < n; i++) {
|
|
668
|
+
const ang = (i / n) * Math.PI * 2;
|
|
669
|
+
heads.push({
|
|
670
|
+
x: a.at.x + Math.cos(ang) * r, y: a.at.y + Math.sin(ang) * r,
|
|
671
|
+
// THE RING MUST STILL BE VISIBLE WHERE IT IS WIDEST. alpha was 0.8 * shrink, and shrink
|
|
672
|
+
// goes to zero as a cube finishes collapsing -- so at u=0.9, with the front at its
|
|
673
|
+
// furthest, every head was alpha 0.00 and the collapse happened in the dark. Measured.
|
|
674
|
+
// A floor keeps the rim lit; `scale` carries the collapse, and it is gated on reach now
|
|
675
|
+
// rather than on alpha, so the two are properly separate.
|
|
676
|
+
color: [255, 190, 110], alpha: 0.35 + 0.55 * shrink, r: 1.1,
|
|
677
|
+
scale: 0.12 + 0.88 * shrink,
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return { boulderdash: { at: a.at, front }, heads };
|
|
682
|
+
},
|
|
683
|
+
draw(ctx, view, lw) {
|
|
684
|
+
const b = view.fx?.boulderdash;
|
|
685
|
+
if (!b) return;
|
|
686
|
+
const U = view.unit ?? 8;
|
|
687
|
+
// dust where the front is passing, so a collapse has debris
|
|
688
|
+
for (let i = 0; i < 28; i++) {
|
|
689
|
+
const ang = hash01(i * 13 + 5) * Math.PI * 2;
|
|
690
|
+
const rad = b.front * (0.6 + 0.5 * hash01(i * 7 + 11));
|
|
691
|
+
const c = project(b.at.x + Math.cos(ang) * rad, b.at.y + Math.sin(ang) * rad, 0.5 + hash01(i) * 1.5, view);
|
|
692
|
+
const r = U * (0.18 + 0.3 * hash01(i * 3 + 19));
|
|
693
|
+
ctx.fillStyle = `rgba(210,190,150,${(0.28 * (1 - Math.min(1, b.front / 11))).toFixed(3)})`;
|
|
694
|
+
ctx.beginPath(); ctx.arc(c.x, c.y, r, 0, Math.PI * 2); ctx.fill();
|
|
695
|
+
}
|
|
696
|
+
void lw;
|
|
697
|
+
},
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
// --- 24. BALL LIGHTNING ---------------------------------------------------
|
|
703
|
+
//
|
|
704
|
+
// (operator, 2026-09-14: "Remove the Portal effect, and replace it with a bright electric blue neon
|
|
705
|
+
// sphere of slow moving ball lightning that crackles with energy, and random arcs of energy burst out
|
|
706
|
+
// from the ball lightning and electrifies the blocks the arcs touch. Have it move from one end of the
|
|
707
|
+
// view space to the other disappearing off-screen.")
|
|
708
|
+
//
|
|
709
|
+
// The route and every arc are decided at build, from the seeded rnd -- the board is still while an
|
|
710
|
+
// effect runs -- so a frame only asks where the ball is and which arcs are alive. An arc is aimed at a
|
|
711
|
+
// real block near the ball, never at empty floor: the ask is that arcs electrify what they touch.
|
|
712
|
+
// The crackle on the sphere itself is re-rolled every frame (Math.random, as this file allows for
|
|
713
|
+
// sparkle that is never asserted frame to frame): electrical precisely because it never repeats.
|
|
714
|
+
defineAgent('stormball', {
|
|
715
|
+
build({ st, W, H, tiles, tops, rnd }) {
|
|
716
|
+
tops ??= cellTops(tiles ?? [], W, H);
|
|
717
|
+
const top = (cx, cy) => (cx >= 0 && cx < W && cy >= 0 && cy < H ? tops[cy * W + cx] : 0);
|
|
718
|
+
let highest = 0;
|
|
719
|
+
for (let i = 0; i < W * H; i++) highest = Math.max(highest, tops[i] ?? 0);
|
|
720
|
+
// ON THE PRICE BOARD IT FLIES THROUGH THE CHART, not over it: a height inside the line's own
|
|
721
|
+
// range, so the candles it passes are beside it and the arcs strike what it comes near
|
|
722
|
+
// (operator, 2026-09-14: "electrifying any elements it comes near")
|
|
723
|
+
const line = st?.axes?.line;
|
|
724
|
+
let alt = highest + 3.5;
|
|
725
|
+
if (line?.length > 1) {
|
|
726
|
+
let lo = Infinity, hi = -Infinity;
|
|
727
|
+
for (const p of line) { lo = Math.min(lo, p.z); hi = Math.max(hi, p.z); }
|
|
728
|
+
alt = lo + (hi - lo) * (0.35 + 0.5 * rnd());
|
|
729
|
+
}
|
|
730
|
+
// across the board the long way round, far enough past both edges to start and end off-screen
|
|
731
|
+
const leftToRight = rnd() < 0.5;
|
|
732
|
+
// the block board's view is far wider than the board, so the run starts well past its edges;
|
|
733
|
+
// the price board's view is fitted to its width (obliqueFit), and the same margin there kept
|
|
734
|
+
// the ball off-screen for the first third of its run (a third of a 7-day board is 190 units)
|
|
735
|
+
// ...but far enough that the whole thing has LEFT before the effect ends (operator, 2026-09-14:
|
|
736
|
+
// "have it fully moved off the display before you remove it"): the corona reaches 12 units
|
|
737
|
+
// from the centre and the trail seven behind, and at 8 the run ended with the glow still
|
|
738
|
+
// on the chart and cut off dead
|
|
739
|
+
const margin = st?.axes?.line?.length > 1 ? 16 : Math.max(W, H) * 0.55 + 6;
|
|
740
|
+
const from = { x: leftToRight ? -margin : W + margin, y: H * (0.22 + 0.56 * rnd()) };
|
|
741
|
+
const to = { x: leftToRight ? W + margin : -margin, y: H * (0.22 + 0.56 * rnd()) };
|
|
742
|
+
const weave = { amp: 1.5 + 2 * rnd(), cycles: 1 + rnd() * 1.5, phase: rnd() * Math.PI * 2 };
|
|
743
|
+
// bursts of arcs: a few a second, one to three at a time, each alive for a moment
|
|
744
|
+
const arcs = [];
|
|
745
|
+
for (let u = 0.03; u < 0.97; u += 0.016 + 0.035 * rnd()) {
|
|
746
|
+
const n = 1 + Math.floor(rnd() * 4);
|
|
747
|
+
for (let k = 0; k < n; k++) {
|
|
748
|
+
const arc = { u0: u + rnd() * 0.01, life: 0.02 + 0.035 * rnd(), ang: rnd() * Math.PI * 2, reach: 3.5 + 8 * rnd(), seed: Math.floor(rnd() * 1e9) };
|
|
749
|
+
// A SECOND ARC OFF THE STRUCK BLOCK, AND SOMETIMES A THIRD (operator, 2026-09-14: "a small
|
|
750
|
+
// chance for a second arc to spawn from the block and arc to a different block", then "Make
|
|
751
|
+
// the secondary arcing more frequent, and have a 50% for an additional third arc"). Half
|
|
752
|
+
// the arcs carry a chain -- its own direction and reach from the block the first lands on,
|
|
753
|
+
// to a different block -- and half of those chain once more from the second block to a
|
|
754
|
+
// third. Decided here, so a replay throws the same chains.
|
|
755
|
+
if (rnd() < 0.5) {
|
|
756
|
+
arc.chain = { ang: rnd() * Math.PI * 2, reach: 2.5 + 6 * rnd(), seed: Math.floor(rnd() * 1e9) };
|
|
757
|
+
if (rnd() < 0.5) arc.chain.next = { ang: rnd() * Math.PI * 2, reach: 2.5 + 6 * rnd(), seed: Math.floor(rnd() * 1e9) };
|
|
758
|
+
}
|
|
759
|
+
arcs.push(arc);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return { from, to, weave, alt, arcs, W, H, top };
|
|
763
|
+
},
|
|
764
|
+
frame(a, u) {
|
|
765
|
+
const at = (uu) => {
|
|
766
|
+
const x = a.from.x + (a.to.x - a.from.x) * uu;
|
|
767
|
+
const y = a.from.y + (a.to.y - a.from.y) * uu + a.weave.amp * Math.sin(uu * Math.PI * 2 * a.weave.cycles + a.weave.phase);
|
|
768
|
+
return { x, y, z: a.alt + 0.6 * Math.sin(uu * 17) };
|
|
769
|
+
};
|
|
770
|
+
const ball = at(u);
|
|
771
|
+
// where it just was: the nebula it leaves behind is drawn there, fading
|
|
772
|
+
const trail = [0.01, 0.022, 0.036, 0.052, 0.07, 0.09].map((d) => (u - d >= 0 ? at(u - d) : null)).filter(Boolean);
|
|
773
|
+
// the block an arc thrown at (aimX, aimY) strikes: the tallest cell near there, never `not`
|
|
774
|
+
// (the block a chain leaves), and none if the floor is bare
|
|
775
|
+
const strike = (aimX, aimY, not = []) => {
|
|
776
|
+
let hit = null;
|
|
777
|
+
const consider = (cx, cy) => {
|
|
778
|
+
const h = a.top(cx, cy);
|
|
779
|
+
// `not`: a chain must reach a different block -- two units clear of every block already
|
|
780
|
+
// struck in its chain, which on the candle board is the next candle along
|
|
781
|
+
if (not.some((n) => Math.max(Math.abs(cx + 0.5 - n.x), Math.abs(cy + 0.5 - n.y)) < 2)) return;
|
|
782
|
+
if (h > 0 && (!hit || h > hit.z)) hit = { x: cx + 0.5, y: cy + 0.5, z: h };
|
|
783
|
+
};
|
|
784
|
+
for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) consider(Math.floor(aimX) + dx, Math.floor(aimY) + dy);
|
|
785
|
+
// A THIN BOARD (the price board: eight deep, hundreds wide): an arc thrown at a random angle
|
|
786
|
+
// lands off the front or the back more often than not, so the strike falls back to the
|
|
787
|
+
// tallest thing in the three columns it was thrown toward, at any depth -- a candle
|
|
788
|
+
// (operator, 2026-09-14: "the ball lightning was not arcing out to any of the candles")
|
|
789
|
+
if (!hit && a.H <= 12) for (let dx = -1; dx <= 1; dx++) for (let cy = 0; cy < a.H; cy++) consider(Math.floor(aimX) + dx, cy);
|
|
790
|
+
return hit;
|
|
791
|
+
};
|
|
792
|
+
const live = [];
|
|
793
|
+
const heads = [{ x: ball.x, y: ball.y, color: [70, 180, 255], alpha: 0.7, r: 4.5 }];
|
|
794
|
+
for (const arc of a.arcs) {
|
|
795
|
+
const age = (u - arc.u0) / arc.life;
|
|
796
|
+
if (age < 0 || age > 2.2) continue; // alive, then an afterglow on the block
|
|
797
|
+
const p = at(arc.u0);
|
|
798
|
+
// the block the arc strikes: the tallest cell near where it is thrown, and none if the floor is bare
|
|
799
|
+
const hit = strike(p.x + Math.cos(arc.ang) * arc.reach, p.y + Math.sin(arc.ang) * arc.reach);
|
|
800
|
+
if (!hit) continue;
|
|
801
|
+
if (age <= 1) live.push({ to: hit, seed: arc.seed, strength: 1 - age * 0.6 });
|
|
802
|
+
// THE BLOCK IT TOUCHES IS ELECTRIFIED: a hard blue flare that flickers while the arc lives and
|
|
803
|
+
// dies away after it -- lit through fxAt's heads, so only the cubes actually struck light up
|
|
804
|
+
const flicker = 0.7 + 0.3 * Math.sin(u * 900 + arc.seed);
|
|
805
|
+
const glow = age <= 1 ? flicker : Math.max(0, 1 - (age - 1) / 1.2) * 0.7;
|
|
806
|
+
heads.push({ x: hit.x, y: hit.y, color: [60, 190, 255], alpha: Math.min(1, glow * 1.25), r: 1.2 });
|
|
807
|
+
// ...and the chain, if this arc carries one: from the struck block to another. ITS OWN
|
|
808
|
+
// EVENT, not a bend in the first (2026-09-14: "I'm not seeing secondary arcs" -- they were
|
|
809
|
+
// there in a third of the arc frames, drawn in the first arc's colour from the first arc's
|
|
810
|
+
// end while it still lived, and read as one longer arc). It leaps half a beat after the
|
|
811
|
+
// first lands and outlives it, in violet against the first's blue, and never to the block it
|
|
812
|
+
// left or one beside it -- a chain a unit long is a chain nobody sees.
|
|
813
|
+
// Violet was the first colour, and it vanished: the nebula the ball drags is violet and
|
|
814
|
+
// magenta. Electric green is on nothing else here -- not the blue arcs, not the nebula, not
|
|
815
|
+
// the price line's yellow -- and a chain lives 1.2 arc-lives, so it is on screen longer
|
|
816
|
+
// than the arc that threw it. NO DELAY (operator: "get rid of the delay on the secondary
|
|
817
|
+
// arcing"): the chain leaps the instant the first arc lands, and the third with it.
|
|
818
|
+
let from = hit, link = arc.chain, hop = 1;
|
|
819
|
+
const struck = [hit];
|
|
820
|
+
while (link) {
|
|
821
|
+
const age2 = age / 1.2;
|
|
822
|
+
const to = strike(from.x + Math.cos(link.ang) * link.reach, from.y + Math.sin(link.ang) * link.reach, struck);
|
|
823
|
+
if (!to) break;
|
|
824
|
+
if (age2 <= 1) live.push({ from, to, seed: link.seed, strength: Math.min(1, 1.2 * (1 - age2 * 0.5)), chain: hop, age: age2 });
|
|
825
|
+
const glow2 = age2 <= 1 ? 0.7 + 0.3 * Math.sin(u * 900 + link.seed) : Math.max(0, 1 - (age2 - 1) / 1.2) * 0.7;
|
|
826
|
+
heads.push({ x: to.x, y: to.y, color: [120, 255, 170], alpha: Math.min(1, glow2 * 1.25), r: 1.2 });
|
|
827
|
+
struck.push(to); from = to; link = link.next; hop++;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
return { stormball: { at: ball, arcs: live, trail, t: u }, heads };
|
|
831
|
+
},
|
|
832
|
+
draw(ctx, view, lw) {
|
|
833
|
+
const s = view.fx?.stormball;
|
|
834
|
+
if (!s) return;
|
|
835
|
+
const U = view.unit ?? 8;
|
|
836
|
+
const c = project(s.at.x, s.at.y, s.at.z, view);
|
|
837
|
+
// sized in grid units, so it reads as the same bright object on every board (bloom's note above)
|
|
838
|
+
// bigger than the first cut on the block board: it is the one thing there (operator: "visually
|
|
839
|
+
// stunning"); half that on the price board (operator, 2026-09-14: "make the ball lightning half
|
|
840
|
+
// the size it is now"), where it shares the view with the line
|
|
841
|
+
const R = U * (view.axes?.line?.length > 1 ? 1.7 : 3.4);
|
|
842
|
+
// a jagged bolt between two screen points, `kink` pixels of wander, re-rolled each frame
|
|
843
|
+
const bolt = (p, q, kink, steps) => {
|
|
844
|
+
const pts = [p];
|
|
845
|
+
for (let i = 1; i < steps; i++) {
|
|
846
|
+
const f = i / steps, nx = -(q.y - p.y), ny = q.x - p.x, L = Math.hypot(nx, ny) || 1;
|
|
847
|
+
const off = (Math.random() - 0.5) * 2 * kink * Math.sin(Math.PI * f);
|
|
848
|
+
pts.push({ x: p.x + (q.x - p.x) * f + (nx / L) * off, y: p.y + (q.y - p.y) * f + (ny / L) * off });
|
|
849
|
+
}
|
|
850
|
+
pts.push(q);
|
|
851
|
+
return pts;
|
|
852
|
+
};
|
|
853
|
+
// the arcs first, so the sphere sits over the roots of its own lightning
|
|
854
|
+
for (const arc of s.arcs) {
|
|
855
|
+
const end = project(arc.to.x, arc.to.y, arc.to.z, view);
|
|
856
|
+
// a chained arc leaves the block the first one struck, not the ball
|
|
857
|
+
const from = arc.from ? project(arc.from.x, arc.from.y, arc.from.z, view) : c;
|
|
858
|
+
const main = bolt(from, end, Math.hypot(end.x - from.x, end.y - from.y) * 0.18, 9);
|
|
859
|
+
// a chain (hop 1 or 2 from the block) is electric green, so it reads as the block discharging, not the ball
|
|
860
|
+
const [halo, body, edge] = arc.chain ? ['20,200,110', '110,255,170', '215,255,235'] : ['30,120,255', '60,170,255', '140,220,255'];
|
|
861
|
+
const sz = arc.chain ? 0.85 : 1;
|
|
862
|
+
line(ctx, main, `rgba(${halo},${(0.3 * arc.strength).toFixed(3)})`, Math.max(lw * 8, U * 1.1) * sz);
|
|
863
|
+
line(ctx, main, `rgba(${body},${(0.6 * arc.strength).toFixed(3)})`, Math.max(lw * 4, U * 0.45) * sz);
|
|
864
|
+
line(ctx, main, `rgba(${edge},${(0.95 * arc.strength).toFixed(3)})`, Math.max(lw * 2, U * 0.2) * sz);
|
|
865
|
+
line(ctx, main, `rgba(245,252,255,${arc.strength.toFixed(3)})`, Math.max(lw, U * 0.08) * sz);
|
|
866
|
+
// ...and a ring bursting from the block it lands on, the moment it lands
|
|
867
|
+
if (arc.chain && arc.age < 0.6) {
|
|
868
|
+
const f = arc.age / 0.6;
|
|
869
|
+
ring(ctx, end.x, end.y, U * (0.4 + 2.2 * f), `rgba(150,255,190,${(0.9 * (1 - f)).toFixed(3)})`, Math.max(lw * 2, U * 0.12) * (1 - 0.5 * f));
|
|
870
|
+
ring(ctx, end.x, end.y, U * (0.2 + 1.4 * f), `rgba(235,255,245,${(0.8 * (1 - f)).toFixed(3)})`, Math.max(lw, U * 0.06));
|
|
871
|
+
}
|
|
872
|
+
// a fork off the main channel, and a spark where it lands
|
|
873
|
+
const k = 3 + Math.floor(Math.random() * 4);
|
|
874
|
+
const fork = main[k], forkEnd = { x: fork.x + (Math.random() - 0.5) * U * 3, y: fork.y + (Math.random() - 0.5) * U * 3 };
|
|
875
|
+
line(ctx, bolt(fork, forkEnd, U * 0.5, 4), `rgba(130,215,255,${(0.6 * arc.strength).toFixed(3)})`, Math.max(lw, U * 0.08));
|
|
876
|
+
bloom(ctx, end.x, end.y, U * 2.2, [90, 200, 255], arc.strength);
|
|
877
|
+
}
|
|
878
|
+
// THE SPHERE IS ONE SMOOTH THING (operator, 2026-09-14: "needs a smooth gradient, not different
|
|
879
|
+
// discs around it. It needs to be visually stunning. Consider adding nebula effects"). Radial
|
|
880
|
+
// gradients, which the canvas rules never forbade -- clip, globalAlpha, composite modes and
|
|
881
|
+
// shadowBlur are the calls software rasterisers drop; a gradient fill is a fill. A harness
|
|
882
|
+
// without them gets the old shells.
|
|
883
|
+
// a gradient fill, or the first stop's colour where the canvas cannot make one (a test harness)
|
|
884
|
+
const gradientOf = (x, y, r, stops) => {
|
|
885
|
+
const g = typeof ctx.createRadialGradient === 'function' ? ctx.createRadialGradient(x, y, 0, x, y, r) : null;
|
|
886
|
+
if (!g || typeof g.addColorStop !== 'function') return stops[0][1];
|
|
887
|
+
for (const [o, col] of stops) g.addColorStop(o, col);
|
|
888
|
+
return g;
|
|
889
|
+
};
|
|
890
|
+
const radial = (x, y, r, stops) => {
|
|
891
|
+
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2);
|
|
892
|
+
ctx.fillStyle = gradientOf(x, y, r, stops); ctx.fill();
|
|
893
|
+
};
|
|
894
|
+
const t = (s.t ?? 0) * 11; // seconds into the run, for the drift
|
|
895
|
+
// THE NEBULA: three slow wisps of plasma around the ball, each a blob whose edge wanders with
|
|
896
|
+
// a few sines, turning at its own rate, in violet, magenta and cyan -- faint, wide, and drawn
|
|
897
|
+
// first so the sphere burns through them
|
|
898
|
+
const wisps = [[170, 70, 255, 0.42, 1.0], [255, 60, 190, 0.38, -0.7], [40, 220, 255, 0.28, 0.5], [255, 130, 80, 0.22, -1.3]];
|
|
899
|
+
wisps.forEach(([r, g, b, a, spin], k) => {
|
|
900
|
+
const cxk = c.x + Math.cos(t * spin * 0.6 + k * 2.1) * R * 0.5, cyk = c.y + Math.sin(t * spin * 0.45 + k * 1.3) * R * 0.4;
|
|
901
|
+
const base = R * (2.1 + 0.45 * k);
|
|
902
|
+
ctx.beginPath();
|
|
903
|
+
for (let i = 0; i <= 40; i++) {
|
|
904
|
+
const th = (i / 40) * Math.PI * 2;
|
|
905
|
+
const wobble = 1 + 0.28 * Math.sin(th * 3 + t * spin + k) + 0.17 * Math.sin(th * 5 - t * 0.8 * spin + k * 3) + 0.09 * Math.sin(th * 8 + t * 1.7);
|
|
906
|
+
const rr = base * wobble;
|
|
907
|
+
const x = cxk + Math.cos(th) * rr, y = cyk + Math.sin(th) * rr;
|
|
908
|
+
if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y);
|
|
909
|
+
}
|
|
910
|
+
ctx.closePath();
|
|
911
|
+
ctx.fillStyle = gradientOf(cxk, cyk, base * 1.4, [[0, `rgba(${r},${g},${b},${a})`], [0.35, `rgba(${r},${g},${b},${(a * 0.7).toFixed(3)})`], [0.7, `rgba(${r},${g},${b},${(a * 0.25).toFixed(3)})`], [1, `rgba(${r},${g},${b},0)`]]);
|
|
912
|
+
ctx.fill();
|
|
913
|
+
});
|
|
914
|
+
// the nebula it leaves behind: where the ball just was, each older and fainter
|
|
915
|
+
(s.trail ?? []).forEach((p, i) => {
|
|
916
|
+
const q = project(p.x, p.y, p.z, view);
|
|
917
|
+
const f = 1 - (i + 1) / ((s.trail?.length ?? 1) + 1);
|
|
918
|
+
radial(q.x, q.y, R * (1.8 + i * 0.6), [[0, `rgba(140,150,255,${(0.28 * f).toFixed(3)})`], [0.5, `rgba(120,60,230,${(0.16 * f).toFixed(3)})`], [1, 'rgba(60,20,160,0)']]);
|
|
919
|
+
});
|
|
920
|
+
// the corona: wide, electric, breathing
|
|
921
|
+
const breath = 1 + 0.06 * Math.sin(t * 5.3) + 0.04 * Math.sin(t * 8.9);
|
|
922
|
+
radial(c.x, c.y, R * 3.6 * breath, [[0, 'rgba(60,160,255,0.5)'], [0.3, 'rgba(40,120,255,0.22)'], [0.6, 'rgba(90,50,230,0.08)'], [1, 'rgba(90,50,230,0)']]);
|
|
923
|
+
// sparks in orbit: motes of light circling at their own speeds and radii, each a tiny gradient
|
|
924
|
+
for (let i = 0; i < 26; i++) {
|
|
925
|
+
const sp = 0.6 + (i % 7) * 0.23, ph = i * 2.399, rr = R * (1.5 + ((i * 37) % 11) / 11 * 2.2);
|
|
926
|
+
const ang = t * sp + ph, wob = 1 + 0.18 * Math.sin(t * 3.1 + i);
|
|
927
|
+
const x = c.x + Math.cos(ang) * rr * wob, y = c.y + Math.sin(ang) * rr * 0.55 * wob;
|
|
928
|
+
const tone = i % 3 === 0 ? '255,120,230' : i % 3 === 1 ? '120,200,255' : '200,160,255';
|
|
929
|
+
radial(x, y, Math.max(1.5, U * 0.22), [[0, `rgba(${tone},0.95)`], [0.4, `rgba(${tone},0.45)`], [1, `rgba(${tone},0)`]]);
|
|
930
|
+
}
|
|
931
|
+
// the ball: white-hot centre through cyan to a violet rim, one gradient
|
|
932
|
+
radial(c.x, c.y, R * 1.35 * breath, [
|
|
933
|
+
[0, 'rgba(255,255,255,1)'], [0.18, 'rgba(235,250,255,1)'], [0.4, 'rgba(150,225,255,0.95)'],
|
|
934
|
+
[0.62, 'rgba(60,170,255,0.8)'], [0.82, 'rgba(90,80,255,0.45)'], [1, 'rgba(120,60,255,0)'],
|
|
935
|
+
]);
|
|
936
|
+
// THE CRACKLE: short filaments skittering over and just off the surface, new every frame
|
|
937
|
+
for (let i = 0; i < 14; i++) {
|
|
938
|
+
const ang = Math.random() * Math.PI * 2, r0 = R * (0.35 + 0.5 * Math.random()), r1 = R * (1.05 + 0.55 * Math.random());
|
|
939
|
+
const p = { x: c.x + Math.cos(ang) * r0, y: c.y + Math.sin(ang) * r0 };
|
|
940
|
+
const q = { x: c.x + Math.cos(ang + (Math.random() - 0.5) * 0.9) * r1, y: c.y + Math.sin(ang + (Math.random() - 0.5) * 0.9) * r1 };
|
|
941
|
+
const pts = bolt(p, q, R * 0.22, 4);
|
|
942
|
+
line(ctx, pts, 'rgba(90,190,255,0.55)', Math.max(lw * 2.2, U * 0.12));
|
|
943
|
+
line(ctx, pts, 'rgba(230,248,255,0.95)', Math.max(lw, U * 0.04));
|
|
944
|
+
}
|
|
945
|
+
},
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
// ================================================================= BATCH FIVE
|
|
949
|
+
//
|
|
950
|
+
// The last six, and with them every effect in the catalogue that was worth building. Two use the
|
|
951
|
+
// skyline as TERRAIN rather than as an obstacle, one is deliberately rare, and one de-reses the
|
|
952
|
+
// board and puts it back.
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
|