blockyard 0.0.9 → 0.1.0
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 +251 -1
- package/README.md +42 -23
- package/bin/blockyard.js +2 -1
- package/docs/API.md +16 -14
- package/docs/ARCHITECTURE.md +92 -5
- package/docs/CONFIGURATION.md +33 -26
- package/docs/GETTING-STARTED.md +5 -2
- package/docs/INSTALL.md +90 -33
- package/docs/MEASUREMENTS.md +147 -0
- package/docs/SECURITY.md +32 -15
- package/docs/TROUBLESHOOTING.md +35 -1
- package/docs/USER-GUIDE.md +266 -26
- package/package.json +1 -1
- package/public/404.html +1 -1
- package/public/css/app.css +306 -82
- package/public/donate-qr.png +0 -0
- package/public/index.html +295 -103
- package/public/js/agents.js +228 -51
- package/public/js/app.js +82 -8
- package/public/js/blockscene3d.js +179 -27
- package/public/js/charts.js +21 -21
- package/public/js/depthchart.js +31 -27
- package/public/js/details3d.js +1456 -71
- 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/login.js +5 -0
- package/public/js/markets.js +46 -8
- package/public/js/mining.js +310 -32
- package/public/js/panels.js +14 -10
- package/public/js/pricechart.js +14 -13
- package/public/js/quake.js +20 -0
- package/public/js/settings.js +103 -21
- package/public/js/soundcard.js +459 -0
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/scripts/donate-qr.py +12 -9
- package/scripts/dos-bench.js +56 -0
- package/scripts/setup.js +34 -12
- package/scripts/shots.mjs +6 -0
- package/scripts/smoke.sh +1 -1
- package/scripts/tls.js +31 -0
- package/server/chain/index/build.js +21 -4
- package/server/collect/monitor.js +30 -1
- package/server/collect/network.js +295 -0
- package/server/config.js +46 -22
- package/server/http/api.js +49 -5
- package/server/http/games.js +77 -0
- package/server/http/server.js +8 -0
- package/server/main.js +53 -8
- package/server/tls/selfsigned.js +160 -0
- package/systemd/blockyard.service +7 -5
- package/docs/PRIVATE-LEADERBOARD.md +0 -230
- package/docs/STATE-2026-09-09.md +0 -200
package/public/js/details3d.js
CHANGED
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
// 3. Zero dependencies, no CDN.
|
|
15
15
|
|
|
16
16
|
import { packBlock, packExact, vbytesPerUnit, vsizeForSide } from './blockpack.js';
|
|
17
|
-
import { planTransition, frameAt, fitToBox, project, fxFront, TRANSITION, SLAB_H, TILE_H, surfaceNormal, cellTops } from './blockscene3d.js';
|
|
17
|
+
import { planTransition, frameAt, fitToBox, project, fxFront, TRANSITION, SLAB_H, TILE_H, surfaceNormal, cellTops, fxHash } from './blockscene3d.js';
|
|
18
18
|
// THE AGENTS (agents.js): the effects that are something happening rather than a pattern.
|
|
19
19
|
// This module keeps three seams and nothing else -- build here in startFx, frame in fxNow,
|
|
20
20
|
// draw in paintFrame -- so fifty agents do not become fifty `if`s in the renderer.
|
|
21
|
-
import { AGENTS, isAgent, rng } from './agents.js';
|
|
21
|
+
import { AGENTS, isAgent, rng, lensFlare, saucerAbove } from './agents.js';
|
|
22
22
|
|
|
23
23
|
const STATE = new WeakMap();
|
|
24
24
|
|
|
@@ -145,10 +145,14 @@ function sizeCanvas(canvas, maxDpr = Infinity) {
|
|
|
145
145
|
// and one toggle in settings.js (the `effects` and `marketEffects` groups), and the lists are checked against each other
|
|
146
146
|
// by a test, so an effect cannot ship without a switch or a switch without an effect.
|
|
147
147
|
const FX_MS = {
|
|
148
|
-
ripple: 5200, outline: 4400, tide: 5200, cascade: 5600, twinkle: 3800, scan: 4200,
|
|
148
|
+
ripple: 5200, outline: 4400, tide: 5200, cascade: 5600, twinkle: 3800, scan: 16800, // 8400 -> 16800: top-down it pans OUT AND BACK, so two traversals at the same unhurried speed // scan 4200 -> 6000 -> 8400: the sweep now starts and ends off-panel, so the span grew and the duration follows it (below)
|
|
149
|
+
xray: 6500, // the board goes x-ray behind a sweeping front, and develops back
|
|
149
150
|
lightcycle: 6500, ball: 5600, pulse: 9000, // pulse 7000 -> 9000 (2026-09-14: "make it a bit slower")
|
|
150
151
|
bulge: 16000, // a sphere rolling through the price line; half speed (was 8000)
|
|
151
|
-
|
|
152
|
+
breathe: 9000, // the price line breathes: three slow swells between the wire and the pulse's heat
|
|
153
|
+
saber: 8000, // the price line ignites as a light saber, hums, and retracts
|
|
154
|
+
blackhole: 26000, // the chart collapses into a black hole and is let go again
|
|
155
|
+
shockwave: 4200, nova: 5200, firework: 5600, flare: 16000, wave: 6000, quake: 3200, // flare 3600 -> 8000 -> 16000 (2026-09-15: it is a supernova now, and the cloud disperses slowly)
|
|
152
156
|
rain: 6400, sparkle: 4600, checker: 4400, radar: 6000, vortex: 6400, powerup: 5000, combo: 4800, aurora: 7200, plasma: 6400,
|
|
153
157
|
// THE AGENTS (agents.js): effects that are a thing MOVING rather than a pattern over the board.
|
|
154
158
|
// Longer than the fields, because something that travels needs time to be watched -- a field
|
|
@@ -162,10 +166,38 @@ const FX_MS = {
|
|
|
162
166
|
// stormball replaced portal (2026-09-14); slow on purpose -- it drifts across the whole view
|
|
163
167
|
boulderdash: 6400, stormball: 11000,};
|
|
164
168
|
export const FX_KINDS = Object.keys(FX_MS);
|
|
169
|
+
// the price board's own lengths, where they differ: ball lightning crosses it at a third of the
|
|
170
|
+
// block board's speed -- 11 s there; a third slower (16.5 s, operator 2026-09-15: "cut the speed
|
|
171
|
+
// by 33% now that it's slower"), then 40% slower again (27.5 s: "Slow it down movement by 40%")
|
|
172
|
+
// THE DURATION FOLLOWS THE SPAN, or widening the travel speeds up the picture (operator,
|
|
173
|
+
// 2026-09-15: "why does it transit the market view so quick? I didn't ask for that"). When the
|
|
174
|
+
// sweep was made to start and end beyond the panel, the distance grew but the clock did not, so
|
|
175
|
+
// the part you actually watch -- the crossing of the board -- got 1.52x faster on Markets and
|
|
176
|
+
// 1.40x on Block space. These restore the crossing to the speed it had: measured, 9145 ms and
|
|
177
|
+
// 8400 ms for the two boards. Markets needs the longer one because its panel is wide and its board
|
|
178
|
+
// is shallow, so proportionally more of the journey happens off-screen.
|
|
179
|
+
// ...and then slower still on Markets (operator, 2026-09-15: "It's still way too fast on the
|
|
180
|
+
// markets display. Slow it down at least 50%"). 9150 restored the pacing the effect had BEFORE the
|
|
181
|
+
// travel change, which turns out not to have been slow enough either -- restoring a speed is not
|
|
182
|
+
// the same as it being right. 14600 makes the on-screen crossing 8451 ms against 5297, i.e. 60%
|
|
183
|
+
// slower, which clears the "at least 50%" asked for.
|
|
184
|
+
// AN EFFECT'S SIZE ON A SMALL BOARD (2026-09-15, the Kiosk): a radius in grid units, capped at a
|
|
185
|
+
// share of the board's width, so the same effect on a small panel never covers it
|
|
186
|
+
export function boundedRadius(units, share, gridW) { return Math.min(units, share * gridW); }
|
|
187
|
+
// effects that read as the same thing: never one right after the other
|
|
188
|
+
const FX_KIN = { scan: ['tractor'], tractor: ['scan'] };
|
|
189
|
+
export const MARKET_MS = { stormball: 27500, firework: 14000, flare: 24000, scan: 29200 }; // and a fireworks display of five shells, each with its smoke, needs the time
|
|
165
190
|
// The longest a refresh will ever wait for an effect to finish, plus a second of slack. Taken from
|
|
166
191
|
// the table rather than written as a number, so culling or adding an effect cannot leave the cap
|
|
167
192
|
// shorter than the effect it is meant to outlast. See the deferral in render3d.
|
|
168
193
|
const FX_DEFER_MAX = Math.max(...Object.values(FX_MS)) + 1000;
|
|
194
|
+
// A BREATH BETWEEN AN EFFECT AND THE REFRESH IT HELD BACK (operator, 2026-09-15, of the black
|
|
195
|
+
// hole: "snapping into place on last frame to drop in"): the parked layout took the stage on the
|
|
196
|
+
// very frame the effect ended, so the board's cubes were seen arriving home and, in the same
|
|
197
|
+
// instant, lifting off for the refresh -- one motion read as the other's last frame. The layout
|
|
198
|
+
// now waits a moment and a half after the effect, with the board whole and still
|
|
199
|
+
const FX_REST_MS = 1500;
|
|
200
|
+
const fxResting = (st, t) => st.fxEndedAt != null && t - st.fxEndedAt < FX_REST_MS;
|
|
169
201
|
// THE PULSE RIDES THE PRICE LINE (operator, 2026-09-12: "the energy pulse effect needs to run
|
|
170
202
|
// across the yellow line, not through space on an invisible grid ... travel the yellow line from
|
|
171
203
|
// one end to the other leaving a electric blue tint on the yellow line that starts fading back to
|
|
@@ -209,9 +241,12 @@ const hash01 = (n) => { const x = Math.sin(n * 12.9898) * 43758.5453; return x -
|
|
|
209
241
|
// where they stand (cascade, twinkle, the flare), or is the price line's own (pulse, bulge, ball
|
|
210
242
|
// lightning). The rest -- riders and walkers, the field patterns, anything that falls down the
|
|
211
243
|
// depth or turns in place, anything that moves a tile -- stays on the block board.
|
|
212
|
-
const ON_CANDLES = new Set(['ripple', 'outline', 'tide', 'cascade', 'twinkle', 'scan', 'pulse', 'bulge', 'firework', 'flare', 'wave', 'stormball']);
|
|
244
|
+
const ON_CANDLES = new Set(['ripple', 'outline', 'tide', 'cascade', 'twinkle', 'scan', 'xray', 'pulse', 'bulge', 'breathe', 'saber', 'blackhole', 'firework', 'flare', 'wave', 'stormball']);
|
|
213
245
|
// effects that are drawn on the price line and nowhere else: never offered to a board of blocks
|
|
214
|
-
|
|
246
|
+
// the black hole is back on the price board only (operator, 2026-09-15, after a day of it hovering
|
|
247
|
+
// over the block board: "still snapping into place. Just remove the black hole effect from block
|
|
248
|
+
// space if you can't fix it")
|
|
249
|
+
const LINE_ONLY = new Set(['pulse', 'bulge', 'breathe', 'saber', 'blackhole']);
|
|
215
250
|
// EACH BOARD ITS OWN LIST (operator, 2026-09-14: "I want the markets tab to have a separate effects
|
|
216
251
|
// list ... the Block Space effects specific to that panel, and settings specific to market panel"):
|
|
217
252
|
// settings.js keeps one group of switches per list (`effects` for the block board, `marketEffects`
|
|
@@ -258,7 +293,7 @@ function startFx(st, kind, now) {
|
|
|
258
293
|
const tiles = st.restTiles || [];
|
|
259
294
|
agent = spec.build({ st, seed, W, H, tiles, tops: cellTops(tiles, W, H), rnd: rng(seed) });
|
|
260
295
|
}
|
|
261
|
-
st.fx = { kind, t0: now, ms: FX_MS[kind] ?? 4500, x: origin.x, y: origin.y, dx: d[0], dy: d[1], rank, seed, agent,
|
|
296
|
+
st.fx = { kind, t0: now, ms: (onPriceBoard(st) ? MARKET_MS[kind] : null) ?? FX_MS[kind] ?? 4500, x: origin.x, y: origin.y, dx: d[0], dy: d[1], rank, seed, agent,
|
|
262
297
|
// `paths`/`crashes` stay on the record because drawCycles reads view.fx.cycles, which the
|
|
263
298
|
// agent's frame() produces from them; nothing outside agents.js builds them any more.
|
|
264
299
|
paths: agent?.paths ?? null, crashes: agent?.crashes ?? null };
|
|
@@ -281,6 +316,95 @@ function fxNow(st, t) {
|
|
|
281
316
|
const reach = Math.hypot(Math.max(f.x, st.gridW - f.x), Math.max(f.y, st.gridH - f.y));
|
|
282
317
|
Object.assign(out, { x: f.x, y: f.y, r: reach * (1 - Math.pow(1 - u, 2)), w: 2.2 + 2.4 * u });
|
|
283
318
|
}
|
|
319
|
+
// THE FIREWORKS LIGHT THEIR SURROUNDINGS IN THEIR OWN COLOUR (operator, 2026-09-15: "emissive
|
|
320
|
+
// colored lighting illuminating the explosion area in the explosion color"): every live shell is a
|
|
321
|
+
// head at its burst point, in its colour, as wide as the shell has spread, fading as it dies
|
|
322
|
+
if (f.kind === 'firework') {
|
|
323
|
+
const price = (st.axes?.line?.length ?? 0) > 1;
|
|
324
|
+
const heads = [];
|
|
325
|
+
for (const sh of fireworkShells(f.seed, st.gridW, st.gridH, price, 1, price ? (st.axes?.zTop ?? 34) : 16)) {
|
|
326
|
+
const v = (u - sh.t0) / sh.life;
|
|
327
|
+
if (!(v > 0.2 && v < 1.15)) continue;
|
|
328
|
+
const s2 = Math.min(1, (v - 0.2) / 0.8);
|
|
329
|
+
const spread = 1 - Math.exp(-3.4 * s2);
|
|
330
|
+
heads.push({ x: sh.bx, y: sh.by, color: sh.cols[0], alpha: Math.pow(Math.max(0, 1 - s2), 1.2) * (v < 0.28 ? 1 : 0.85), r: (price ? 7 : 8) * sh.size * (0.5 + 0.9 * spread) });
|
|
331
|
+
}
|
|
332
|
+
out.heads = heads;
|
|
333
|
+
}
|
|
334
|
+
// THE BLACK HOLE (2026-09-15): where it sits on the chart, how big its horizon is this frame,
|
|
335
|
+
// and -- through a head that HIDES -- which candles it has swallowed; the tiles near it glow the
|
|
336
|
+
// disk's orange. Everything the renderer needs rides on `out.blackhole`.
|
|
337
|
+
if (f.kind === 'blackhole' && st.axes?.line?.length > 1) {
|
|
338
|
+
const line = st.axes.line;
|
|
339
|
+
// IT CROSSES (operator, 2026-09-15: "get the black hole effect on the market display to slowly
|
|
340
|
+
// move from one side to the other"): from a fifth of the way in on one side to a fifth from
|
|
341
|
+
// the other, the way the front effects go (f.dx), easing in and out so it is still while it
|
|
342
|
+
// opens and while it closes; the candles' orbits ride along with it
|
|
343
|
+
// ...AND IT STOPS BEFORE IT LETS GO (operator, 2026-09-15: "the front faces of the lowest
|
|
344
|
+
// blocks just snap into place instead of dropping"): the crossing ran to 0.9 while the release
|
|
345
|
+
// began at 0.82, so a cube was let go not by the release curve but by the hole moving out of
|
|
346
|
+
// reach of it -- dropped in a few frames as the hole passed. The crossing is done by 0.8 now,
|
|
347
|
+
// and the release is the only thing that lets anything go
|
|
348
|
+
const travel = u < 0.1 ? 0 : u > 0.8 ? 1 : (() => { const t = (u - 0.1) / 0.7; return t * t * (3 - 2 * t); })();
|
|
349
|
+
const dir = (f.dx || 0) !== 0 ? Math.sign(f.dx) : hash01(f.seed + 5) < 0.5 ? 1 : -1;
|
|
350
|
+
const xa = st.gridW * (dir > 0 ? 0.18 : 0.82), xb = st.gridW * (dir > 0 ? 0.82 : 0.18);
|
|
351
|
+
const hx = xa + (xb - xa) * travel;
|
|
352
|
+
let hy, hz, lo = 0, hi = 0;
|
|
353
|
+
if (line) {
|
|
354
|
+
// THROUGH SPACE, NOT ALONG THE LINE (operator, 2026-09-15: "I don't want the black hole
|
|
355
|
+
// moving along the yellow line. I want it moving through space from point A to point B, and
|
|
356
|
+
// have everything warp around it ... riding the line hides that amazing effect"): a straight
|
|
357
|
+
// run from one height to another, both drawn from the seed inside the line's own range, so
|
|
358
|
+
// the hole crosses the line's path rather than sitting on it and the line is seen bending
|
|
359
|
+
// round the shadow as it passes
|
|
360
|
+
lo = Infinity; hi = -Infinity; for (const p of line) { lo = Math.min(lo, p.z); hi = Math.max(hi, p.z); }
|
|
361
|
+
const R = Math.max(1, hi - lo);
|
|
362
|
+
const za = lo + R * (0.15 + 0.85 * hash01(f.seed + 6)), zb = lo + R * (0.15 + 0.85 * hash01(f.seed + 7));
|
|
363
|
+
hz = za + (zb - za) * travel;
|
|
364
|
+
hy = st.axes.y ?? st.gridH / 2;
|
|
365
|
+
}
|
|
366
|
+
// ...AND ALL THE WAY HOME BEFORE THE END (operator, 2026-09-15: "still snapping into place on
|
|
367
|
+
// last frame to drop in"): the release curve reached zero only at u = 1, so on the last frame
|
|
368
|
+
// a cube was still a few pixels off home at nine tenths size, and the effect's end put it
|
|
369
|
+
// there in one step. It is a smoothstep now, done by 0.97 -- zero speed as it arrives -- and
|
|
370
|
+
// the last three hundredths draw nothing at all
|
|
371
|
+
const growIn = u < 0.22 ? Math.pow(u / 0.22, 1.6) : 1;
|
|
372
|
+
const growOut = u <= 0.82 ? 1 : u >= 0.97 ? 0 : (() => { const t = 1 - (u - 0.82) / 0.15; return t * t * (3 - 2 * t); })();
|
|
373
|
+
const grow = growIn * growOut;
|
|
374
|
+
const rs = boundedRadius(2.3, 0.047, st.gridW) * grow; // the horizon, in grid units (4.8 on 2026-09-15 -- "double the size" -- then 3.4: "obscuring too much" -- then 2.3: "shrink up the black hole by 33%"), and never more than 4.7% of the board
|
|
375
|
+
out.blackhole = { x: hx, y: hy, z: hz, rs, grow, lo, hi };
|
|
376
|
+
// SWALLOWED SMOOTHLY (operator, 2026-09-15: "the candles just blinking out of existence looks
|
|
377
|
+
// bad"): not `hide`, which is a threshold, but `scale: 0`, which fxAt applies by reach -- a
|
|
378
|
+
// candle shrinks toward nothing as it nears the horizon and grows back as the hole recedes
|
|
379
|
+
const reach = 1.5;
|
|
380
|
+
// LIFTED OUT WHOLE, NOT SHEARED, on the block board (operator, 2026-09-15: "too much bad
|
|
381
|
+
// shearing of the blocks around the black hole on the block space page. instead of stretching,
|
|
382
|
+
// can it just lift the blocks out?"): no corner lean there (lean 0 -- buildScene's orbit
|
|
383
|
+
// carries the cube toward the hole as one piece, its own shape, and it shrinks away into it).
|
|
384
|
+
// Not `lift` either: that extrudes a cube into a column from the floor, which read as an even
|
|
385
|
+
// longer stretch
|
|
386
|
+
// ...and `shrink` on the block board: the scale alone flattens a cube into a plate (it is a
|
|
387
|
+
// height override), and a plate sliding toward the hole was the "shearing"; shrunk in
|
|
388
|
+
// footprint as well it stays a cube, a smaller one, all the way in
|
|
389
|
+
// LET GO SMOOTHLY (operator, 2026-09-15: "The boxes are snapping into view when they come to
|
|
390
|
+
// rest on the board"): the release used to come from the head's reach shrinking with the
|
|
391
|
+
// hole, which let go of the outer cubes gently and the ones right under it all at once in the
|
|
392
|
+
// last frames -- they stayed on their rings at nothing, then popped home full size. The reach
|
|
393
|
+
// now holds its full size through the release and `release` (growOut) fades the pull and the
|
|
394
|
+
// scale themselves, so every cube glides home and grows back over the whole last fifth
|
|
395
|
+
const R = boundedRadius(2.3, 0.047, st.gridW) * reach;
|
|
396
|
+
out.heads = grow > 0.02 ? [{ x: hx, y: hy, z: hz, color: [255, 160, 60], alpha: grow, r: R * growIn, rPeak: R, scale: 0, pull: 1, release: growOut, lean: line ? 1 : 0, shrink: !line }] : [];
|
|
397
|
+
}
|
|
398
|
+
// THE PULSE LIGHTS WHAT IT PASSES (operator, 2026-09-15: "interfering with the affected areas"):
|
|
399
|
+
// its head is a light on the board, so the candles under it glow warm as it goes by (fxAt's
|
|
400
|
+
// heads branch, as the agents do)
|
|
401
|
+
if (f.kind === 'pulse' && st.axes?.line?.length > 1) {
|
|
402
|
+
const line = st.axes.line, headAt = u / PULSE_TRAVEL;
|
|
403
|
+
const k = Math.max(0, Math.min(1, headAt)) * (line.length - 1), i = Math.min(line.length - 2, Math.floor(k)), fr = k - i;
|
|
404
|
+
const x = line[i].x + (line[i + 1].x - line[i].x) * fr;
|
|
405
|
+
const fade = headAt <= 1 ? 1 : Math.max(0, 1 - (headAt - 1) / 0.34);
|
|
406
|
+
out.heads = [{ x, y: st.axes.y ?? st.gridH / 2, color: [255, 225, 150], alpha: fade, r: 3.2 }];
|
|
407
|
+
}
|
|
284
408
|
// AN AGENT'S FRAME comes from its own spec (agents.js). Everything it publishes lands on the
|
|
285
409
|
// frame object: `heads` (how it lights the cubes -- fxAt's heads branch), plus whatever its
|
|
286
410
|
// draw reads, such as `cycles` for the light walls or `ball` for the plasma ball.
|
|
@@ -312,7 +436,18 @@ export function chooseIdleFx(kinds, st, now, rnd = Math.random, noRepeat = 12) {
|
|
|
312
436
|
if (!kinds.length) return null;
|
|
313
437
|
const recent = st.recentFx ?? (st.recentFx = []);
|
|
314
438
|
const window = Math.max(0, Math.min(Math.floor(noRepeat), kinds.length - 1));
|
|
315
|
-
|
|
439
|
+
// FROM THE START, NOT FROM THE END (operator, 2026-09-15: "I keep seeing the same effect being
|
|
440
|
+
// played before new ones"). This was `recent.slice(recent.length - window)`, and while fewer
|
|
441
|
+
// effects had played than the window is wide that index was negative -- which slice counts from
|
|
442
|
+
// the END: nine plays under a window of eleven blocked only the last two. The rule was weakest
|
|
443
|
+
// right after the page opened, which is exactly when anyone is watching. Measured before the
|
|
444
|
+
// fix on the Markets list: a repeat at the tenth pick; after it: every effect plays once before
|
|
445
|
+
// any comes round again.
|
|
446
|
+
const blocked = new Set(recent.slice(Math.max(0, recent.length - window)));
|
|
447
|
+
// KINDS THAT LOOK ALIKE SHARE THE WINDOW (operator, 2026-09-15: "I just saw the UFO effect get
|
|
448
|
+
// triggered twice in a row on the block space" -- the scan's searchlight and the tractor beam
|
|
449
|
+
// fly the same saucer, so one after the other read as a repeat though the rule saw two kinds)
|
|
450
|
+
for (const k of [...blocked]) for (const kin of FX_KIN[k] ?? []) blocked.add(kin);
|
|
316
451
|
let pool = kinds.filter((k) => !blocked.has(k));
|
|
317
452
|
if (!pool.length) {
|
|
318
453
|
// every kind is inside the window: the ones played least recently
|
|
@@ -757,6 +892,1003 @@ function chargeTrail(ctx, segs, lw, now, seedBase = 0, pale = false) {
|
|
|
757
892
|
ctx.lineWidth = lw;
|
|
758
893
|
}
|
|
759
894
|
|
|
895
|
+
// A FIREWORKS DISPLAY (operator, 2026-09-15: "the fireworks effect doesn't work at all on the
|
|
896
|
+
// market screen", then "Make the fireworks much more impressive with further effects. Make them
|
|
897
|
+
// Arc. instead of just going straight up. Add the nebula smoke effect that fades out as it
|
|
898
|
+
// disperses ... Make a visually stunning fireworks display for the markets page"). The effect
|
|
899
|
+
// only ever LIT tiles -- fxAt's ring over the cubes was the picture on the block board, and on
|
|
900
|
+
// eight rows of candles it was a candle blinking. Every shell is drawn now, on both boards:
|
|
901
|
+
// * the LAUNCH is an arc, not a line: the rocket leaves the floor to one side and climbs a
|
|
902
|
+
// parabola to its burst point, a comet with a glowing tail and exhaust sparks falling behind;
|
|
903
|
+
// * the BURST is a white flash and a shockwave ring racing out and thinning;
|
|
904
|
+
// * the SHELL is one of four kinds by turn -- peony (round, two colours), chrysanthemum (long
|
|
905
|
+
// glittering trails that crackle white late), willow (heavy droop under gravity, long
|
|
906
|
+
// trails), ring (a hoop that keeps its shape as it falls) -- sixty to ninety sparks, each a
|
|
907
|
+
// curved trail of the last few positions, slowing and pulled down, twinkling, fading;
|
|
908
|
+
// * the SMOKE is a nebula: soft gradient blobs that leave the burst outward, growing, drifting
|
|
909
|
+
// up, in the shell's colour gone grey, and fade as they disperse -- on after the sparks;
|
|
910
|
+
// * up to ten shells on the price board, five on the block board (fireworkShells), overlapping,
|
|
911
|
+
// of every size, at every height of the picture, each lighting the candles under it in its
|
|
912
|
+
// own colour. Deterministic in fx.seed, as every effect is, so it replays and can be tested;
|
|
913
|
+
// only the twinkle reads the clock.
|
|
914
|
+
const SHELL_COLS = [[[255, 170, 110], [255, 235, 160]], [[140, 220, 255], [220, 245, 255]], [[220, 160, 255], [255, 200, 240]], [[130, 255, 190], [235, 255, 220]], [[255, 120, 150], [255, 210, 120]], [[255, 215, 90], [255, 250, 210]], [[120, 200, 255], [255, 140, 200]]];
|
|
915
|
+
|
|
916
|
+
// THE SHELLS OF A DISPLAY, from the seed: where and when each bursts, how big, what kind, what
|
|
917
|
+
// colour. Shared by the drawing (drawFireworks) and the lighting (fxNow's heads), so the candles
|
|
918
|
+
// glow in a shell's own colour exactly where and when it bursts. Up to ten on the price board
|
|
919
|
+
// (operator, 2026-09-15: "have more than 1 firework going off at a time. 10 max. Vary the size ...
|
|
920
|
+
// vary the height. Use the entire vertical viewport"), five on the block board; each launches at
|
|
921
|
+
// its own moment in the first half of the run and lives long enough to overlap the next.
|
|
922
|
+
export const FIREWORK_KINDS = ['peony', 'chrysanthemum', 'willow', 'ring', 'crossette', 'strobe']; // no pinwheel: sparks fly straight (2026-09-15)
|
|
923
|
+
export function fireworkShells(seed, gridW, gridH, price, zLo, zHi) {
|
|
924
|
+
const count = price ? 10 : 5, life = price ? 0.28 : 0.33, SMOKE = 1.35;
|
|
925
|
+
const out = [];
|
|
926
|
+
for (let i = 0; i < count; i++) {
|
|
927
|
+
const H = (k) => hash01(seed + i * 131 + k);
|
|
928
|
+
out.push({
|
|
929
|
+
i, life, smoke: SMOKE,
|
|
930
|
+
t0: 0.02 + H(0) * (price ? 0.5 : 0.4),
|
|
931
|
+
bx: 0.08 * gridW + H(1) * 0.84 * gridW, by: H(2) * gridH,
|
|
932
|
+
zc: zLo + (zHi - zLo) * (0.15 + 0.8 * H(3)),
|
|
933
|
+
size: 0.45 + 0.85 * H(4), // of the board's shell radius: small to big
|
|
934
|
+
kind: FIREWORK_KINDS[Math.floor(H(8) * FIREWORK_KINDS.length)],
|
|
935
|
+
cols: SHELL_COLS[Math.floor(H(9) * SHELL_COLS.length)],
|
|
936
|
+
drift: (H(5) < 0.5 ? -1 : 1) * (0.12 + 0.2 * H(6)) * gridW,
|
|
937
|
+
H,
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
return out;
|
|
941
|
+
}
|
|
942
|
+
// THE SHELL KINDS, by turn: how the sparks fly. (operator, 2026-09-15: "Make more varied
|
|
943
|
+
// explosions. More effects. Lensflare? Really think stunning to see.")
|
|
944
|
+
// peony round, two colours
|
|
945
|
+
// chrysanthemum long glittering trails that crackle white late
|
|
946
|
+
// willow heavy droop under gravity, the longest trails
|
|
947
|
+
// ring a hoop that keeps its shape as it falls
|
|
948
|
+
// crossette a peony whose sparks each split four ways partway out
|
|
949
|
+
// strobe the sparks blink in unison, white, on a beat
|
|
950
|
+
// pinwheel every spark curls the same way -- a spiral opening out
|
|
951
|
+
const SHELL_KINDS = ['peony', 'chrysanthemum', 'willow', 'ring', 'crossette', 'strobe']; // pinwheel is gone (2026-09-15: "a spiral effect ... defies physics of fireworks")
|
|
952
|
+
// SMOOTH BY LAYERING, NOT BY GRADIENT (operator, 2026-09-15: "gradient visible! We need smooth
|
|
953
|
+
// fills. no gradient shit!" -- then, of the block board: "what is still with the gradient fill
|
|
954
|
+
// shit?!"). createRadialGradient bands on this rasteriser: the rings are plainly visible, and
|
|
955
|
+
// worst on exactly the large soft blobs the effects are made of. Nested flat discs do not band --
|
|
956
|
+
// each is a plain rgba fill and the SUM is the curve.
|
|
957
|
+
//
|
|
958
|
+
// Takes the same `stops` a radial gradient would, so a conversion is mechanical and the colours
|
|
959
|
+
// are unchanged: it walks outward-in, interpolating the stop list, painting N flat rings.
|
|
960
|
+
export function softStops(ctx, x, y, r, stops, N = 0) {
|
|
961
|
+
// THE STEP MUST LAND UNDER A PIXEL, or the cure is the disease (operator, 2026-09-15: "What is
|
|
962
|
+
// with the gradient garbage?" -- of fills I had already converted). Twenty flat discs over a
|
|
963
|
+
// 300px radius is a 15px step: concentric rings, which is exactly the banding a gradient makes,
|
|
964
|
+
// only coarser. The layer count therefore comes from the RADIUS, not from a constant: about one
|
|
965
|
+
// ring per 1.2px, floored so a small sprite is not wasteful and capped so a huge nebula cannot
|
|
966
|
+
// cost thousands of fills.
|
|
967
|
+
if (!N) N = Math.max(16, Math.min(220, Math.round(Math.abs(r) / 1.2)));
|
|
968
|
+
const at = (t) => {
|
|
969
|
+
let a = stops[0], b = stops[stops.length - 1];
|
|
970
|
+
for (let i = 0; i < stops.length - 1; i++) if (t >= stops[i][0] && t <= stops[i + 1][0]) { a = stops[i]; b = stops[i + 1]; break; }
|
|
971
|
+
const span = b[0] - a[0], k = span > 1e-6 ? (t - a[0]) / span : 0;
|
|
972
|
+
const pa = String(a[1]).match(/rgba?\(([^)]+)\)/), pb = String(b[1]).match(/rgba?\(([^)]+)\)/);
|
|
973
|
+
if (!pa || !pb) return String(a[1]);
|
|
974
|
+
const va = pa[1].split(',').map(Number), vb = pb[1].split(',').map(Number);
|
|
975
|
+
const m = (i, d) => (va[i] ?? d) + (((vb[i] ?? d) - (va[i] ?? d)) * k);
|
|
976
|
+
return `rgba(${Math.round(m(0, 0))},${Math.round(m(1, 0))},${Math.round(m(2, 0))},${m(3, 1).toFixed(4)})`;
|
|
977
|
+
};
|
|
978
|
+
// THE DISCS ADD UP (2026-09-15: "far too much solid white" on the Kiosk -- the supernova's pool
|
|
979
|
+
// and glow were opaque discs). Each nested disc was painted at the stop's whole alpha, and a
|
|
980
|
+
// point at radius t lies under EVERY disc of radius >= t: two hundred discs at 0.08 stack to
|
|
981
|
+
// solid. What the caller means by a stop is the opacity of everything painted at that radius,
|
|
982
|
+
// so each disc carries only the INCREMENT -- painted from the outside in, the disc at t adds
|
|
983
|
+
// 1 - (1 - a(t)) / (1 - a(t_outer)), and the cumulative opacity at every radius is exactly a(t).
|
|
984
|
+
const alphaAt = (t) => { const m = at(t).match(/rgba\([^,]+,[^,]+,[^,]+,([^)]+)\)/); return m ? Math.min(0.999, Math.max(0, Number(m[1]))) : 1; };
|
|
985
|
+
let outer = 0; // the cumulative opacity just outside the current disc
|
|
986
|
+
for (let i = N; i >= 1; i--) {
|
|
987
|
+
const t = i / N, want = alphaAt(t);
|
|
988
|
+
const inc = want > outer ? 1 - (1 - want) / (1 - outer) : 0;
|
|
989
|
+
if (want > outer) outer = want;
|
|
990
|
+
if (inc < 0.0005) continue;
|
|
991
|
+
ctx.fillStyle = at(t).replace(/,([^,]+)\)$/, `,${inc.toFixed(4)})`);
|
|
992
|
+
ctx.beginPath(); ctx.arc(x, y, Math.max(0.5, r * t), 0, Math.PI * 2); ctx.fill();
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function drawFireworks(ctx, view, lw) {
|
|
997
|
+
const fx = view.fx;
|
|
998
|
+
if (!fx || fx.kind !== 'firework') return;
|
|
999
|
+
const U = view.unit ?? 8;
|
|
1000
|
+
const P = (x, y, z) => project(x, y, z, view);
|
|
1001
|
+
const line = view.axes?.line;
|
|
1002
|
+
const price = line?.length > 1;
|
|
1003
|
+
// THE WHOLE HEIGHT of the picture: the floor to the chart's top on the price board, the floor
|
|
1004
|
+
// to well above the cubes on the block board
|
|
1005
|
+
const zLo = 1, zHi = price ? (view.axes?.zTop ?? 34) : 16;
|
|
1006
|
+
const gf = Math.min(1, (1 - fx.u) / 0.1); // the run's own fade-out
|
|
1007
|
+
// still a real gradient maker, because gasCloud() takes one as a callback and uses it for the
|
|
1008
|
+
// smoke puffs; every FILL in this function is layered flat discs now (softStops)
|
|
1009
|
+
const gradientOf = (x, y, r, stops) => {
|
|
1010
|
+
const g = typeof ctx.createRadialGradient === 'function' ? ctx.createRadialGradient(x, y, 0, x, y, r) : null;
|
|
1011
|
+
if (!g || typeof g.addColorStop !== 'function') return stops[0][1];
|
|
1012
|
+
for (const [o, col] of stops) g.addColorStop(o, col);
|
|
1013
|
+
return g;
|
|
1014
|
+
};
|
|
1015
|
+
const linearOf = (x0, y0, x1, y1, stops) => {
|
|
1016
|
+
const g = typeof ctx.createLinearGradient === 'function' ? ctx.createLinearGradient(x0, y0, x1, y1) : null;
|
|
1017
|
+
if (!g || typeof g.addColorStop !== 'function') return stops[Math.floor(stops.length / 2)][1];
|
|
1018
|
+
for (const [o, col] of stops) g.addColorStop(o, col);
|
|
1019
|
+
return g;
|
|
1020
|
+
};
|
|
1021
|
+
const disc = (x, y, r, fill) => { ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(x, y, Math.max(0.5, r), 0, Math.PI * 2); ctx.fill(); };
|
|
1022
|
+
const seg = (a, b, col, w) => { ctx.strokeStyle = col; ctx.lineWidth = Math.max(lw * 0.8, w); ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); };
|
|
1023
|
+
const now = view.now ?? 0;
|
|
1024
|
+
const cx0 = (view.boardW ?? 0) / 2, cy0 = -(view.boardH ?? 0) / 4;
|
|
1025
|
+
for (const sh of fireworkShells(fx.seed, fx.gridW, fx.gridH, price, zLo, zHi)) {
|
|
1026
|
+
const { H, life, smoke: SMOKE, kind } = sh;
|
|
1027
|
+
const v = (fx.u - sh.t0) / life;
|
|
1028
|
+
if (!(v > 0 && v < SMOKE)) continue;
|
|
1029
|
+
const [col, col2] = sh.cols;
|
|
1030
|
+
const c1 = col.join(','), c2 = col2.join(',');
|
|
1031
|
+
const top = P(sh.bx, sh.by, sh.zc);
|
|
1032
|
+
const R0 = U * boundedRadius(price ? 4.5 : 6, 0.09, fx.gridW) * sh.size; // 7 and 8 until 2026-09-15 ("same with fireworks" -- on the Kiosk's panel ten shells carpeted it); never more than 9% of the board
|
|
1033
|
+
// --- the launch: a parabola from the floor, off to one side, to the burst point
|
|
1034
|
+
const RISE = 0.2;
|
|
1035
|
+
const arc = (f) => P(sh.bx - sh.drift * (1 - f), sh.by, zLo + (sh.zc - zLo) * (1 - (1 - f) * (1 - f)));
|
|
1036
|
+
if (v < RISE) {
|
|
1037
|
+
const f = v / RISE;
|
|
1038
|
+
const head = arc(f);
|
|
1039
|
+
const tail = [];
|
|
1040
|
+
for (let k = 0; k <= 8; k++) tail.push(arc(Math.max(0, f - (k / 8) * 0.22)));
|
|
1041
|
+
for (let k = 0; k < 8; k++) {
|
|
1042
|
+
const a = (1 - k / 8) * gf;
|
|
1043
|
+
seg(tail[k], tail[k + 1], `rgba(${c1},${(0.35 * a).toFixed(3)})`, U * 0.26 * a * sh.size);
|
|
1044
|
+
seg(tail[k], tail[k + 1], `rgba(255,240,210,${(0.9 * a).toFixed(3)})`, U * 0.08 * a);
|
|
1045
|
+
}
|
|
1046
|
+
for (let k = 0; k < 10; k++) {
|
|
1047
|
+
const age = ((f * 6 + k / 10) % 1), back = arc(Math.max(0, f - age * 0.18));
|
|
1048
|
+
const sx = back.x + (H(50 + k) - 0.5) * U * 0.8, sy = back.y + age * U * 1.6;
|
|
1049
|
+
disc(sx, sy, U * 0.06 * (1 - age), `rgba(255,220,160,${(0.8 * (1 - age) * gf).toFixed(3)})`);
|
|
1050
|
+
}
|
|
1051
|
+
softStops(ctx, head.x, head.y, U * 0.32, [[0, `rgba(255,255,255,${gf.toFixed(3)})`], [0.4, `rgba(${c2},${(0.9 * gf).toFixed(3)})`], [1, `rgba(${c1},0)`]]);
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
const s = Math.min(1, (v - RISE) / (1 - RISE)); // 0 at the burst, 1 when the sparks are out
|
|
1055
|
+
const ss = (v - RISE) / (SMOKE - RISE); // the smoke's own clock, 0..1 over the longer life
|
|
1056
|
+
// --- THE DISSIPATION (operator: "The dissipation effects need to be more detailed"): a nebula
|
|
1057
|
+
// of sixteen lumps, each two gradient discs offset so it is not round, leaving the burst
|
|
1058
|
+
// outward and curling as it goes, the shell's colour gone grey with the colour still in its
|
|
1059
|
+
// heart; embers falling slowly through it, flickering; ash sinking after them; all of it
|
|
1060
|
+
// fading as it disperses and gone before the run ends
|
|
1061
|
+
const smokeRc = [Math.round(col[0] * 0.45 + 95), Math.round(col[1] * 0.45 + 95), Math.round(col[2] * 0.45 + 105)];
|
|
1062
|
+
const smokeCol = smokeRc.join(',');
|
|
1063
|
+
// THE SMOKE IS A VOLUME OF GAS (operator, 2026-09-15: "Use the gas cloud for the fireworks
|
|
1064
|
+
// smoke too"): the supernova's renderer, smaller -- a sphere of soft blobs carried out from
|
|
1065
|
+
// the burst, drifting up, going from the shell's colour toward grey as it disperses
|
|
1066
|
+
{
|
|
1067
|
+
const sr = R0 * (0.3 + 1.1 * (1 - Math.exp(-2.2 * ss))), rise = R0 * 0.3 * ss;
|
|
1068
|
+
// TONED DOWN (operator, 2026-09-15: "the fireworks nebulas are too prominent. they need to be
|
|
1069
|
+
// toned down by half at least. Too opaque for too long"): half the alpha, a steeper fade,
|
|
1070
|
+
// fewer blobs, and the smoke lives 1.35 shell-lives rather than 1.7
|
|
1071
|
+
const sa = 0.45 * Math.pow(1 - ss, 2.2) * (0.4 + 0.6 * Math.min(1, ss * 6)) * gf;
|
|
1072
|
+
if (sa > 0.01) gasCloud(ctx, top.x, top.y - rise, sr, ss, now, fx.seed + sh.i * 977, sa * 1.1, smokeRc, gradientOf, disc, 60, [50, 50, 58]);
|
|
1073
|
+
}
|
|
1074
|
+
if (ss > 0.15) {
|
|
1075
|
+
const em = (ss - 0.15) / 0.85;
|
|
1076
|
+
for (let k = 0; k < 18; k++) {
|
|
1077
|
+
const ang = H(300 + k) * Math.PI * 2, d = R0 * (0.25 + 0.75 * H(320 + k));
|
|
1078
|
+
const x = top.x + Math.cos(ang) * d + Math.sin(now * 0.002 + k) * U * 0.35, y = top.y + Math.sin(ang) * d * 0.7 + R0 * (0.15 + 1.2 * em) * (0.5 + 0.5 * H(340 + k));
|
|
1079
|
+
const flick = 0.5 + 0.5 * Math.sin(now * 0.02 + k * 2.3);
|
|
1080
|
+
const a = (1 - em) * gf;
|
|
1081
|
+
if (a > 0.02) {
|
|
1082
|
+
softStops(ctx, x, y, U * 0.11 * (1 - 0.5 * em), [[0, `rgba(255,255,255,${(0.9 * a * flick).toFixed(3)})`], [0.5, `rgba(${c2},${(0.7 * a * flick).toFixed(3)})`], [1, `rgba(${c1},0)`]]);
|
|
1083
|
+
if (k % 3 === 0) disc(x + U * 0.2, y + U * 0.5 * em, U * 0.03, `rgba(${smokeCol},${(0.6 * a).toFixed(3)})`); // ash
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
if (v >= 1) continue; // only the dissipation is left
|
|
1088
|
+
// --- the burst: a flash, a lens flare, and a shockwave ring racing out and thinning
|
|
1089
|
+
if (s < 0.14) {
|
|
1090
|
+
const f = (1 - s / 0.14) * gf;
|
|
1091
|
+
const r = R0 * 0.5 * (1.3 - f * 0.6);
|
|
1092
|
+
softStops(ctx, top.x, top.y, r, [[0, `rgba(255,255,255,${(0.95 * f).toFixed(3)})`], [0.3, `rgba(${c2},${(0.7 * f).toFixed(3)})`], [1, `rgba(${c1},0)`]]);
|
|
1093
|
+
}
|
|
1094
|
+
if (s < 0.35) {
|
|
1095
|
+
const f = Math.pow(1 - s / 0.35, 1.5) * gf;
|
|
1096
|
+
const L = R0 * 2.6;
|
|
1097
|
+
ctx.strokeStyle = linearOf(top.x - L, top.y, top.x + L, top.y, [[0, `rgba(${c2},0)`], [0.35, `rgba(${c2},${(0.35 * f).toFixed(3)})`], [0.5, `rgba(255,255,255,${(0.9 * f).toFixed(3)})`], [0.65, `rgba(${c2},${(0.35 * f).toFixed(3)})`], [1, `rgba(${c2},0)`]]);
|
|
1098
|
+
ctx.lineWidth = Math.max(lw, U * 0.09); ctx.beginPath(); ctx.moveTo(top.x - L, top.y); ctx.lineTo(top.x + L, top.y); ctx.stroke();
|
|
1099
|
+
ctx.lineWidth = Math.max(lw * 3, U * 0.3); ctx.strokeStyle = linearOf(top.x - L, top.y, top.x + L, top.y, [[0, `rgba(${c1},0)`], [0.5, `rgba(${c1},${(0.25 * f).toFixed(3)})`], [1, `rgba(${c1},0)`]]);
|
|
1100
|
+
ctx.beginPath(); ctx.moveTo(top.x - L * 0.7, top.y); ctx.lineTo(top.x + L * 0.7, top.y); ctx.stroke();
|
|
1101
|
+
const rays = 4, spin = now * 0.0004 + H(11) * Math.PI;
|
|
1102
|
+
for (let k = 0; k < rays; k++) {
|
|
1103
|
+
const ang = spin + (k / rays) * Math.PI, rl = R0 * (0.9 + 0.5 * H(12 + k));
|
|
1104
|
+
const x0 = top.x - Math.cos(ang) * rl, y0 = top.y - Math.sin(ang) * rl, x1 = top.x + Math.cos(ang) * rl, y1 = top.y + Math.sin(ang) * rl;
|
|
1105
|
+
ctx.strokeStyle = linearOf(x0, y0, x1, y1, [[0, 'rgba(255,255,255,0)'], [0.5, `rgba(255,255,255,${(0.5 * f).toFixed(3)})`], [1, 'rgba(255,255,255,0)']]);
|
|
1106
|
+
ctx.lineWidth = Math.max(lw * 0.8, U * 0.03); ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
|
|
1107
|
+
}
|
|
1108
|
+
for (let k = 1; k <= 5; k++) {
|
|
1109
|
+
const t = k * 0.35, gx = top.x + (cx0 - top.x) * t, gy = top.y + (cy0 - top.y) * t;
|
|
1110
|
+
const gr = R0 * (0.08 + 0.1 * H(20 + k)) * (0.6 + t), ga = 0.16 * f * (1 - k / 7);
|
|
1111
|
+
softStops(ctx, gx, gy, gr, [[0, `rgba(${k % 2 ? c1 : c2},${(ga * 0.5).toFixed(3)})`], [0.7, `rgba(${k % 2 ? c1 : c2},${ga.toFixed(3)})`], [1, `rgba(${k % 2 ? c1 : c2},0)`]]);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
if (s < 0.4) {
|
|
1115
|
+
const f = s / 0.4;
|
|
1116
|
+
ctx.strokeStyle = `rgba(255,255,255,${(0.55 * (1 - f) * gf).toFixed(3)})`; ctx.lineWidth = Math.max(lw, U * 0.12 * (1 - f));
|
|
1117
|
+
ctx.beginPath(); ctx.arc(top.x, top.y, R0 * 1.25 * f, 0, Math.PI * 2); ctx.stroke();
|
|
1118
|
+
}
|
|
1119
|
+
// --- EMISSIVE LIGHT on the explosion's surroundings (operator: "emissive colored lighting
|
|
1120
|
+
// illuminating the explosion area in the explosion color"): a wide soft pool of the shell's
|
|
1121
|
+
// colour over everything near the burst, strongest at the burst and fading with the shell
|
|
1122
|
+
// (the candles under it glow in the colour too, through fxNow's heads)
|
|
1123
|
+
{
|
|
1124
|
+
const f = Math.pow(1 - s, 1.3) * gf, r = R0 * 2.4;
|
|
1125
|
+
softStops(ctx, top.x, top.y + R0 * 0.2 * s, r, [[0, `rgba(${c1},${(0.32 * f).toFixed(3)})`], [0.4, `rgba(${c1},${(0.14 * f).toFixed(3)})`], [1, `rgba(${c1},0)`]]);
|
|
1126
|
+
}
|
|
1127
|
+
// --- the shell
|
|
1128
|
+
const N = kind === 'ring' ? 60 : kind === 'crossette' ? 36 : 84;
|
|
1129
|
+
const fade = Math.pow(1 - s, kind === 'willow' ? 0.8 : 1.2) * gf;
|
|
1130
|
+
const spreadAt = (sq) => (kind === 'ring' ? Math.min(1, sq * 3) : 1 - Math.exp(-3.4 * sq));
|
|
1131
|
+
const gscale = kind === 'willow' ? 1.9 : kind === 'ring' ? 0.7 : 1;
|
|
1132
|
+
const pos = (k, sq, scale) => {
|
|
1133
|
+
const jitter = kind === 'ring' ? 0.03 : 0.25;
|
|
1134
|
+
const curl = kind === 'pinwheel' ? 2.4 * sq : 0;
|
|
1135
|
+
const ang = (k / N) * Math.PI * 2 + (H(100 + k) - 0.5) * jitter + curl;
|
|
1136
|
+
const len = R0 * (kind === 'ring' ? 0.9 : 0.6 + 0.5 * H(200 + k)) * scale;
|
|
1137
|
+
const sp = spreadAt(sq);
|
|
1138
|
+
return { x: top.x + Math.cos(ang) * len * sp, y: top.y + Math.sin(ang) * len * sp * (kind === 'ring' ? 0.55 : 1) + R0 * 0.9 * gscale * sq * sq };
|
|
1139
|
+
};
|
|
1140
|
+
const blink = kind === 'strobe' ? (Math.sin(now * 0.03) > 0.2 ? 1 : 0.15) : 1;
|
|
1141
|
+
for (let k = 0; k < N; k++) {
|
|
1142
|
+
const inner = k % 3 === 0;
|
|
1143
|
+
const c = inner ? c2 : c1;
|
|
1144
|
+
const scale = inner ? 0.62 : 1;
|
|
1145
|
+
const long = kind === 'chrysanthemum' || kind === 'willow';
|
|
1146
|
+
const steps = long ? 5 : 3, back = long ? 0.22 : 0.12;
|
|
1147
|
+
let prev = null;
|
|
1148
|
+
for (let q = steps; q >= 0; q--) {
|
|
1149
|
+
const p = pos(k, Math.max(0, s - (q / steps) * back), scale);
|
|
1150
|
+
if (prev) {
|
|
1151
|
+
const a = (1 - q / (steps + 1)) * blink;
|
|
1152
|
+
seg(prev, p, `rgba(${c},${(0.28 * fade * a).toFixed(3)})`, U * 0.2 * a * sh.size);
|
|
1153
|
+
seg(prev, p, `rgba(${c},${(0.95 * fade * a).toFixed(3)})`, U * 0.07 * a);
|
|
1154
|
+
}
|
|
1155
|
+
prev = p;
|
|
1156
|
+
}
|
|
1157
|
+
const tw = 0.55 + 0.45 * Math.sin(now * 0.02 + k * 1.7);
|
|
1158
|
+
const pop = kind === 'chrysanthemum' && s > 0.3 && hash01(fx.seed + k * 7 + Math.floor(now / 50)) < 0.12;
|
|
1159
|
+
disc(prev.x, prev.y, U * (pop ? 0.2 : 0.075) * scale, `rgba(255,255,255,${(fade * blink * (pop ? 1 : 0.9 * tw)).toFixed(3)})`);
|
|
1160
|
+
if (kind === 'crossette' && s > 0.42) {
|
|
1161
|
+
const s2 = (s - 0.42) / 0.58, f2 = Math.pow(1 - s2, 1.3) * gf;
|
|
1162
|
+
for (let m = 0; m < 4; m++) {
|
|
1163
|
+
const ang = (m / 4) * Math.PI * 2 + H(400 + k) * 1.5, d = R0 * 0.22 * (1 - Math.exp(-3 * s2));
|
|
1164
|
+
const e = { x: prev.x + Math.cos(ang) * d, y: prev.y + Math.sin(ang) * d + R0 * 0.2 * s2 * s2 };
|
|
1165
|
+
seg(prev, e, `rgba(${c2},${(0.8 * f2).toFixed(3)})`, U * 0.05);
|
|
1166
|
+
disc(e.x, e.y, U * 0.06, `rgba(255,255,255,${(0.9 * f2).toFixed(3)})`);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
disc(top.x, top.y + R0 * 0.25 * s, U * 0.16 * fade, `rgba(255,255,255,${(0.7 * fade).toFixed(3)})`);
|
|
1171
|
+
}
|
|
1172
|
+
ctx.lineWidth = lw;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// THE SCAN IS A LIGHT CURTAIN (operator, 2026-09-15: "Suggest a way to drastically improve the
|
|
1176
|
+
// visuals on the scan line effect. Again, create new render tools" -- then "do it"). The front
|
|
1177
|
+
// was a tight line on the floor that lit each tile for a frame. Now it is a vertical SHEET of
|
|
1178
|
+
// light standing on the board, floor to the chart's top, sweeping across: a hard white core
|
|
1179
|
+
// with soft cyan falloff on both faces, faint raster lines rippling down it on the clock, a bar
|
|
1180
|
+
// of light along its top edge it hangs from with glints at the ends, a glowing foot on the floor
|
|
1181
|
+
// with a green-cyan phosphor tail fading behind it, motes drifting in the beam, sparks thrown up
|
|
1182
|
+
// from the foot, and a thin volumetric haze inside its thickness. The tiles keep lighting through
|
|
1183
|
+
// fxAt as before, and the price line flares white where the curtain crosses it (scanFlare).
|
|
1184
|
+
function curtainEnds(fx, z) {
|
|
1185
|
+
// the front is the set of board points with x*dx + y*dy = p; its ends are where that line
|
|
1186
|
+
// leaves the board, at height z
|
|
1187
|
+
const n = fx.gridW, rows = fx.gridH, p = fxFront(fx);
|
|
1188
|
+
const c0x = n / 2 + (p - (n / 2) * fx.dx - (rows / 2) * fx.dy) * fx.dx;
|
|
1189
|
+
const c0y = rows / 2 + (p - (n / 2) * fx.dx - (rows / 2) * fx.dy) * fx.dy;
|
|
1190
|
+
const L = Math.hypot(n, rows);
|
|
1191
|
+
let a = null, b = null;
|
|
1192
|
+
for (let i = -60; i <= 60; i++) {
|
|
1193
|
+
const gx = c0x - fx.dy * (L * i) / 60, gy = c0y + fx.dx * (L * i) / 60;
|
|
1194
|
+
if (gx >= -0.01 && gx <= n + 0.01 && gy >= -0.01 && gy <= rows + 0.01) { if (!a) a = { x: gx, y: gy, z }; b = { x: gx, y: gy, z }; }
|
|
1195
|
+
}
|
|
1196
|
+
return a && b && (a.x !== b.x || a.y !== b.y) ? [a, b] : null;
|
|
1197
|
+
}
|
|
1198
|
+
// THE LINE UNDER THE CURTAIN: the stretch the scan is crossing flares white and rings out
|
|
1199
|
+
// either side, so the wire reads as struck by the sheet of light as it passes
|
|
1200
|
+
function scanFlare(ctx, pts, axes, view, lw) {
|
|
1201
|
+
const fx = view.fx, line = axes.line ?? [];
|
|
1202
|
+
if (!fx || fx.dx === 0 || line.length !== pts.length) return;
|
|
1203
|
+
const xf = fxFront(fx) * fx.dx; // the front's x on the board (dy is 0 on the price board)
|
|
1204
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
1205
|
+
const mx = (line[i].x + line[i + 1].x) / 2, d = Math.abs(mx - xf);
|
|
1206
|
+
const k = Math.exp(-(d * d) / 6);
|
|
1207
|
+
if (k < 0.03) continue;
|
|
1208
|
+
ctx.strokeStyle = `rgba(160,230,255,${(0.6 * k).toFixed(3)})`; ctx.lineWidth = lw * 14 * k;
|
|
1209
|
+
ctx.beginPath(); ctx.moveTo(pts[i].x, pts[i].y); ctx.lineTo(pts[i + 1].x, pts[i + 1].y); ctx.stroke();
|
|
1210
|
+
ctx.strokeStyle = `rgba(255,255,255,${(0.95 * k).toFixed(3)})`; ctx.lineWidth = lw * 4;
|
|
1211
|
+
ctx.beginPath(); ctx.moveTo(pts[i].x, pts[i].y); ctx.lineTo(pts[i + 1].x, pts[i + 1].y); ctx.stroke();
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
// THE BEAM'S ALTITUDE AND WIDTH, decided once. fxAt lights the swath while buildScene runs, and
|
|
1215
|
+
// drawScanCurtain draws the cone afterwards; if each worked its own numbers out they would drift
|
|
1216
|
+
// apart and the lit blocks would not be the ones under the light.
|
|
1217
|
+
function scanBeamGeom(view) {
|
|
1218
|
+
const price = (view.axes?.line?.length ?? 0) > 1;
|
|
1219
|
+
const top = price ? (view.axes?.zTop ?? 34) : 9;
|
|
1220
|
+
const line = view.axes?.line ?? [];
|
|
1221
|
+
const peak = line.length > 1 ? line.reduce((h, q) => Math.max(h, q.z ?? 0), 0) : top * 0.8;
|
|
1222
|
+
const clearance = Math.max(2.5, top * 0.1);
|
|
1223
|
+
const z = Math.max(peak + 1, Math.min(peak + clearance, top * 0.95));
|
|
1224
|
+
const spread = price ? Math.max(3, Math.min(11, z * 0.32)) : Math.max(2, Math.min(5, z * 0.45));
|
|
1225
|
+
return { price, top, z, spread };
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function drawScanCurtain(ctx, view, lw) {
|
|
1229
|
+
const fx = view.fx;
|
|
1230
|
+
if (!fx || fx.kind !== 'scan') return;
|
|
1231
|
+
const U = view.unit ?? 8, now = view.now ?? 0;
|
|
1232
|
+
const { price, top } = scanBeamGeom(view);
|
|
1233
|
+
const P = (q) => project(q.x, q.y, q.z, view);
|
|
1234
|
+
// NO FADE WHILE IT IS IN SIGHT (operator, 2026-09-15: "it still fades in and out ... with the
|
|
1235
|
+
// effects properly transitioning before being killed"). fx.amp is the shared envelope every
|
|
1236
|
+
// effect dims by at its start and end -- which for a thing that TRAVELS means it materialises in
|
|
1237
|
+
// the middle of the picture and dissolves there. The craft and its beam now hold full strength
|
|
1238
|
+
// and let POSITION do the transition: the front enters and leaves beyond the panel's edge
|
|
1239
|
+
// (fxFront's margin), so there is nothing on screen to fade.
|
|
1240
|
+
const amp = 1;
|
|
1241
|
+
void fx.amp;
|
|
1242
|
+
// IT MUST LEAVE THE VIEWPORT, NOT BLINK OUT AT THE EDGE (operator, 2026-09-15: "it disappears at
|
|
1243
|
+
// the edge of the screen. Have it move entirely off the viewport before you delete it").
|
|
1244
|
+
// curtainEnds returns null the moment the front stops crossing the board, so everything below --
|
|
1245
|
+
// the craft included -- simply stopped being drawn while it was still in frame. The craft's
|
|
1246
|
+
// position comes from the front itself now, which fxFront carries four units past each edge, so
|
|
1247
|
+
// the saucer flies in from off-board and out the far side under its own power.
|
|
1248
|
+
const fp = fxFront(fx);
|
|
1249
|
+
const fcx = fx.gridW / 2 + (fp - (fx.gridW / 2) * fx.dx - (fx.gridH / 2) * fx.dy) * fx.dx;
|
|
1250
|
+
const fcy = fx.gridH / 2 + (fp - (fx.gridW / 2) * fx.dx - (fx.gridH / 2) * fx.dy) * fx.dy;
|
|
1251
|
+
const ends = curtainEnds(fx, 0);
|
|
1252
|
+
const [A, B] = ends ?? [{ x: fcx, y: fcy, z: 0 }, { x: fcx, y: fcy, z: 0 }];
|
|
1253
|
+
|
|
1254
|
+
// A CONE, NOT A STACK OF SHEETS (operator, 2026-09-15: "that leaning portion and the
|
|
1255
|
+
// cross-hatching is not working. Can we make it a conical beam. instead, and do something
|
|
1256
|
+
// volumetric effect?").
|
|
1257
|
+
//
|
|
1258
|
+
// WHY THE OLD ONE HATCHED. The curtain was twenty-one PARALLEL flat quads (k = -10..10, each
|
|
1259
|
+
// offset k*0.5 along the sweep) with fourteen raster lines drawn across them. Under the oblique
|
|
1260
|
+
// camera parallel sheets project to parallelograms offset by a constant, so their translucent
|
|
1261
|
+
// edges land on near-parallel screen lines and read as a lattice -- a leaning cross-hatch. No
|
|
1262
|
+
// amount of alpha tuning fixes that; the geometry itself is a grid.
|
|
1263
|
+
//
|
|
1264
|
+
// A cone has no parallel edges. Its body is a fan of triangles that all converge on the apex,
|
|
1265
|
+
// so overlap -- and therefore density -- rises smoothly toward the axis on its own. That is the
|
|
1266
|
+
// volumetric part: nothing is faked with a gradient across a flat face, the light is thick where
|
|
1267
|
+
// the geometry is thick. Three nested shells give the falloff depth, a bright pool marks where
|
|
1268
|
+
// it lands, and motes drift inside the volume rather than on a sheet.
|
|
1269
|
+
const mx = fcx, my = fcy; // the front's own centre, on or off the board
|
|
1270
|
+
const half = Math.hypot(B.x - A.x, B.y - A.y) / 2; // reach along the front line
|
|
1271
|
+
const ax = -fx.dy, ay = fx.dx; // unit vector ALONG the front
|
|
1272
|
+
const sx = fx.dx, sy = fx.dy; // unit vector along the SWEEP
|
|
1273
|
+
// PROPORTIONS ARE THE WHOLE EFFECT (operator, 2026-09-15: the first cut "is fucked"). It put the
|
|
1274
|
+
// apex at top*1.5 -- z = 51 on the price board -- over a base barely 5 units across, which draws
|
|
1275
|
+
// 49px wide by 147px tall: a needle, not a beam. A spotlight reads as a spotlight when its height
|
|
1276
|
+
// and the width where it lands are comparable, so the apex sits just under the chart's own top
|
|
1277
|
+
// and the pool is wide enough to look like something landed in it.
|
|
1278
|
+
// ABOVE THE PEAK, not level with it (operator, 2026-09-15: "It needs to fly a bit higher than
|
|
1279
|
+
// the price line peak"). `top` is the chart's axis ceiling, which the line can come close to --
|
|
1280
|
+
// a craft at 0.9 of it flies through the high candles. So the ceiling is the taller of the axis
|
|
1281
|
+
// top and the line's own highest point, and the saucer clears it by 15%.
|
|
1282
|
+
// ABOVE THE PEAK, BUT UNDER THE CEILING (operator, 2026-09-15: "It needs to fly a bit higher
|
|
1283
|
+
// than the price line peak", then -- when I raised it past the chart's own top -- "It's flying at
|
|
1284
|
+
// the top of teh screen. don't do that ... set it lower with some above tolerances").
|
|
1285
|
+
//
|
|
1286
|
+
// Both mistakes were the same one: taking a single bound and scaling it. Too low and the craft
|
|
1287
|
+
// flies through the high candles; too high and it leaves the frame. It wants a clearance over the
|
|
1288
|
+
// line AND a hard ceiling under the chart's top, so it rides above the price wherever the price
|
|
1289
|
+
// happens to be and never reaches the edge.
|
|
1290
|
+
const apex = { x: mx, y: my, z: scanBeamGeom(view).z };
|
|
1291
|
+
const SPREAD = scanBeamGeom(view).spread;
|
|
1292
|
+
const RING = 44;
|
|
1293
|
+
// a point on the base ellipse: `f` scales the shell, `th` runs around it
|
|
1294
|
+
const ring = (th, f) => ({
|
|
1295
|
+
x: mx + Math.cos(th) * half * f * ax + Math.sin(th) * SPREAD * f * sx,
|
|
1296
|
+
y: my + Math.cos(th) * half * f * ay + Math.sin(th) * SPREAD * f * sy,
|
|
1297
|
+
z: 0,
|
|
1298
|
+
});
|
|
1299
|
+
const tri = (a, b, c, fill) => {
|
|
1300
|
+
const p0 = P(a), p1 = P(b), p2 = P(c);
|
|
1301
|
+
ctx.fillStyle = fill;
|
|
1302
|
+
ctx.beginPath(); ctx.moveTo(p0.x, p0.y); ctx.lineTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.closePath(); ctx.fill();
|
|
1303
|
+
};
|
|
1304
|
+
const seg = (a, b, col, w) => { const p = P(a), q = P(b); ctx.strokeStyle = col; ctx.lineWidth = Math.max(lw * 0.8, w); ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y); ctx.stroke(); };
|
|
1305
|
+
// SMOOTH BY LAYERING, NOT BY GRADIENT (operator, 2026-09-15: "gradient visible! We need smooth
|
|
1306
|
+
// fills. no gradient shit!"). A radial gradient bands on this rasteriser -- the rings are
|
|
1307
|
+
// plainly visible, and worst on a large fill like the beam's pool. Many nested flat discs do not
|
|
1308
|
+
// band: each is a plain rgba fill and the SUM is the curve. Same doctrine as bloom() in
|
|
1309
|
+
// agents.js, with far more steps because the pool is large.
|
|
1310
|
+
// one ring per ~1.2px, same rule as softStops: a fixed count bands on a large pool
|
|
1311
|
+
const soft = (x, y, r, rgb, a) => {
|
|
1312
|
+
const N = Math.max(16, Math.min(220, Math.round(Math.abs(r) / 1.2)));
|
|
1313
|
+
for (let i = N; i >= 1; i--) {
|
|
1314
|
+
ctx.fillStyle = `rgba(${rgb},${(a / N).toFixed(4)})`;
|
|
1315
|
+
ctx.beginPath(); ctx.arc(x, y, Math.max(0.5, r * (i / N)), 0, Math.PI * 2); ctx.fill();
|
|
1316
|
+
}
|
|
1317
|
+
};
|
|
1318
|
+
const disc = (x, y, r, fill) => { ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(x, y, Math.max(0.5, r), 0, Math.PI * 2); ctx.fill(); };
|
|
1319
|
+
|
|
1320
|
+
// off the board there is nothing to light, so the beam is not drawn -- but the craft still is,
|
|
1321
|
+
// which is what carries it out of the viewport
|
|
1322
|
+
const onBoard = !!ends;
|
|
1323
|
+
// --- the volume: nested cone shells, widest and faintest first
|
|
1324
|
+
// DIMMER (operator, 2026-09-15: "the brightness of the scanner cone in the block space view needs
|
|
1325
|
+
// to be toned down. It's too bright. Actually tone down beam brightness on both"): the shells,
|
|
1326
|
+
// the axis, the pool and the rim all at a little over half of what they were
|
|
1327
|
+
const DIM = 0.55;
|
|
1328
|
+
if (onBoard) for (const [f, a] of [[1, 0.018], [0.88, 0.019], [0.76, 0.021], [0.64, 0.023], [0.53, 0.026], [0.43, 0.029], [0.34, 0.033], [0.26, 0.038], [0.19, 0.044], [0.13, 0.05]]) {
|
|
1329
|
+
const fill = `rgba(120,220,255,${(a * DIM * amp).toFixed(3)})`;
|
|
1330
|
+
for (let i = 0; i < RING; i++) {
|
|
1331
|
+
tri(apex, ring((i / RING) * Math.PI * 2, f), ring(((i + 1) / RING) * Math.PI * 2, f), fill);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
// the hot axis: a thin cone down the middle, and the shaft itself
|
|
1335
|
+
if (onBoard) for (let i = 0; i < RING; i++) {
|
|
1336
|
+
tri(apex, ring((i / RING) * Math.PI * 2, 0.08), ring(((i + 1) / RING) * Math.PI * 2, 0.08), `rgba(235,250,255,${(0.10 * DIM * amp).toFixed(3)})`);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// --- where it lands: a pool on the floor, brightest at the axis
|
|
1340
|
+
if (onBoard) {
|
|
1341
|
+
const c = P({ x: mx, y: my, z: 0 });
|
|
1342
|
+
const r = U * Math.max(half, SPREAD) * 1.35;
|
|
1343
|
+
soft(c.x, c.y, r, '150,225,255', 1.5 * DIM * amp);
|
|
1344
|
+
soft(c.x, c.y, r * 0.42, '225,248,255', 1.1 * DIM * amp);
|
|
1345
|
+
}
|
|
1346
|
+
// the rim of the pool, so the cone reads as landing on something
|
|
1347
|
+
if (onBoard) for (let i = 0; i < RING; i++) {
|
|
1348
|
+
seg(ring((i / RING) * Math.PI * 2, 1), ring(((i + 1) / RING) * Math.PI * 2, 1), `rgba(170,235,255,${(0.28 * DIM * amp).toFixed(3)})`, U * 0.05);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// --- the source: the UFO, flying the beam (operator, 2026-09-15: "The top-down view in block
|
|
1352
|
+
// space coming from a sphere does not look good. Can we re-purpose the UFO"). A glowing ball
|
|
1353
|
+
// seen from directly above is just a blob -- it has no orientation, so top-down it reads as a
|
|
1354
|
+
// smudge where the light starts. The saucer has a rim and a dome, so it stays a craft at any
|
|
1355
|
+
// camera angle. Same craft the tractor beam flies (agents.js saucer()), so the board has one
|
|
1356
|
+
// ship rather than two unrelated light sources.
|
|
1357
|
+
{
|
|
1358
|
+
const p = P(apex);
|
|
1359
|
+
// (no glow round the craft: operator, 2026-09-15, "remove the light around the UFO. We have
|
|
1360
|
+
// enough lights for that effect")
|
|
1361
|
+
// TOP-DOWN (operator, 2026-09-15: "We need a top-down rendering of the UFO for this scene").
|
|
1362
|
+
// The board's camera looks DOWN, so a dome-and-rim craft drawn edge-on is a smear; from above
|
|
1363
|
+
// a saucer is concentric, and `squash` is the camera's own depth foreshortening so its circles
|
|
1364
|
+
// are the ellipses everything else on the board is drawn in.
|
|
1365
|
+
saucerAbove(ctx, p.x, p.y, U * 1.9, amp, view.oblique?.dy ?? 1, now * 0.0009);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// --- motes INSIDE the volume: placed by angle and depth, so they sit in the cone rather than
|
|
1369
|
+
// on a face. Each drifts up the shaft and is reseeded by its own hash, never by Math.random.
|
|
1370
|
+
// ...and only while the beam is on the board (operator, 2026-09-15: "I don't like how the
|
|
1371
|
+
// particles are kept when the scanner is off"): off it, the craft flies alone
|
|
1372
|
+
if (onBoard) for (let k = 0; k < 30; k++) {
|
|
1373
|
+
const H = (q) => hash01(fx.seed + 700 + k * 13 + q);
|
|
1374
|
+
const climb = ((H(1) + now * 0.00007 * (0.5 + H(2))) % 1); // 0 at the floor, 1 at the apex
|
|
1375
|
+
const th = H(3) * Math.PI * 2, rad = Math.sqrt(H(4)) * (1 - climb);
|
|
1376
|
+
const b = ring(th, rad);
|
|
1377
|
+
const q = { x: b.x + (apex.x - b.x) * climb, y: b.y + (apex.y - b.y) * climb, z: apex.z * climb };
|
|
1378
|
+
const p = P(q), tw = 0.5 + 0.5 * Math.sin(now * 0.008 + k * 1.7);
|
|
1379
|
+
disc(p.x, p.y, U * 0.06 * (1 - 0.5 * climb), `rgba(230,250,255,${(0.75 * tw * (1 - climb) * amp).toFixed(3)})`);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// --- sparks thrown from where it lands, as before: the beam is doing something to the board
|
|
1383
|
+
const STEP = 160, LIFE = 900, kNow = Math.floor(now / STEP);
|
|
1384
|
+
if (onBoard) for (let k = kNow - Math.ceil(LIFE / STEP); k <= kNow; k++) {
|
|
1385
|
+
if (hash01(fx.seed + k * 5) > 0.5) continue;
|
|
1386
|
+
const age = (now - k * STEP) / LIFE;
|
|
1387
|
+
if (age < 0 || age >= 1) continue;
|
|
1388
|
+
const t = hash01(fx.seed + k * 5 + 1);
|
|
1389
|
+
const q = { x: A.x + (B.x - A.x) * t, y: A.y + (B.y - A.y) * t, z: 0 };
|
|
1390
|
+
const p = P(q), rise = U * 2.2 * age * (1 - age) * 4, drift = (hash01(fx.seed + k * 5 + 2) - 0.5) * U * 1.2 * age;
|
|
1391
|
+
disc(p.x + drift, p.y - rise, U * 0.07 * (1 - age), `rgba(255,255,255,${(0.9 * (1 - age) * amp).toFixed(3)})`);
|
|
1392
|
+
}
|
|
1393
|
+
ctx.lineWidth = lw;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// THE BLACK HOLE (operator, 2026-09-15: "Simulate the price chart turning into a black hole",
|
|
1397
|
+
// reference svs.gsfc.nasa.gov/14576 -- NASA's visualisation: an orange accretion disk of hot gas
|
|
1398
|
+
// spiralling in, brighter on the side coming toward us, a photon ring inside it, the far side of
|
|
1399
|
+
// the disk lensed into an arch over the top of the shadow and a thinner one under it, the sky
|
|
1400
|
+
// behind bent into arcs). On the chart:
|
|
1401
|
+
// collapse 0 - 0.22 a point of darkness opens on the price line; the line bends round it
|
|
1402
|
+
// (lensPoints) and the candles nearest are swallowed (fxNow's head hides
|
|
1403
|
+
// them) as the horizon grows
|
|
1404
|
+
// accretion 0.22 - 0.82 the disk: the chart's own colours -- gold, and the candles' green and
|
|
1405
|
+
// red -- spiralling in, Keplerian (faster inside), Doppler-bright on
|
|
1406
|
+
// the approaching side; the far side arched over and under the shadow;
|
|
1407
|
+
// the photon ring; lensed starlight in arcs round the horizon; the
|
|
1408
|
+
// shadow, black, soft-edged
|
|
1409
|
+
// release 0.82 - 1 the hole shrinks to nothing, the disk spins away, the line straightens
|
|
1410
|
+
// and the candles come back
|
|
1411
|
+
// Screen-space, gradients and strokes under transforms, no clip or composite modes.
|
|
1412
|
+
function holeOnScreen(view) {
|
|
1413
|
+
const b = view.fx?.blackhole;
|
|
1414
|
+
if (!b) return null;
|
|
1415
|
+
const c = project(b.x, b.y, b.z, view);
|
|
1416
|
+
const U = view.unit ?? 8;
|
|
1417
|
+
return { c, U, rs: b.rs * U, grow: b.grow };
|
|
1418
|
+
}
|
|
1419
|
+
function lensPoints(pts, view) {
|
|
1420
|
+
const h = holeOnScreen(view);
|
|
1421
|
+
if (!h || h.rs <= 0.5) return pts;
|
|
1422
|
+
const rE = h.rs * 1.9;
|
|
1423
|
+
return pts.map((p) => {
|
|
1424
|
+
const dx = p.x - h.c.x, dy = p.y - h.c.y, r = Math.hypot(dx, dy) || 1e-6;
|
|
1425
|
+
const r2 = Math.sqrt(r * r + rE * rE);
|
|
1426
|
+
return { x: h.c.x + (dx / r) * r2, y: h.c.y + (dy / r) * r2 };
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
function drawBlackHole(ctx, view, lw) {
|
|
1430
|
+
const fx = view.fx;
|
|
1431
|
+
if (!fx || fx.kind !== 'blackhole') return;
|
|
1432
|
+
const h = holeOnScreen(view);
|
|
1433
|
+
if (!h || h.rs <= 0.5) return;
|
|
1434
|
+
const { c, U, rs, grow } = h;
|
|
1435
|
+
const now = view.now ?? 0;
|
|
1436
|
+
const disc = (x, y, r, fill) => { ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(x, y, Math.max(0.5, r), 0, Math.PI * 2); ctx.fill(); };
|
|
1437
|
+
const canT = typeof ctx.save === 'function' && typeof ctx.rotate === 'function' && typeof ctx.scale === 'function';
|
|
1438
|
+
// THE LOOK OF THE REFERENCE (operator, 2026-09-15, with NASA's still of the disk: "Is there any
|
|
1439
|
+
// way we can get it looking closer to this?"): the disk seen well above its plane, not edge-on;
|
|
1440
|
+
// a broad continuous body, white-yellow at its inner edge through orange to a dark red rim that
|
|
1441
|
+
// fades to nothing; FIBROUS -- hundreds of thin streaks spiralling inward over it, sheared by
|
|
1442
|
+
// the Keplerian flow, brightest on the approaching side; the shadow in the middle with a thin
|
|
1443
|
+
// photon ring, a gap, and the far side's thin lensed arch hugging it over the top.
|
|
1444
|
+
// ...then the Interstellar view (operator, with a second still: "need it to look more like this"):
|
|
1445
|
+
// the disk nearly edge-on, a thin band crossing in front of the shadow, the far side thrown
|
|
1446
|
+
// into a great arch over the top and a smaller one beneath
|
|
1447
|
+
const TILT = 0.04, SQUASH = 0.17;
|
|
1448
|
+
const inner = rs * 1.5, outer = rs * 5; // 6.4 horizons at the largest ("twice as large"), then 5 ("obscuring too much")
|
|
1449
|
+
const doppler = (ang) => 0.55 + 0.75 * Math.max(0, Math.cos(ang - Math.PI)); // the left side comes toward us: brightest at ang = pi
|
|
1450
|
+
const H = (k) => hash01(fx.seed + k);
|
|
1451
|
+
softStops(ctx, c.x, c.y, outer * 1.15, [[0, `rgba(255,170,70,${(0.22 * grow).toFixed(3)})`], [0.5, `rgba(255,130,50,${(0.08 * grow).toFixed(3)})`], [1, 'rgba(255,100,40,0)']]);
|
|
1452
|
+
const toScreen = (rr, ang) => {
|
|
1453
|
+
const px = rr * Math.cos(ang), py = rr * Math.sin(ang) * SQUASH;
|
|
1454
|
+
return { x: c.x + px * Math.cos(TILT) - py * Math.sin(TILT), y: c.y + px * Math.sin(TILT) + py * Math.cos(TILT) };
|
|
1455
|
+
};
|
|
1456
|
+
// the fibres: thin streaks spiralling inward, each sheared along the flow, in the disk's plane
|
|
1457
|
+
const streaks = [];
|
|
1458
|
+
for (let k = 0; k < 520; k++) {
|
|
1459
|
+
const hk = (q) => hash01(fx.seed + 400 + k * 13 + q);
|
|
1460
|
+
const r0 = inner + (outer - inner) * Math.pow(hk(1), 1.3);
|
|
1461
|
+
const omega = 0.0016 * Math.pow(inner / r0, 1.5);
|
|
1462
|
+
const a0 = hk(2) * Math.PI * 2 + now * omega;
|
|
1463
|
+
const span = (0.25 + 0.9 * hk(3)) * Math.pow(inner / r0, 0.4), heat = 1 - (r0 - inner) / (outer - inner);
|
|
1464
|
+
streaks.push({ r0, a0, span, heat, w: rs * (0.02 + 0.045 * hk(4)), bright: 0.4 + 0.6 * hk(5), kind: hk(6), k });
|
|
1465
|
+
}
|
|
1466
|
+
const drawStreaks = (far) => {
|
|
1467
|
+
if (!canT) return;
|
|
1468
|
+
ctx.save(); ctx.translate(c.x, c.y); ctx.rotate(TILT); ctx.scale(1, SQUASH); ctx.lineCap = 'round';
|
|
1469
|
+
for (const st of streaks) {
|
|
1470
|
+
const mid = st.a0 + st.span / 2, isFar = Math.sin(mid) < 0;
|
|
1471
|
+
if (isFar !== far) continue;
|
|
1472
|
+
const dop = doppler(mid), heat = st.heat;
|
|
1473
|
+
const col = st.kind < 0.06 ? '120,240,170' : st.kind < 0.12 ? '255,110,110' : heat > 0.7 ? '255,250,225' : heat > 0.35 ? '255,215,120' : '255,120,50';
|
|
1474
|
+
ctx.strokeStyle = `rgba(${col},${(0.7 * grow * st.bright * Math.min(1.2, dop) / 1.2 * (0.35 + 0.65 * heat)).toFixed(3)})`; ctx.lineWidth = st.w * (0.8 + 0.6 * heat);
|
|
1475
|
+
ctx.beginPath();
|
|
1476
|
+
for (let i = 0; i <= 10; i++) { const t = i / 10, rr = st.r0 * (1 - 0.07 * t), ang = st.a0 + st.span * t; const x = rr * Math.cos(ang), y = rr * Math.sin(ang); if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y); }
|
|
1477
|
+
ctx.stroke();
|
|
1478
|
+
}
|
|
1479
|
+
ctx.restore();
|
|
1480
|
+
};
|
|
1481
|
+
// --- THE FAR HALF, LIFTED INTO THE ARCH (operator, 2026-09-15: "the disc above is not merging
|
|
1482
|
+
// with the sides, and looks really bad" -- the arch was a circle drawn round the shadow while
|
|
1483
|
+
// the disk is an ellipse in its own plane, two surfaces that met by accident). The arch IS the
|
|
1484
|
+
// far half of the disk now: every point keeps its x on the disk (rr cos ang) and is raised over
|
|
1485
|
+
// the top by an amount that grows with its radius -- 1.1 horizons at the inner edge, 2.7 at the
|
|
1486
|
+
// outer -- so at the sides, where sin ang -> 0, the arch comes down exactly onto the flat band.
|
|
1487
|
+
// The second image under the hole is the same, mirrored, smaller and dimmer.
|
|
1488
|
+
const lift = (rr) => rs * (1.1 + 1.6 * (rr - inner) / (outer - inner));
|
|
1489
|
+
const farPt = (rr, ang, under) => {
|
|
1490
|
+
// the second image is a MIRROR, x as well as y: it turns with the band above it, not against
|
|
1491
|
+
// (2026-09-15: "the two discs are spinning in opposite directions")
|
|
1492
|
+
// CONTINUOUS INTO THE BAND (2026-09-15: "The transition between the two discs is not steady
|
|
1493
|
+
// or continuous"): the arch's height starts at the flat band's own slope (rr * SQUASH per
|
|
1494
|
+
// radian) and the lift takes over with |sin|^2.4, so the surface bends smoothly up from the
|
|
1495
|
+
// band rather than kinking where the two meet
|
|
1496
|
+
const sn = Math.abs(Math.sin(ang)), top = under ? rs * (1.05 + 0.45 * (rr - inner) / (outer - inner)) : lift(rr);
|
|
1497
|
+
const sx = Math.cos(ang) * rr * (under ? -0.78 : 1), sy = rr * SQUASH * sn + Math.max(0, top - rr * SQUASH) * Math.pow(sn, 2.4);
|
|
1498
|
+
return { x: c.x + sx * Math.cos(TILT) - sy * (under ? 1 : -1) * Math.sin(TILT), y: c.y + sx * Math.sin(TILT) + (under ? sy : -sy) * Math.cos(TILT) };
|
|
1499
|
+
};
|
|
1500
|
+
// ONE SET OF STRIPS FOR THE WHOLE DISK (operator, 2026-09-15, of a hard line across the middle:
|
|
1501
|
+
// "that harsh line does not work"): the near half was a radial gradient in the disk's plane
|
|
1502
|
+
// and the arch forty colour strips, and where they met at the midline the two disagreed. The
|
|
1503
|
+
// near half is the same strips now, laid on the flat ellipse, so the colour at any radius is
|
|
1504
|
+
// the same on both sides of the line and there is no line.
|
|
1505
|
+
const archBody = (mode) => {
|
|
1506
|
+
// 'ring': ONE CLOSED POLYGON PER STRIP round the whole disk -- the flat mapping over the near
|
|
1507
|
+
// half, the lifted mapping over the far -- so there is no interior edge at the midline at all
|
|
1508
|
+
// (2026-09-15: "still see harsh line bisecting" -- two fills sharing an edge draw that edge,
|
|
1509
|
+
// antialiased twice, however well their colours agree). 'under' is the second image alone.
|
|
1510
|
+
const under = mode === 'under';
|
|
1511
|
+
const STRIPS = under ? 16 : 40, dim = under ? 0.55 : 1;
|
|
1512
|
+
const at = (rr, ang) => (under ? farPt(rr, ang, true) : ang <= Math.PI ? toScreen(rr, ang) : farPt(rr, ang, false));
|
|
1513
|
+
const a0 = under ? Math.PI : 0, aSpan = under ? Math.PI : Math.PI * 2, N = under ? 24 : 72;
|
|
1514
|
+
// the second image FADES IN FROM THE MIDLINE (2026-09-15: "Still seeing the gradient" -- its
|
|
1515
|
+
// top edge lay exactly on the disk's midline): it is drawn in sectors, each at an alpha that
|
|
1516
|
+
// rises with |sin| from nothing at the line, so it has no edge there
|
|
1517
|
+
const SECT = under ? N : 1;
|
|
1518
|
+
for (let k = 0; k < STRIPS; k++) {
|
|
1519
|
+
const t0 = k / STRIPS, t1 = (k + 1) / STRIPS, r0 = inner + (outer - inner) * t0, r1 = inner + (outer - inner) * t1, heat = 1 - (t0 + t1) / 2;
|
|
1520
|
+
for (let sct = 0; sct < SECT; sct++) {
|
|
1521
|
+
const s0 = a0 + (aSpan * sct) / SECT, s1 = a0 + (aSpan * (sct + 1)) / SECT, n = under ? 1 : N;
|
|
1522
|
+
const fadeIn = under ? Math.pow(Math.abs(Math.sin((s0 + s1) / 2)), 0.8) : 1;
|
|
1523
|
+
ctx.beginPath();
|
|
1524
|
+
for (let i = 0; i <= n; i++) { const ang = s0 + ((s1 - s0) * i) / n; const p = at(r1, ang); if (i) ctx.lineTo(p.x, p.y); else ctx.moveTo(p.x, p.y); }
|
|
1525
|
+
for (let i = n; i >= 0; i--) { const ang = s0 + ((s1 - s0) * i) / n; const p = at(r0, ang); ctx.lineTo(p.x, p.y); }
|
|
1526
|
+
ctx.closePath();
|
|
1527
|
+
// bright, never brown: the rim stays a saturated orange rather than a dark red at low alpha
|
|
1528
|
+
const R = 255, G = Math.round(95 + 160 * Math.pow(heat, 0.7)), B = Math.round(25 + 200 * Math.pow(heat, 2.2));
|
|
1529
|
+
const alpha = grow * dim * fadeIn * (0.55 + 0.42 * Math.pow(heat, 0.8));
|
|
1530
|
+
// the Doppler side across the strip: a linear gradient, bright left, dark right
|
|
1531
|
+
const dg = typeof ctx.createLinearGradient === 'function' ? ctx.createLinearGradient(c.x - outer, 0, c.x + outer, 0) : null;
|
|
1532
|
+
if (dg && typeof dg.addColorStop === 'function') { dg.addColorStop(0, `rgba(${Math.min(255, R)},${Math.min(255, G + 50)},${Math.min(255, B + 70)},${alpha.toFixed(3)})`); dg.addColorStop(0.5, `rgba(${R},${G},${B},${(alpha * 0.85).toFixed(3)})`); dg.addColorStop(1, `rgba(${Math.round(R * 0.7)},${Math.round(G * 0.45)},${Math.round(B * 0.4)},${(alpha * 0.7).toFixed(3)})`); ctx.fillStyle = dg; }
|
|
1533
|
+
else ctx.fillStyle = `rgba(${R},${G},${B},${alpha.toFixed(3)})`;
|
|
1534
|
+
ctx.fill();
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
};
|
|
1538
|
+
const archStreaks = (under) => {
|
|
1539
|
+
ctx.lineCap = 'round';
|
|
1540
|
+
for (const st of streaks) {
|
|
1541
|
+
const mid = st.a0 + st.span / 2;
|
|
1542
|
+
if (Math.sin(mid) >= 0) continue; // the far half only
|
|
1543
|
+
const dop = doppler(mid), heat = st.heat;
|
|
1544
|
+
const col = st.kind < 0.06 ? '120,240,170' : st.kind < 0.12 ? '255,110,110' : heat > 0.7 ? '255,250,225' : heat > 0.35 ? '255,215,120' : '255,120,50';
|
|
1545
|
+
const fadeIn = under ? Math.pow(Math.abs(Math.sin(mid)), 0.8) : 1;
|
|
1546
|
+
ctx.strokeStyle = `rgba(${col},${(0.7 * grow * (under ? 0.5 : 1) * fadeIn * st.bright * Math.min(1.2, dop) / 1.2 * (0.35 + 0.65 * heat)).toFixed(3)})`; ctx.lineWidth = Math.max(lw * 0.8, st.w * (0.8 + 0.6 * heat) * 1.6);
|
|
1547
|
+
ctx.beginPath();
|
|
1548
|
+
for (let i = 0; i <= 10; i++) { const t = i / 10, rr = st.r0 * (1 - 0.07 * t), ang = st.a0 + st.span * t; const p = farPt(rr, ang, under); if (i) ctx.lineTo(p.x, p.y); else ctx.moveTo(p.x, p.y); }
|
|
1549
|
+
ctx.stroke();
|
|
1550
|
+
}
|
|
1551
|
+
};
|
|
1552
|
+
archBody('under'); archStreaks(true); // the second image, under
|
|
1553
|
+
// the shadow, then the whole disk over it -- the far half is lifted clear of the shadow, the
|
|
1554
|
+
// near half crosses in front of it -- then the fibres, then the photon ring over everything
|
|
1555
|
+
softStops(ctx, c.x, c.y, rs * 1.1, [[0, 'rgba(0,0,0,1)'], [0.88, 'rgba(0,0,0,1)'], [1, 'rgba(0,0,0,0)']]);
|
|
1556
|
+
archBody('ring');
|
|
1557
|
+
archStreaks(false);
|
|
1558
|
+
drawStreaks(false);
|
|
1559
|
+
ctx.strokeStyle = `rgba(255,245,215,${(0.95 * grow).toFixed(3)})`; ctx.lineWidth = Math.max(lw, rs * 0.045);
|
|
1560
|
+
ctx.beginPath(); ctx.arc(c.x, c.y, rs * 1.07, 0, Math.PI * 2); ctx.stroke();
|
|
1561
|
+
ctx.strokeStyle = `rgba(255,190,100,${(0.45 * grow).toFixed(3)})`; ctx.lineWidth = Math.max(lw * 2, rs * 0.14);
|
|
1562
|
+
ctx.beginPath(); ctx.arc(c.x, c.y, rs * 1.11, 0, Math.PI * 2); ctx.stroke();
|
|
1563
|
+
// --- lensed starlight: short arcs of white round the horizon, the sky behind bent into rings
|
|
1564
|
+
for (let k = 0; k < 26; k++) {
|
|
1565
|
+
const hk = (q) => hash01(fx.seed + 800 + k * 7 + q);
|
|
1566
|
+
const rr = rs * (1.15 + 1.6 * hk(1)), a0 = hk(2) * Math.PI * 2 + now * 0.00008 * (hk(3) - 0.5), span = 0.08 + 0.35 * hk(4);
|
|
1567
|
+
ctx.strokeStyle = `rgba(255,255,255,${(0.45 * grow * (0.4 + 0.6 * hk(5))).toFixed(3)})`; ctx.lineWidth = Math.max(lw * 0.8, U * 0.03);
|
|
1568
|
+
ctx.beginPath(); ctx.arc(c.x, c.y, rr, a0, a0 + span); ctx.stroke();
|
|
1569
|
+
}
|
|
1570
|
+
ctx.lineWidth = lw;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// A STAR'S GLINT (for the supernova's progenitor; 2026-09-15, operator: "The flashing dot as a
|
|
1574
|
+
// supernova looks bad. The star is blue-shifted before it blows up. Can we have a better glint
|
|
1575
|
+
// effect there?"). A soft halo, a hot core, four long diffraction spikes and two shorter
|
|
1576
|
+
// diagonals, each a gradient fading to its tip, all of it twinkling on the clock; `col` is the
|
|
1577
|
+
// star's colour and `f` its strength. Gradients only.
|
|
1578
|
+
function starGlint(ctx, x, y, size, col, f, now, lw) {
|
|
1579
|
+
if (f <= 0.003) return;
|
|
1580
|
+
const c = col.join(','), tw = 0.85 + 0.15 * Math.sin(now * 0.017) * Math.sin(now * 0.031 + 1);
|
|
1581
|
+
const grad = (r, stops) => { const g = typeof ctx.createRadialGradient === 'function' ? ctx.createRadialGradient(x, y, 0, x, y, r) : null; if (!g || typeof g.addColorStop !== 'function') return stops[0][1]; for (const [o, cc] of stops) g.addColorStop(o, cc); return g; };
|
|
1582
|
+
const lin = (x0, y0, x1, y1, stops) => { const g = typeof ctx.createLinearGradient === 'function' ? ctx.createLinearGradient(x0, y0, x1, y1) : null; if (!g || typeof g.addColorStop !== 'function') return stops[1][1]; for (const [o, cc] of stops) g.addColorStop(o, cc); return g; };
|
|
1583
|
+
ctx.fillStyle = grad(size * 3, [[0, `rgba(255,255,255,${(0.9 * f * tw).toFixed(3)})`], [0.12, `rgba(${c},${(0.7 * f * tw).toFixed(3)})`], [0.4, `rgba(${c},${(0.18 * f).toFixed(3)})`], [1, `rgba(${c},0)`]]);
|
|
1584
|
+
ctx.beginPath(); ctx.arc(x, y, size * 3, 0, Math.PI * 2); ctx.fill();
|
|
1585
|
+
const spikes = [[0, 1, 0.09], [Math.PI / 2, 1, 0.09], [Math.PI / 4, 0.45, 0.05], [-Math.PI / 4, 0.45, 0.05]];
|
|
1586
|
+
for (const [ang, len, w] of spikes) {
|
|
1587
|
+
const L = size * 9 * len * tw, x0 = x - Math.cos(ang) * L, y0 = y - Math.sin(ang) * L, x1 = x + Math.cos(ang) * L, y1 = y + Math.sin(ang) * L;
|
|
1588
|
+
ctx.strokeStyle = lin(x0, y0, x1, y1, [[0, `rgba(${c},0)`], [0.5, `rgba(255,255,255,${(0.85 * f * tw).toFixed(3)})`], [1, `rgba(${c},0)`]]);
|
|
1589
|
+
ctx.lineWidth = Math.max(lw * 0.8, size * w); ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
|
|
1590
|
+
ctx.strokeStyle = lin(x0, y0, x1, y1, [[0, `rgba(${c},0)`], [0.5, `rgba(${c},${(0.4 * f).toFixed(3)})`], [1, `rgba(${c},0)`]]);
|
|
1591
|
+
ctx.lineWidth = Math.max(lw * 2, size * w * 3.5); ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
|
|
1592
|
+
}
|
|
1593
|
+
ctx.fillStyle = `rgba(255,255,255,${(f * tw).toFixed(3)})`; ctx.beginPath(); ctx.arc(x, y, size * 0.45, 0, Math.PI * 2); ctx.fill();
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
// A VOLUME OF GAS (for the supernova's debris, the fireworks' smoke, the pulsar's wind; 2026-09-15).
|
|
1597
|
+
// `n` blobs with fixed places in the unit sphere -- radius biased toward the rim, so the limb reads
|
|
1598
|
+
// brighter, the way a shell does when seen through -- projected with the sphere's depth kept: the
|
|
1599
|
+
// blobs are drawn back to front, the back ones darker, the front ones paler and hotter. The whole
|
|
1600
|
+
// thing expands homologously (every blob's distance in proportion to its own), grows, boils
|
|
1601
|
+
// slowly on the clock, and cools from `rc` toward `dark` as `f` runs 0..1. No strokes: gradients.
|
|
1602
|
+
// ORGANIC, NOT CIRCULAR (operator, 2026-09-15: "Everything is too circular RN. It needs to look
|
|
1603
|
+
// more organic like the video"): the blobs gather in CLUMPS rather than spreading evenly, each is
|
|
1604
|
+
// an ELLIPSE stretched along its own radial line (a streak of gas blown outward) and turned to
|
|
1605
|
+
// it, and the sphere's silhouette is LUMPY -- every blob's radius carries a low-order wobble by
|
|
1606
|
+
// its direction, so the cloud has lobes and bays rather than an outline. `colorAt(front, f, H)`,
|
|
1607
|
+
// if given, decides each blob's colour from its depth, the run and its own hashes.
|
|
1608
|
+
function gasCloud(ctx, cx, cy, shell, f, now, seed, bright, rc, grad, disc, n = 150, dark = [90, 20, 25], colorAt = null, blobScale = 1, stretchMax = 2.4) {
|
|
1609
|
+
const blobs = [];
|
|
1610
|
+
const clumps = Math.max(4, Math.round(n / 14));
|
|
1611
|
+
const cdir = [];
|
|
1612
|
+
for (let q = 0; q < clumps; q++) { const H = (k) => hash01(seed + 900 + q * 17 + k); cdir.push({ th: H(1) * Math.PI * 2, ph: Math.acos(2 * H(2) - 1), w: 0.35 + 0.5 * H(3) }); }
|
|
1613
|
+
const lumpA = hash01(seed + 77) * Math.PI * 2, lumpB = hash01(seed + 78) * Math.PI * 2;
|
|
1614
|
+
for (let k = 0; k < n; k++) {
|
|
1615
|
+
const H = (q) => hash01(seed + 5000 + k * 37 + q);
|
|
1616
|
+
const cl = cdir[Math.floor(H(6) * clumps)];
|
|
1617
|
+
// a gaussian-ish scatter round the clump's direction, wider for some clumps
|
|
1618
|
+
const th = cl.th + (H(2) + H(7) - 1) * cl.w * 1.6, ph = Math.max(0.05, Math.min(Math.PI - 0.05, cl.ph + (H(3) + H(8) - 1) * cl.w * 1.2));
|
|
1619
|
+
const lump = 1 + 0.22 * Math.sin(3 * th + lumpA) * Math.sin(2 * ph + lumpB) + 0.14 * Math.sin(5 * th - lumpB) + 0.1 * Math.cos(4 * ph + lumpA);
|
|
1620
|
+
const rho = (0.3 + 0.7 * Math.sqrt(H(1))) * lump;
|
|
1621
|
+
blobs.push({ x: rho * Math.sin(ph) * Math.cos(th), y: rho * Math.sin(ph) * Math.sin(th), z: rho * Math.cos(ph), s: 0.16 + 0.2 * H(4), w: H(5) * Math.PI * 2, stretch: 1 + (stretchMax - 1) * (0.3 + 0.7 * H(9)), H, k });
|
|
1622
|
+
}
|
|
1623
|
+
blobs.sort((a, b) => a.z - b.z); // back to front
|
|
1624
|
+
const canTransform = typeof ctx.save === 'function' && typeof ctx.rotate === 'function' && typeof ctx.scale === 'function';
|
|
1625
|
+
for (const b of blobs) {
|
|
1626
|
+
const wob = 0.035 * Math.sin(now * 0.0013 + b.w) + 0.02 * Math.sin(now * 0.0029 + b.k);
|
|
1627
|
+
const x = cx + (b.x + wob) * shell, y = cy + (b.y - wob * 0.6) * shell * 0.85;
|
|
1628
|
+
const r = shell * b.s * (0.8 + 0.7 * f) * blobScale;
|
|
1629
|
+
const front = (b.z + 1) / 2; // 0 at the back, 1 at the front
|
|
1630
|
+
let col, core;
|
|
1631
|
+
if (colorAt) { col = colorAt(front, f, b.H); core = col.map((v) => Math.round(v + (255 - v) * 0.45)); }
|
|
1632
|
+
else {
|
|
1633
|
+
const heat = Math.max(0, Math.min(1, 0.5 + 0.55 * front - 0.45 * f));
|
|
1634
|
+
col = rc.map((v, i) => Math.round(dark[i] + (v - dark[i]) * heat));
|
|
1635
|
+
core = col.map((v) => Math.round(v + (255 - v) * 0.5 * heat));
|
|
1636
|
+
}
|
|
1637
|
+
const a = bright * (0.32 + 0.3 * front) * (1 - 0.25 * f);
|
|
1638
|
+
if (a < 0.004) continue;
|
|
1639
|
+
const fill = (rr) => grad(0, 0, rr, [[0, `rgba(${core.join(',')},${a.toFixed(3)})`], [0.45, `rgba(${col.join(',')},${(a * 0.7).toFixed(3)})`], [1, `rgba(${col.join(',')},0)`]]);
|
|
1640
|
+
if (canTransform) {
|
|
1641
|
+
const ang = Math.atan2(y - cy, x - cx); // stretched along its own radial line
|
|
1642
|
+
ctx.save(); ctx.translate(x, y); ctx.rotate(ang); ctx.scale(b.stretch, 1 / Math.sqrt(b.stretch));
|
|
1643
|
+
ctx.fillStyle = grad(0, 0, r, [[0, `rgba(${core.join(',')},${a.toFixed(3)})`], [0.45, `rgba(${col.join(',')},${(a * 0.7).toFixed(3)})`], [1, `rgba(${col.join(',')},0)`]]);
|
|
1644
|
+
ctx.beginPath(); ctx.arc(0, 0, Math.max(0.5, r), 0, Math.PI * 2); ctx.fill();
|
|
1645
|
+
ctx.restore();
|
|
1646
|
+
} else softStops(ctx, x, y, r, [[0, `rgba(${core.join(',')},${a.toFixed(3)})`], [0.45, `rgba(${col.join(',')},${(a * 0.7).toFixed(3)})`], [1, `rgba(${col.join(',')},0)`]]);
|
|
1647
|
+
void fill;
|
|
1648
|
+
}
|
|
1649
|
+
// the limb: a soft brightening at the sphere's edge
|
|
1650
|
+
const R = shell * 1.02;
|
|
1651
|
+
softStops(ctx, cx, cy, R, [[0, `rgba(${rc.join(',')},0)`], [0.78, `rgba(${rc.join(',')},0)`], [0.92, `rgba(${rc.join(',')},${(0.16 * bright).toFixed(3)})`], [1, `rgba(${rc.join(',')},0)`]]);
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
// A SUPERNOVA (operator, 2026-09-15: "build the supernova" -- the solar flare only ever lit a cube
|
|
1655
|
+
// and its neighbours, and on a chart of candles that was one candle blinking). MODELLED ON NASA
|
|
1656
|
+
// GODDARD'S ANIMATION (svs.gsfc.nasa.gov/20413, the operator's reference, 2026-09-15: "Look at
|
|
1657
|
+
// this video of the explosive supernova. Model something similar"): a star, a blinding flash
|
|
1658
|
+
// that whites out the whole picture, a turbulent debris cloud of knots and filaments expanding
|
|
1659
|
+
// and reddening as it dims, and, as the cloud thins, a PULSAR -- a point of light pulsing at the
|
|
1660
|
+
// centre inside a small blue pulsar-wind nebula that grows. Drawn on both boards, on the same
|
|
1661
|
+
// star fxAt lights (fxHash(seed + 7, 8)), whose shockwave throws the candles it crosses:
|
|
1662
|
+
// ignition 0 - 0.12 the star swells white-hot, crackle rising over it
|
|
1663
|
+
// breakout 0.12 - 0.34 the white-out: the whole picture goes white and comes back (no flare)
|
|
1664
|
+
// debris 0.14 - 1 the cloud: a volume of gas (gasCloud), a sphere of soft blobs
|
|
1665
|
+
// carried out by the expansion, white-gold, orange, then deep red,
|
|
1666
|
+
// the shock band at its leading edge
|
|
1667
|
+
// pulsar 0.45 - 1 the point pulsing at the centre, its blue nebula growing
|
|
1668
|
+
// Everything is a function of fx.u, the seed and the clock; the run fades in its last tenth.
|
|
1669
|
+
// a radial-gradient maker and a disc filler bound to a given context (gasCloud takes both)
|
|
1670
|
+
function gradFor(c) {
|
|
1671
|
+
return (x, y, r, stops) => {
|
|
1672
|
+
const g = typeof c.createRadialGradient === 'function' ? c.createRadialGradient(x, y, 0, x, y, r) : null;
|
|
1673
|
+
if (!g || typeof g.addColorStop !== 'function') return stops[0][1];
|
|
1674
|
+
for (const [o, col] of stops) g.addColorStop(o, col);
|
|
1675
|
+
return g;
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
function discFor(c) { return (x, y, r, fill) => { c.fillStyle = fill; c.beginPath(); c.arc(x, y, Math.max(0.5, r), 0, Math.PI * 2); c.fill(); }; }
|
|
1679
|
+
// a half-size offscreen layer matching the board's canvas and transform, cleared for this frame;
|
|
1680
|
+
// null where there is no real canvas to make one from
|
|
1681
|
+
function plumeLayer(ctx, fx) {
|
|
1682
|
+
const cv = ctx.canvas;
|
|
1683
|
+
if (!cv || !cv.width || typeof ctx.getTransform !== 'function' || typeof ctx.drawImage !== 'function' || typeof document === 'undefined') return null;
|
|
1684
|
+
const w = Math.ceil(cv.width / 2), h = Math.ceil(cv.height / 2);
|
|
1685
|
+
let L = fx._plumes;
|
|
1686
|
+
if (!L || L.canvas.width !== w || L.canvas.height !== h) {
|
|
1687
|
+
const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h;
|
|
1688
|
+
const c2 = canvas.getContext('2d');
|
|
1689
|
+
if (!c2) return null;
|
|
1690
|
+
L = fx._plumes = { canvas, ctx: c2 };
|
|
1691
|
+
}
|
|
1692
|
+
const m = ctx.getTransform();
|
|
1693
|
+
L.ctx.setTransform(1, 0, 0, 1, 0, 0); L.ctx.clearRect(0, 0, w, h);
|
|
1694
|
+
L.ctx.setTransform(m.a / 2, m.b / 2, m.c / 2, m.d / 2, m.e / 2, m.f / 2);
|
|
1695
|
+
return L;
|
|
1696
|
+
}
|
|
1697
|
+
function drawSupernova(ctx, view, lw) {
|
|
1698
|
+
const fx = view.fx;
|
|
1699
|
+
if (!fx || fx.kind !== 'flare') return;
|
|
1700
|
+
const U = view.unit ?? 8, u = fx.u, now = view.now ?? 0;
|
|
1701
|
+
const line = view.axes?.line, price = line?.length > 1;
|
|
1702
|
+
const sx = fxHash(fx.seed + 7) * fx.gridW, sy = price ? (view.axes.y ?? fx.gridH / 2) : fxHash(fx.seed + 8) * fx.gridH;
|
|
1703
|
+
let sz = 3;
|
|
1704
|
+
if (price) { // the star sits on the price line at that hour
|
|
1705
|
+
let best = line[0]; for (const p of line) if (Math.abs(p.x - sx) < Math.abs(best.x - sx)) best = p;
|
|
1706
|
+
sz = best.z;
|
|
1707
|
+
}
|
|
1708
|
+
const c = project(sx, sy, sz, view);
|
|
1709
|
+
// BOUNDED (operator, 2026-09-15: "supernova and black hole nebula effects take up too much
|
|
1710
|
+
// screen space ... it's obscuring too much"): the shell was sized at half the board and the
|
|
1711
|
+
// white-out was the whole frame, which on the Kiosk's panel was the whole panel; the shell is
|
|
1712
|
+
// about a third of the board now and the flash a soft disc round the star
|
|
1713
|
+
// ...and never more than 7% of the board's width, whatever the board (the Kiosk's block-space
|
|
1714
|
+
// panel is 44 units across and small on screen: at 4.5 units the cloud covered most of it)
|
|
1715
|
+
const R0 = U * boundedRadius(price ? 3.4 : 4.5, 0.07, fx.gridW);
|
|
1716
|
+
const gf = Math.min(1, (1 - u) / 0.1);
|
|
1717
|
+
// still a real gradient maker, because gasCloud() takes one as a callback for its puffs; every
|
|
1718
|
+
// FILL drawn directly by this function is layered flat discs now (softStops)
|
|
1719
|
+
const grad = (x, y, r, stops) => {
|
|
1720
|
+
const g = typeof ctx.createRadialGradient === 'function' ? ctx.createRadialGradient(x, y, 0, x, y, r) : null;
|
|
1721
|
+
if (!g || typeof g.addColorStop !== 'function') return stops[0][1];
|
|
1722
|
+
for (const [o, col] of stops) g.addColorStop(o, col);
|
|
1723
|
+
return g;
|
|
1724
|
+
};
|
|
1725
|
+
const disc = (x, y, r, fill) => { ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(x, y, Math.max(0.5, r), 0, Math.PI * 2); ctx.fill(); };
|
|
1726
|
+
const mixc = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * t));
|
|
1727
|
+
// the cloud's colour with the run, as in the reference (operator: "the cloud is white at the
|
|
1728
|
+
// start, shifting towards blue, then purple/violet"): white, blue, then violet
|
|
1729
|
+
const cool = Math.max(0, Math.min(1, (u - 0.14) / 0.86));
|
|
1730
|
+
const rc = cool < 0.3 ? mixc([255, 255, 255], [150, 195, 255], cool / 0.3) : mixc([150, 195, 255], [175, 110, 255], (cool - 0.3) / 0.7);
|
|
1731
|
+
const rcs = rc.join(',');
|
|
1732
|
+
// --- ignition: a white nebula, not a ball (operator, 2026-09-15: "Don't use a white ball visual
|
|
1733
|
+
// at the beginning of the animation. Use white nebula effects"): a small cloud of white gas
|
|
1734
|
+
// gathering and brightening round the star, blue-shifting as the blast nears
|
|
1735
|
+
if (u < 0.14) {
|
|
1736
|
+
const f = Math.min(1, u / 0.12), ease = f * f;
|
|
1737
|
+
const col = [Math.round(255 - 70 * ease), Math.round(255 - 30 * ease), 255];
|
|
1738
|
+
gasCloud(ctx, c.x, c.y, R0 * (0.35 + 0.9 * ease), 0.2, now, fx.seed + 555, 0.25 + 0.75 * ease, col, grad, disc, 70, [120, 150, 230]);
|
|
1739
|
+
}
|
|
1740
|
+
// --- THE DEBRIS CLOUD AS A VOLUME OF GAS (operator, 2026-09-15: "Overall it needs to look
|
|
1741
|
+
// more like the NASA video. I don't think the ejecta/fireworks is working well ... The thin
|
|
1742
|
+
// white lines shifting at the edges does not hold up well"). No lines of any kind. The cloud
|
|
1743
|
+
// is gasCloud: a sphere of soft gradient blobs, each with a fixed place in the unit sphere
|
|
1744
|
+
// (denser toward the rim, so the limb is brighter, as a shell seen through is), carried
|
|
1745
|
+
// outward by the expansion -- every blob at a radius in proportion to its own, as a real
|
|
1746
|
+
// remnant expands -- growing as it goes, the front ones hotter and paler than the ones behind,
|
|
1747
|
+
// the whole cooling white-gold -> orange -> red as it dims, its surface boiling on the clock.
|
|
1748
|
+
const band = (r, w, col, a) => {
|
|
1749
|
+
if (r <= 0 || a <= 0.003) return;
|
|
1750
|
+
const R = r + w;
|
|
1751
|
+
softStops(ctx, c.x, c.y, R, [[0, `rgba(${col},0)`], [Math.max(0, (r - w) / R), `rgba(${col},0)`], [Math.max(0, (r - w * 0.35) / R), `rgba(${col},${a.toFixed(3)})`], [r / R, `rgba(240,245,255,${(a * 0.8).toFixed(3)})`], [Math.min(1, (r + w * 0.5) / R), `rgba(${col},${(a * 0.6).toFixed(3)})`], [1, `rgba(${col},0)`]]);
|
|
1752
|
+
};
|
|
1753
|
+
if (u >= 0.14) {
|
|
1754
|
+
const f = (u - 0.14) / 0.86;
|
|
1755
|
+
const shell = R0 * (0.5 + 4.2 * (1 - Math.exp(-1.3 * f))); // the cloud's radius: slower than the first cut (operator: "the cloud dispersion is too fast")
|
|
1756
|
+
// LINGERING (operator, 2026-09-15: "make the violet nebula linger longer into the blink
|
|
1757
|
+
// phase"): the cloud holds full weight until four fifths of the run -- well into the pulsar's
|
|
1758
|
+
// beat -- and only thins over the last fifth, so the purple stays round the blinking star
|
|
1759
|
+
const bright = (f < 0.08 ? f / 0.08 : f < 0.8 ? 1 : Math.pow(1 - (f - 0.8) / 0.2, 1.2)) * gf;
|
|
1760
|
+
// THE MORPHING (operator, 2026-09-15: "as it shifts from blue to white initially, then the
|
|
1761
|
+
// purples and violets start appearing, as the blue clouds continue to disperse ahead of
|
|
1762
|
+
// everything else"): three clouds. The BLUE one is out in front, expanding fastest and
|
|
1763
|
+
// thinning first; the MAIN one behind it is white at the start, and violet grows into it
|
|
1764
|
+
// patch by patch, each blob turning on its own schedule, until the whole is purple; the
|
|
1765
|
+
// INNER one is the deep violet heart that shows as the others thin.
|
|
1766
|
+
const blueShell = R0 * (0.6 + 4.6 * (1 - Math.exp(-1.9 * f)));
|
|
1767
|
+
const blueBright = bright * 0.7 * Math.pow(1 - f, 1.6);
|
|
1768
|
+
// SMOOTH (operator, 2026-09-15: "We see too much of the radial texture. Lets try to smoothen
|
|
1769
|
+
// that up"): more and larger blobs at a lower weight, barely stretched, so they melt into one
|
|
1770
|
+
// another instead of reading as radial streaks
|
|
1771
|
+
gasCloud(ctx, c.x, c.y, blueShell, f, now, fx.seed + 31337, blueBright * 0.32, [150, 195, 255], grad, disc, 140, [70, 110, 220], (front, ff, H) => {
|
|
1772
|
+
const t = Math.min(1, ff / 0.5);
|
|
1773
|
+
return [Math.round(215 - 65 * t), Math.round(235 - 40 * t), 255].map((v, i) => Math.round(v * (0.75 + 0.25 * front) + (i === 2 ? 0 : 0)));
|
|
1774
|
+
}, 0.85, 1.25);
|
|
1775
|
+
// THIN, NOT SOLID (2026-09-15, the Kiosk: "far too much solid white"): overlapping discs sum
|
|
1776
|
+
// toward opaque however faint each is -- 320 blobs saturated, and 170 at a third still did --
|
|
1777
|
+
// so the cloud is ninety blobs at an eighth of the weight, which peaks near half opacity at the
|
|
1778
|
+
// centre and reads as gas the chart shows through
|
|
1779
|
+
gasCloud(ctx, c.x, c.y, shell, f, now, fx.seed, bright * 0.17, rc, grad, disc, 220, [60, 30, 120], (front, ff, H) => {
|
|
1780
|
+
// SOONER (operator, 2026-09-15: "we need more of the third stage purple and violet nebula
|
|
1781
|
+
// becoming prominent as it starts the blink phase"): the patches turn violet between a tenth
|
|
1782
|
+
// and four tenths of the run, so the cloud is mostly purple by the time the pulsar wakes
|
|
1783
|
+
const start = 0.1 + 0.3 * H(11), on = Math.max(0, Math.min(1, (ff - start) / 0.2)); // when this patch turns violet
|
|
1784
|
+
const white = [225, 235, 255], violet = [170, 105, 255], deep = [95, 45, 170]; // blue-white, not solid white (2026-09-15)
|
|
1785
|
+
const c1 = white.map((v, i) => v + (violet[i] - v) * on);
|
|
1786
|
+
const shade = 0.55 + 0.45 * front;
|
|
1787
|
+
return c1.map((v, i) => Math.round(deep[i] + (v - deep[i]) * shade));
|
|
1788
|
+
}, 0.85, 1.25);
|
|
1789
|
+
if (f > 0.2) {
|
|
1790
|
+
// the violet heart, sooner and heavier (the same request): rising from a fifth of the run,
|
|
1791
|
+
// full by the blink, and a second wider violet cloud behind it that keeps growing
|
|
1792
|
+
const inner = Math.min(1, (f - 0.2) / 0.18);
|
|
1793
|
+
gasCloud(ctx, c.x, c.y, shell * 0.7, f, now, fx.seed + 777, bright * 0.3 * inner, [160, 90, 255], grad, disc, 90, [60, 25, 130], null, 0.9, 1.25);
|
|
1794
|
+
gasCloud(ctx, c.x, c.y, shell * 1.05, f, now, fx.seed + 778, bright * 0.16 * inner, [175, 115, 255], grad, disc, 90, [70, 30, 140], null, 1.1, 1.3);
|
|
1795
|
+
}
|
|
1796
|
+
// and the interior glow: the cloud lit from within
|
|
1797
|
+
softStops(ctx, c.x, c.y, shell * 0.8, [[0, `rgba(${rcs},${(0.1 * bright).toFixed(3)})`], [0.6, `rgba(${rcs},${(0.05 * bright).toFixed(3)})`], [1, `rgba(${rcs},0)`]]);
|
|
1798
|
+
// the shock band at the cloud's leading edge, soft, in the cloud's colour
|
|
1799
|
+
if (u < 0.6) {
|
|
1800
|
+
const fr = (u - 0.14) / 0.46, r = R0 * 4.5 * (1 - Math.pow(1 - fr, 2.2));
|
|
1801
|
+
softStops(ctx, c.x, c.y, r, [[0, 'rgba(235,240,255,0)'], [0.7, `rgba(235,240,255,${(0.05 * (1 - fr) * gf).toFixed(3)})`], [1, `rgba(240,245,255,${(0.14 * (1 - fr) * gf).toFixed(3)})`]]);
|
|
1802
|
+
band(r, R0 * (0.3 + 0.6 * (1 - fr)), rcs, 0.4 * (1 - fr) * gf);
|
|
1803
|
+
}
|
|
1804
|
+
// a wide pool of the cloud's colour on everything near
|
|
1805
|
+
softStops(ctx, c.x, c.y, shell * 1.9, [[0, `rgba(${rcs},${(0.08 * bright).toFixed(3)})`], [1, `rgba(${rcs},0)`]]);
|
|
1806
|
+
}
|
|
1807
|
+
// --- the breakout: the whole picture goes white and comes back
|
|
1808
|
+
if (u >= 0.12 && u < 0.4) {
|
|
1809
|
+
const t = (u - 0.12) / 0.28, f = t < 0.1 ? t / 0.1 : Math.pow(1 - (t - 0.1) / 0.9, 1.5);
|
|
1810
|
+
// THE FLASH ITSELF (operator, 2026-09-15: "It's missing the bright white flash!"): a bright,
|
|
1811
|
+
// brief one -- full white at the heart for an instant, gone in a third of the breakout -- so
|
|
1812
|
+
// it reads as a flash, not as a solid glow that sits on the Kiosk's panel
|
|
1813
|
+
const fl = t < 0.04 ? t / 0.04 : t < 0.12 ? 1 : Math.pow(Math.max(0, 1 - (t - 0.12) / 0.3), 2);
|
|
1814
|
+
// WHITE-HOT (operator, 2026-09-15: "We need to add the white-hot-flash to the explosion
|
|
1815
|
+
// again"): the heart goes fully white and holds for a moment, and a wide veil of light
|
|
1816
|
+
// washes twice as far out for the same instant -- bounded to the star's neighbourhood, so
|
|
1817
|
+
// the Kiosk's panel flashes bright but never sits solid
|
|
1818
|
+
if (fl > 0) {
|
|
1819
|
+
const V = R0 * 8;
|
|
1820
|
+
disc(c.x, c.y, V, grad(c.x, c.y, V, [[0, `rgba(255,255,252,${(0.45 * fl).toFixed(3)})`], [0.4, `rgba(255,252,246,${(0.18 * fl).toFixed(3)})`], [1, 'rgba(255,248,240,0)']]));
|
|
1821
|
+
}
|
|
1822
|
+
// a soft flash round the star, not the whole frame -- and NOT SOLID (operator, 2026-09-15, via
|
|
1823
|
+
// his wife on the Kiosk: "far too much solid white"): a modest disc, at most half opaque at its
|
|
1824
|
+
// heart, thin by three shells out, and the white gas thrown with it at a third of the weight
|
|
1825
|
+
const W = R0 * 4;
|
|
1826
|
+
// PRESENCE BACK (operator, 2026-09-15: "We've removed so much of the initial white nebula
|
|
1827
|
+
// emission that it's lost much of its presence and intensity") -- now that the discs add up
|
|
1828
|
+
// honestly, the flash and the white gas can carry weight again without going solid
|
|
1829
|
+
disc(c.x, c.y, W, grad(c.x, c.y, W, [[0, `rgba(255,255,252,${Math.min(1, 0.3 * f + 1.0 * fl).toFixed(3)})`], [0.35, `rgba(255,252,244,${Math.min(1, 0.15 * f + 0.6 * fl).toFixed(3)})`], [0.7, `rgba(255,248,235,${(0.05 * f + 0.06 * fl).toFixed(3)})`], [1, 'rgba(255,245,230,0)']]));
|
|
1830
|
+
// MORE GAS OFF THE FLASH (operator, 2026-09-15: "Opening white flash needs more dispersing
|
|
1831
|
+
// nebula gas"): two clouds thrown with it -- a dense one at the flash and a wider, thinner
|
|
1832
|
+
// one racing ahead of it -- both flying out faster than the flash fades, so the white
|
|
1833
|
+
// disperses into the dark rather than shrinking back into the star
|
|
1834
|
+
// A BURST OF PLUMES, not two clouds (operator, 2026-09-15: "need much more than two clouds of
|
|
1835
|
+
// white gas in the initial explosion"): the dense cloud at the flash, and the plumes of
|
|
1836
|
+
// white gas thrown from it in every direction (below), each its own cloud flying outward on
|
|
1837
|
+
// its own speed and growing as it goes, the fast ones out ahead and thinning first
|
|
1838
|
+
gasCloud(ctx, c.x, c.y, R0 * (1.0 + 2.2 * t), t, now, fx.seed + 999, 0.1 * f, [255, 255, 255], grad, disc, 80, [200, 210, 240], null, 0.9, 1.25);
|
|
1839
|
+
// (no lens flare here: its turning rays were the "opening rotating glints" the operator had
|
|
1840
|
+
// taken out on 2026-09-15; the white-out alone is the breakout)
|
|
1841
|
+
}
|
|
1842
|
+
// FURTHER AND LONGER (operator, 2026-09-15: "the plumes need to disperse further out and linger
|
|
1843
|
+
// longer ... Double the amount"): thirty-two plumes on their own clock, outliving the flash by
|
|
1844
|
+
// half again -- they reach nine star radii, keep flying after the flash has gone, and only
|
|
1845
|
+
// thin out slowly, so the white gas is still drifting outward when the blue debris rises
|
|
1846
|
+
// AND AGAIN (operator, 2026-09-15: "the white plumes still fade too fast, make them linger
|
|
1847
|
+
// longer. Double the amount again" -- then "Double the white ejecta again. We're getting
|
|
1848
|
+
// closer!"): a hundred and twenty-eight plumes, holding full for the first fifth of
|
|
1849
|
+
// their flight and thinning slowly over the rest, still drifting at nine tenths of the effect
|
|
1850
|
+
if (u >= 0.12 && u < 0.9) {
|
|
1851
|
+
const t = (u - 0.12) / 0.78, g = t < 0.04 ? t / 0.04 : t < 0.2 ? 1 : Math.pow(1 - (t - 0.2) / 0.8, 0.7);
|
|
1852
|
+
// AT HALF RESOLUTION: a hundred and twenty-eight clouds of soft gradient discs is the costliest
|
|
1853
|
+
// thing any effect draws, so the plumes go onto a half-size layer (a quarter of the pixels to
|
|
1854
|
+
// fill) and are blitted up -- gas is blurry to begin with, so nothing is lost; where there is
|
|
1855
|
+
// no offscreen canvas (the tests' stub context) they draw straight onto the board
|
|
1856
|
+
const layer = plumeLayer(ctx, fx), pc = layer ? layer.ctx : ctx;
|
|
1857
|
+
const pgrad = layer ? gradFor(pc) : grad, pdisc = layer ? discFor(pc) : disc;
|
|
1858
|
+
for (let k = 0; k < 128; k++) {
|
|
1859
|
+
const H = (q) => hash01(fx.seed + 4400 + k * 19 + q);
|
|
1860
|
+
const ang = (k / 128) * Math.PI * 2 + (H(1) - 0.5) * 0.2, sp = 0.4 + 1.2 * H(2);
|
|
1861
|
+
const reach = R0 * (0.6 + 9 * t) * sp, ease = 1 - Math.exp(-2.2 * t);
|
|
1862
|
+
const px = c.x + Math.cos(ang) * reach * ease, py = c.y + Math.sin(ang) * 0.75 * reach * ease;
|
|
1863
|
+
const shell = R0 * (0.5 + 2.8 * t) * (0.7 + 0.6 * H(3));
|
|
1864
|
+
gasCloud(pc, px, py, shell, t, now, fx.seed + 5000 + k * 131, 0.09 * g * (1 - 0.3 * sp), [245, 248, 255], pgrad, pdisc, 10, [180, 195, 235], null, 1.3, 1.3);
|
|
1865
|
+
}
|
|
1866
|
+
if (layer) { ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.drawImage(layer.canvas, 0, 0, ctx.canvas.width, ctx.canvas.height); ctx.restore(); }
|
|
1867
|
+
}
|
|
1868
|
+
// --- the pulsar: as the cloud dims, a point pulsing at the centre, its blue nebula growing
|
|
1869
|
+
if (u >= 0.45) {
|
|
1870
|
+
const f = (u - 0.45) / 0.55, on = Math.min(1, f / 0.1) * gf;
|
|
1871
|
+
const period = 140, ph = ((now % period) / period), pulse = Math.pow(Math.max(0, 1 - Math.abs(ph - 0.5) * 6), 2); // a sharp beat, seven a second (operator: "Pulsar needs to pulse quicker")
|
|
1872
|
+
const neb = R0 * (0.25 + 1.1 * f);
|
|
1873
|
+
// THE WIND NEBULA IS A GAS CLOUD TOO (operator, 2026-09-15: "The pulsar nebula needs the gas
|
|
1874
|
+
// cloud too"): a small blue-white sphere of blobs blown outward from the pulsar, growing with
|
|
1875
|
+
// it, lit from the centre, the pulsar's beat showing in its brightness
|
|
1876
|
+
softStops(ctx, c.x, c.y, neb, [[0, `rgba(190,225,255,${(0.28 * on).toFixed(3)})`], [0.5, `rgba(120,175,255,${(0.12 * on).toFixed(3)})`], [1, 'rgba(80,130,255,0)']]);
|
|
1877
|
+
gasCloud(ctx, c.x, c.y, neb, f, now, fx.seed + 4242, on * (0.75 + 0.25 * pulse), [170, 215, 255], grad, disc, 70, [40, 70, 170]);
|
|
1878
|
+
const rp = R0 * (0.12 + 0.25 * pulse);
|
|
1879
|
+
softStops(ctx, c.x, c.y, rp * 2.4, [[0, `rgba(255,255,255,${((0.5 + 0.5 * pulse) * on).toFixed(3)})`], [0.3, `rgba(200,235,255,${(0.55 * pulse * on).toFixed(3)})`], [1, 'rgba(140,200,255,0)']]);
|
|
1880
|
+
disc(c.x, c.y, rp * 0.5, `rgba(255,255,255,${on.toFixed(3)})`);
|
|
1881
|
+
// the beam: two thin rays sweeping round with the beat
|
|
1882
|
+
const sweep = now * 0.004;
|
|
1883
|
+
for (const side of [0, Math.PI]) {
|
|
1884
|
+
const ang = sweep + side, L = neb * (0.8 + 0.8 * pulse);
|
|
1885
|
+
ctx.strokeStyle = `rgba(220,240,255,${(0.35 * pulse * on).toFixed(3)})`; ctx.lineWidth = Math.max(lw, U * 0.04);
|
|
1886
|
+
ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(c.x + Math.cos(ang) * L, c.y + Math.sin(ang) * L); ctx.stroke();
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
ctx.lineWidth = lw;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
760
1892
|
// THE LIGHTNING BALL, over the cubes: the grid line it has traced burning behind it and cooling
|
|
761
1893
|
// over 16 units, a plasma ball of one pale gradient with a white-hot heart, and bolts jumping from it
|
|
762
1894
|
// to the grid crossings round it, new every frame. Plain rgba fills and strokes only.
|
|
@@ -801,17 +1933,16 @@ function drawBall(ctx, view, lw) {
|
|
|
801
1933
|
// thin so the ball is light on the board rather than a shadow over it.
|
|
802
1934
|
const flick = 0.85 + 0.15 * Math.random();
|
|
803
1935
|
const R = 4.2 * U;
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
ctx.beginPath(); ctx.arc(c.x, c.y, R, 0, Math.PI * 2); ctx.fill();
|
|
1936
|
+
// layered flat discs, not a radial gradient: this is the biggest soft blob on the board and it
|
|
1937
|
+
// banded visibly (operator, 2026-09-15: "what is still with the gradient fill shit?!")
|
|
1938
|
+
softStops(ctx, c.x, c.y, R, [
|
|
1939
|
+
[0, 'rgba(255,255,255,1)'],
|
|
1940
|
+
[0.1, 'rgba(240,250,255,1)'],
|
|
1941
|
+
[0.22, 'rgba(200,236,255,0.85)'],
|
|
1942
|
+
[0.4, `rgba(170,220,255,${(0.32 * flick).toFixed(3)})`],
|
|
1943
|
+
[0.68, `rgba(160,205,255,${(0.1 * flick).toFixed(3)})`],
|
|
1944
|
+
[1, 'rgba(160,200,255,0)'],
|
|
1945
|
+
], 26);
|
|
815
1946
|
const z0 = b.z - 0.9;
|
|
816
1947
|
const bolts = 4 + ((Math.random() * 4) | 0);
|
|
817
1948
|
for (let i = 0; i < bolts; i++) {
|
|
@@ -980,7 +2111,7 @@ function drawGrid(ctx, view, opts, n, blockRows, rows = n) {
|
|
|
980
2111
|
polyline(pts, 16, `rgba(80,255,190,${(0.12 * fx.amp).toFixed(3)})`);
|
|
981
2112
|
polyline(pts, 3, `rgba(180,255,230,${(0.45 * fx.amp).toFixed(3)})`);
|
|
982
2113
|
}
|
|
983
|
-
if (fx && (fx.kind === 'outline' || fx.kind === '
|
|
2114
|
+
if (fx && (fx.kind === 'outline' || fx.kind === 'tide')) { // the scan draws its own foot (drawScanCurtain)
|
|
984
2115
|
// the front itself, drawn across the floor where it is
|
|
985
2116
|
const p = fxFront(fx);
|
|
986
2117
|
const L = Math.hypot(n, rows);
|
|
@@ -1297,20 +2428,37 @@ function curveByLength(pts, samples = 400) {
|
|
|
1297
2428
|
let lo = 0, hi = samples;
|
|
1298
2429
|
while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (table[mid].len < L) lo = mid; else hi = mid; }
|
|
1299
2430
|
const a = table[lo], b = table[hi], f = b.len > a.len ? (L - a.len) / (b.len - a.len) : 0;
|
|
1300
|
-
|
|
2431
|
+
// never exactly 1: curveAt's last segment is pts[n-1]..pts[n], and s = 1 would ask for pts[n+1]
|
|
2432
|
+
// (the pulse's head bulge sits at the very end of the line once the head has run off it)
|
|
2433
|
+
return curveAt(pts, Math.min(0.999999, a.s + (b.s - a.s) * f));
|
|
1301
2434
|
};
|
|
1302
|
-
|
|
2435
|
+
// the length along the curve at parameter s (0..1 by point index, as headPoint and curveAt count):
|
|
2436
|
+
// the pulse's bulge is placed with this, so it sits ON the head rather than a share of the length
|
|
2437
|
+
// along -- which drifted from the head wherever the candles' hops were unequal (operator,
|
|
2438
|
+
// 2026-09-15: "the bulge effect is lagging behind the energy. They should be on top of each other")
|
|
2439
|
+
const lenAt = (sv) => {
|
|
2440
|
+
const S = Math.max(0, Math.min(1, sv));
|
|
2441
|
+
let lo = 0, hi = samples;
|
|
2442
|
+
while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (table[mid].s < S) lo = mid; else hi = mid; }
|
|
2443
|
+
const a = table[lo], b = table[hi], f = b.s > a.s ? (S - a.s) / (b.s - a.s) : 0;
|
|
2444
|
+
return a.len + (b.len - a.len) * f;
|
|
2445
|
+
};
|
|
2446
|
+
return { total, at, lenAt, travel: bulgeTravel(table) };
|
|
1303
2447
|
}
|
|
1304
2448
|
|
|
1305
2449
|
function drawBulge(ctx, pts, lw0, u, layers, ms = FX_MS.bulge) {
|
|
1306
2450
|
const lw = Number.isFinite(lw0) && lw0 > 0 ? lw0 : 1; // a canvas that will not say: one pixel
|
|
1307
2451
|
const { t } = bulgeAt(u, ms);
|
|
1308
2452
|
const coreR = (lw * 5.5) / 2;
|
|
1309
|
-
const Rfull = coreR * BULGE_BALL;
|
|
1310
2453
|
const curve = curveByLength(pts);
|
|
1311
2454
|
// the ball's centre travels the whole line, quicker downhill and slower up (bulgeTravel); the ball
|
|
1312
2455
|
// is as big as fits between it and the nearer end
|
|
1313
|
-
|
|
2456
|
+
drawBulgeAt(ctx, pts, lw, curve.travel(t), coreR * BULGE_BALL, layers, curve);
|
|
2457
|
+
}
|
|
2458
|
+
/** The swell itself, at `centre` along the curve with a full radius of `Rfull`: the pulse's head borrows it. */
|
|
2459
|
+
function drawBulgeAt(ctx, pts, lw0, centre, Rfull, layers, curve = curveByLength(pts)) {
|
|
2460
|
+
const lw = Number.isFinite(lw0) && lw0 > 0 ? lw0 : 1; // a canvas that will not say: one pixel
|
|
2461
|
+
const coreR = (lw * 5.5) / 2;
|
|
1314
2462
|
const R = bulgeFit(Math.min(centre, curve.total - centre), Rfull, coreR);
|
|
1315
2463
|
const amp = (R - coreR) / (Rfull - coreR || 1);
|
|
1316
2464
|
if (amp < 0.002) return;
|
|
@@ -1406,9 +2554,83 @@ function electrifyLine(ctx, pts, view, lw) {
|
|
|
1406
2554
|
}
|
|
1407
2555
|
}
|
|
1408
2556
|
|
|
2557
|
+
|
|
2558
|
+
// A LIGHT SABER (operator, 2026-09-15: "add another markets effect that really rips off a star wars
|
|
2559
|
+
// light saber visual somehow"). The price line is the blade. It IGNITES from the left end -- the
|
|
2560
|
+
// blade extends along the line with a flash at the hilt and a bright tip -- HUMS for most of the
|
|
2561
|
+
// run (the glow breathes on two fast sines, the core shimmers, and now and then a spark spits off
|
|
2562
|
+
// the blade), and RETRACTS to the hilt. One of four colours a run, hashed from the seed: blue,
|
|
2563
|
+
// green, red, purple. The unlit rest of the line stays as the faint wire. Nothing here is the
|
|
2564
|
+
// price line's resting look, which is the steady neon glow (markets.test.js keeps it so).
|
|
2565
|
+
const SABER_COLS = [[80, 170, 255], [90, 255, 130], [255, 70, 70], [200, 100, 255]];
|
|
2566
|
+
function drawSaberLine(ctx, pts, lw, fx, view, GLOW, CORE) {
|
|
2567
|
+
const u = fx.u, now = view.now ?? 0;
|
|
2568
|
+
const col = SABER_COLS[Math.floor(hash01(fx.seed + 7) * SABER_COLS.length)];
|
|
2569
|
+
const c = col.join(',');
|
|
2570
|
+
const pale = col.map((v) => Math.round(v + (255 - v) * 0.55)).join(',');
|
|
2571
|
+
const IGN = 0.14, RET = 0.86;
|
|
2572
|
+
const ease = (t) => 1 - Math.pow(1 - t, 3);
|
|
2573
|
+
const ext = u < IGN ? ease(u / IGN) : u > RET ? 1 - ease((u - RET) / (1 - RET)) : 1;
|
|
2574
|
+
// the faint wire under it all
|
|
2575
|
+
const stroke = (w, colour, path) => { ctx.strokeStyle = colour; ctx.lineWidth = lw * w; ctx.beginPath(); path(); ctx.stroke(); };
|
|
2576
|
+
const wire = () => traceCurve(ctx, pts);
|
|
2577
|
+
for (const [w, cc, a] of [...GLOW, ...CORE]) stroke(w, `rgba(${cc[0]},${cc[1]},${cc[2]},${(a * 0.3).toFixed(3)})`, wire);
|
|
2578
|
+
if (ext <= 0.002) return;
|
|
2579
|
+
// the lit part: the curve up to `ext` of its length
|
|
2580
|
+
const curve = curveByLength(pts);
|
|
2581
|
+
const len = curve.total * ext;
|
|
2582
|
+
const N = Math.max(2, Math.ceil(64 * ext));
|
|
2583
|
+
const blade = [];
|
|
2584
|
+
for (let k = 0; k <= N; k++) blade.push(curve.at((len * k) / N));
|
|
2585
|
+
const along = () => { ctx.moveTo(blade[0].x, blade[0].y); for (let k = 1; k < blade.length; k++) ctx.lineTo(blade[k].x, blade[k].y); };
|
|
2586
|
+
// the hum: the glow breathes on two fast sines, never still
|
|
2587
|
+
const hum = 1 + 0.06 * Math.sin(now * 0.05) + 0.035 * Math.sin(now * 0.131 + 1);
|
|
2588
|
+
const bright = 0.9 + 0.1 * Math.sin(now * 0.021);
|
|
2589
|
+
// a third thicker than the first cut (operator, 2026-09-15: "make the saber effect a bit thicker")
|
|
2590
|
+
stroke(44 * hum, `rgba(${c},${(0.11 * bright).toFixed(3)})`, along);
|
|
2591
|
+
stroke(27 * hum, `rgba(${c},${(0.24 * bright).toFixed(3)})`, along);
|
|
2592
|
+
stroke(15 * hum, `rgba(${c},${(0.58 * bright).toFixed(3)})`, along);
|
|
2593
|
+
stroke(8.8, `rgba(${pale},0.92)`, along);
|
|
2594
|
+
stroke(4.4, `rgba(255,255,255,${(0.95 * bright).toFixed(3)})`, along);
|
|
2595
|
+
// the core shimmers: a thin white thread whose brightness runs along the blade
|
|
2596
|
+
for (let k = 0; k < blade.length - 1; k++) {
|
|
2597
|
+
const f = 0.5 + 0.5 * Math.sin(now * 0.03 + k * 0.9);
|
|
2598
|
+
ctx.strokeStyle = `rgba(255,255,255,${(0.35 * f).toFixed(3)})`; ctx.lineWidth = lw * 1.9;
|
|
2599
|
+
ctx.beginPath(); ctx.moveTo(blade[k].x, blade[k].y); ctx.lineTo(blade[k + 1].x, blade[k + 1].y); ctx.stroke();
|
|
2600
|
+
}
|
|
2601
|
+
// the tip: a bright point with the colour's bloom round it
|
|
2602
|
+
const tip = blade[blade.length - 1];
|
|
2603
|
+
for (const [r, colour, a] of [[12, c, 0.25], [6.8, pale, 0.7], [3.2, '255,255,255', 1]]) {
|
|
2604
|
+
ctx.fillStyle = `rgba(${colour},${a})`; ctx.beginPath(); ctx.arc(tip.x, tip.y, lw * r, 0, Math.PI * 2); ctx.fill();
|
|
2605
|
+
}
|
|
2606
|
+
// the hilt flashes at ignition and again as the blade comes home
|
|
2607
|
+
const flash = u < IGN ? 1 - u / IGN : u > RET ? (u - RET) / (1 - RET) : 0;
|
|
2608
|
+
if (flash > 0) {
|
|
2609
|
+
const h = blade[0];
|
|
2610
|
+
for (const [r, colour, a] of [[26, c, 0.18], [14, pale, 0.45], [6, '255,255,255', 0.9]]) {
|
|
2611
|
+
ctx.fillStyle = `rgba(${colour},${(a * flash).toFixed(3)})`; ctx.beginPath(); ctx.arc(h.x, h.y, lw * r * (0.6 + 0.4 * flash), 0, Math.PI * 2); ctx.fill();
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
// now and then a spark spits off the blade: a few short lines from one point, gone in a beat
|
|
2615
|
+
const beat = Math.floor(now / 90);
|
|
2616
|
+
if (ext >= 1 && hash01(fx.seed + beat) < 0.3) {
|
|
2617
|
+
const at = curve.at(curve.total * hash01(fx.seed + beat * 3 + 1));
|
|
2618
|
+
const srnd = (k) => hash01(fx.seed + beat * 7 + k);
|
|
2619
|
+
for (let k = 0; k < 6; k++) {
|
|
2620
|
+
const ang = srnd(k) * Math.PI * 2, r = lw * (6 + 12 * srnd(k + 20));
|
|
2621
|
+
ctx.strokeStyle = `rgba(${pale},${(0.8 * (0.5 + 0.5 * srnd(k + 40))).toFixed(3)})`; ctx.lineWidth = lw * 1.2;
|
|
2622
|
+
ctx.beginPath(); ctx.moveTo(at.x, at.y); ctx.lineTo(at.x + Math.cos(ang) * r, at.y + Math.sin(ang) * r); ctx.stroke();
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
1409
2626
|
function priceLine(ctx, view, axes) {
|
|
1410
|
-
|
|
2627
|
+
let pts = (axes.line ?? []).map((q) => project(q.x, axes.y ?? 0, q.z, view));
|
|
1411
2628
|
if (pts.length < 2) return;
|
|
2629
|
+
// THE LINE BENDS ROUND THE BLACK HOLE (2026-09-15): every point is pushed out from the hole's
|
|
2630
|
+
// centre the way a lens pushes a background image -- r' = sqrt(r^2 + rE^2), the Einstein
|
|
2631
|
+
// radius rE growing with the hole -- so the chart warps into an arc round the shadow, and
|
|
2632
|
+
// the stretch inside the horizon is drawn under the shadow, which covers it
|
|
2633
|
+
if (view.fx?.kind === 'blackhole' && view.fx.blackhole) pts = lensPoints(pts, view);
|
|
1412
2634
|
const lw = ctx.lineWidth;
|
|
1413
2635
|
const join = ctx.lineJoin, cap = ctx.lineCap;
|
|
1414
2636
|
ctx.lineJoin = 'round';
|
|
@@ -1445,6 +2667,28 @@ function priceLine(ctx, view, axes) {
|
|
|
1445
2667
|
const CORE = [[5.5, [255, 236, 70], 0.78, 'core'], [3, [255, 246, 150], 1, 'core'], [1.3, [255, 255, 240], 1, 'core']];
|
|
1446
2668
|
const fx = view.fx && view.fx.kind === 'pulse' ? view.fx : null;
|
|
1447
2669
|
const done = () => { ctx.lineWidth = lw; ctx.lineJoin = join; ctx.lineCap = cap; };
|
|
2670
|
+
const HOT = [255, 255, 215]; // the flash: whiter than the wire
|
|
2671
|
+
// THE LINE BREATHES (operator, 2026-09-15: "an effect for the markets page that makes the line
|
|
2672
|
+
// breathe in-and-out between those two extents"): three slow swells over the run, each easing
|
|
2673
|
+
// the tube from the plain wire to the pulse's heat -- wider, whiter, brighter -- and back.
|
|
2674
|
+
if (view.fx?.kind === 'breathe') {
|
|
2675
|
+
const bre = 0.5 - 0.5 * Math.cos(view.fx.u * Math.PI * 2 * 3);
|
|
2676
|
+
const env = Math.sin(Math.PI * view.fx.u); // in from nothing and out to nothing over the run
|
|
2677
|
+
const k = bre * env;
|
|
2678
|
+
for (const [w, c, a] of [...GLOW, ...CORE]) {
|
|
2679
|
+
const mix = (j) => Math.round(c[j] + (HOT[j] - c[j]) * 0.8 * k);
|
|
2680
|
+
stroke(w * (1 + 0.9 * k), `rgba(${mix(0)},${mix(1)},${mix(2)},${Math.min(1, a * (1 + 0.6 * k))})`);
|
|
2681
|
+
}
|
|
2682
|
+
done();
|
|
2683
|
+
return;
|
|
2684
|
+
}
|
|
2685
|
+
if (view.fx?.kind === 'saber') { drawSaberLine(ctx, pts, lw, view.fx, view, GLOW, CORE); done(); return; }
|
|
2686
|
+
if (view.fx?.kind === 'scan') {
|
|
2687
|
+
for (const [w, c, a] of [...GLOW, ...CORE]) stroke(w, `rgba(${c[0]},${c[1]},${c[2]},${a})`);
|
|
2688
|
+
scanFlare(ctx, pts, axes, view, lw);
|
|
2689
|
+
done();
|
|
2690
|
+
return;
|
|
2691
|
+
}
|
|
1448
2692
|
if (view.fx?.kind === 'bulge') {
|
|
1449
2693
|
for (const [w, c, a] of [...GLOW, ...CORE]) stroke(w, `rgba(${c[0]},${c[1]},${c[2]},${a})`);
|
|
1450
2694
|
drawBulge(ctx, pts, lw, view.fx.u, [...GLOW, ...CORE], view.fx.ms);
|
|
@@ -1465,9 +2709,13 @@ function priceLine(ctx, view, axes) {
|
|
|
1465
2709
|
// surround reads as pure blue instead of the washed periwinkle it was. The tail's shape is
|
|
1466
2710
|
// untouched -- every pass still blends wire-yellow through these to HOT at the overshoot; only
|
|
1467
2711
|
// how blue "blue" is has changed.
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
2712
|
+
// NO BLUE (operator, 2026-09-15: "during the energy pulse, I don't like the blue shift/tint it's
|
|
2713
|
+
// adding on everything. Get rid of that, and add a bulge effect around the energy pulse"). The
|
|
2714
|
+
// surge is heat now: behind the head the tube goes white-hot through hot gold and cools back to
|
|
2715
|
+
// the wire, the cloud behind it is warm, the crackle and the motes are gold and white, and the
|
|
2716
|
+
// head is a swell in the pipe (drawBulgeAt) with a white-hot core, not a blue dot.
|
|
2717
|
+
const BLUE = [255, 255, 235]; // the core behind the head: white-hot
|
|
2718
|
+
const DEEP = [255, 205, 90]; // the bloom behind the head: hot gold
|
|
1471
2719
|
// THE NEBULA BEHIND THE SURGE (operator, 2026-09-12: "like a blue nebula behind the energy pulse
|
|
1472
2720
|
// that starts expanding and fading out to black"). Drawn FIRST, so it sits behind the wire: soft
|
|
1473
2721
|
// nested discs on every charged segment, their radius growing with that stretch's age and their
|
|
@@ -1488,37 +2736,32 @@ function priceLine(ctx, view, axes) {
|
|
|
1488
2736
|
// large"). Puffs spread about half as far from the line (4 + 78 * age line-widths -> 3 + 38), are
|
|
1489
2737
|
// a bit over half the radius and grow less with age, and sit at three-quarters of the alpha, so the
|
|
1490
2738
|
// cloud hugs the wire instead of billowing over the chart. Fewer of them, since each covers less.
|
|
1491
|
-
|
|
1492
|
-
|
|
2739
|
+
// THE MIST, NOT A PATH OF CIRCLES (operator, 2026-09-15: "the energy pulse effect trail looks
|
|
2740
|
+
// really bad. it looks like a path of circles rather than emissive nebula/mist"). The cloud was
|
|
2741
|
+
// 160 flat discs, and flat discs of one alpha have edges the eye joins into circles however they
|
|
2742
|
+
// are scattered. Each puff is a radial gradient now -- bright at its heart, gone at its rim, so
|
|
2743
|
+
// no puff has an edge -- larger, fewer, and laid so they overlap into one glowing haze behind
|
|
2744
|
+
// the head that thins and cools down the tail. Hashed, so it holds its shape frame to frame.
|
|
2745
|
+
const MIST = 54;
|
|
2746
|
+
for (let k = 0; k < MIST; k++) {
|
|
1493
2747
|
const u = hash01(k * 7 + 13); // how far back down the tail it sits
|
|
1494
2748
|
const at = headAt - u * PULSE_TAIL;
|
|
1495
2749
|
if (at < 0) continue;
|
|
1496
|
-
// PAST THE END IT FADES, IT DOES NOT VANISH (operator: "The nebula effects should also fade out
|
|
1497
|
-
// instead of just disappearing"). This was `at > 1 -> continue`, so the cloud was culled the
|
|
1498
|
-
// instant its stretch ran off the last candle and the whole trail blinked out together.
|
|
1499
2750
|
const off = at > 1 ? (at - 1) / 0.34 : 0;
|
|
1500
2751
|
if (off >= 1) continue;
|
|
1501
|
-
const
|
|
1502
|
-
const tint = Math.pow(1 - u, 1.4) * edge;
|
|
1503
|
-
const age = u;
|
|
1504
|
-
// positioned along the same flight the head takes, so the cloud follows it off the end
|
|
1505
|
-
// instead of piling up on the last candle
|
|
2752
|
+
const tint = Math.pow(1 - u, 1.3) * (1 - off);
|
|
1506
2753
|
const hp = headPoint(pts, at, 0.34);
|
|
1507
2754
|
if (!hp) continue;
|
|
1508
|
-
const mx = hp.x, my = hp.y;
|
|
1509
2755
|
const a1 = hash01(k * 31 + 101) * Math.PI * 2;
|
|
1510
|
-
const spread = lw * (
|
|
1511
|
-
const rad = lw * (
|
|
1512
|
-
const al = 0.
|
|
1513
|
-
const px =
|
|
1514
|
-
|
|
1515
|
-
ctx.
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
ctx.fillStyle = `rgba(120,180,255,${(al * 0.7).toFixed(3)})`;
|
|
1520
|
-
ctx.beginPath(); ctx.arc(px, py, rad * 0.5, 0, Math.PI * 2); ctx.fill();
|
|
1521
|
-
}
|
|
2756
|
+
const spread = lw * (2 + 22 * u) * (0.2 + 0.8 * hash01(k * 17 + 5));
|
|
2757
|
+
const rad = lw * (9 + 16 * hash01(k * 13 + 67)) * (1 + 0.9 * u);
|
|
2758
|
+
const al = 0.16 * tint * (0.5 + 0.5 * hash01(k * 5 + 29));
|
|
2759
|
+
const px = hp.x + Math.cos(a1) * spread, py = hp.y + Math.sin(a1) * spread;
|
|
2760
|
+
const warm = u < 0.35 ? '255,236,170' : '255,185,70';
|
|
2761
|
+
ctx.fillStyle = gradientOr(ctx, () => ctx.createRadialGradient(px, py, 0, px, py, rad),
|
|
2762
|
+
[[0, `rgba(${warm},${al.toFixed(3)})`], [0.4, `rgba(255,185,70,${(al * 0.55).toFixed(3)})`], [1, 'rgba(255,150,40,0)']],
|
|
2763
|
+
`rgba(255,185,70,${(al * 0.5).toFixed(3)})`);
|
|
2764
|
+
ctx.beginPath(); ctx.arc(px, py, rad, 0, Math.PI * 2); ctx.fill();
|
|
1522
2765
|
}
|
|
1523
2766
|
// THE TUBE, AS ONE STROKE PER LAYER WITH A GRADIENT ALONG IT.
|
|
1524
2767
|
//
|
|
@@ -1540,10 +2783,13 @@ function priceLine(ctx, view, axes) {
|
|
|
1540
2783
|
const tailStop = clamp01(headAt - PULSE_TAIL);
|
|
1541
2784
|
for (const [w, c, a, kind] of [...GLOW, ...CORE]) {
|
|
1542
2785
|
const B = kind === 'glow' ? DEEP : BLUE;
|
|
1543
|
-
//
|
|
1544
|
-
//
|
|
1545
|
-
|
|
1546
|
-
|
|
2786
|
+
// NO WHOLE-LINE SWELL (operator, 2026-09-15: "the energy pulse effect makes the yellow line
|
|
2787
|
+
// entirely grow at the start of the animation, and instantly snap back into the prior size.
|
|
2788
|
+
// I want the energy pulse effect growing the thickness of the yellow line in an arced radius
|
|
2789
|
+
// around it"). One stroke has one width, so a swell here was the whole wire jumping fat at
|
|
2790
|
+
// the start and thin at the end; the swell is the head's bulge (drawBulgeAt), round the head
|
|
2791
|
+
// and nowhere else, and the wire keeps its width.
|
|
2792
|
+
ctx.lineWidth = lw * w;
|
|
1547
2793
|
const at = (passed) => {
|
|
1548
2794
|
const tint = passed >= 0 && passed < PULSE_TAIL ? Math.pow(1 - passed / PULSE_TAIL, 1.4) : 0;
|
|
1549
2795
|
const past = passed - PULSE_TAIL * 0.55;
|
|
@@ -1572,7 +2818,49 @@ function priceLine(ctx, view, axes) {
|
|
|
1572
2818
|
if (path) ctx.stroke(path);
|
|
1573
2819
|
else { ctx.beginPath(); traceCurve(ctx, pts); ctx.stroke(); }
|
|
1574
2820
|
}
|
|
1575
|
-
//
|
|
2821
|
+
// THE BULGE AT THE HEAD: the pipe swells round the surge as it passes, the same stretched skin
|
|
2822
|
+
// and fish-eyed core as the bulge effect, at a smaller radius, in the heat's own colours
|
|
2823
|
+
{
|
|
2824
|
+
const headFrac = Math.max(0, Math.min(1, headAt));
|
|
2825
|
+
const curve = curveByLength(pts);
|
|
2826
|
+
const coreR = (lw * 5.5) / 2;
|
|
2827
|
+
const hotLayers = [...GLOW, ...CORE].map(([w, c, a, kind]) => [w, c.map((v, j) => Math.round(v + (HOT[j] - v) * 0.75)), a, kind]);
|
|
2828
|
+
const fadeIn = headAt < 0 ? 0 : headAt > 1 ? Math.max(0, 1 - (headAt - 1) / 0.2) : 1;
|
|
2829
|
+
// PROMINENT (operator, 2026-09-15: "the bulging effect is not prominent enough ... It needs a
|
|
2830
|
+
// lot more vibrance, presence, and effect"): the swell is the bulge effect's own full size now
|
|
2831
|
+
// ...then THREE TIMES THAT (operator: "make the bulge 3 times larger for the energy pulse. It's
|
|
2832
|
+
// not obvious the line is being deformed"), then half of that ("reduce the bulge by half")
|
|
2833
|
+
// ...then a third bigger ("the bulge effect on the energy pulse is getting lost in the animation.
|
|
2834
|
+
// Increase in size by 33%")
|
|
2835
|
+
if (fadeIn > 0) drawBulgeAt(ctx, pts, lw, curve.lenAt(headFrac), (Number.isFinite(coreR) && coreR > 0 ? coreR : 2.75) * (1 + 2 * (BULGE_BALL - 1) * fadeIn), hotLayers, curve);
|
|
2836
|
+
}
|
|
2837
|
+
// THE HAZE (operator, 2026-09-15: "haze effects of some sort for the energy blurring and
|
|
2838
|
+
// interfering with the affected areas"): three ghost copies of the charged stretch, each thrown
|
|
2839
|
+
// off the wire by a slow wave that runs along it and drifts with the clock, faint and warm, so
|
|
2840
|
+
// the wire seems to shimmer through hot air -- and a wide soft light over the whole stretch
|
|
2841
|
+
{
|
|
2842
|
+
const now0 = view.now ?? 0;
|
|
2843
|
+
const i0 = Math.max(0, Math.floor((headAt - PULSE_TAIL) * n)), i1 = Math.min(n, Math.ceil(Math.min(1, headAt) * n));
|
|
2844
|
+
if (i1 - i0 >= 1) {
|
|
2845
|
+
for (let g = 0; g < 3; g++) {
|
|
2846
|
+
ctx.strokeStyle = `rgba(255,220,140,${(0.10 + 0.04 * g).toFixed(3)})`; ctx.lineWidth = lw * (7 - 2 * g);
|
|
2847
|
+
ctx.beginPath();
|
|
2848
|
+
for (let i = i0; i <= i1; i++) {
|
|
2849
|
+
const passed = headAt - i / n;
|
|
2850
|
+
const tint = passed >= 0 && passed < PULSE_TAIL ? Math.pow(1 - passed / PULSE_TAIL, 1.4) : 0;
|
|
2851
|
+
const wob = lw * (3.5 + 2 * g) * tint * Math.sin(now0 * (0.004 + 0.0015 * g) + i * 0.9 + g * 2.1);
|
|
2852
|
+
const p = pts[i];
|
|
2853
|
+
if (i === i0) ctx.moveTo(p.x, p.y + wob); else ctx.lineTo(p.x, p.y + wob);
|
|
2854
|
+
}
|
|
2855
|
+
ctx.stroke();
|
|
2856
|
+
}
|
|
2857
|
+
// the light over the affected area: a wide soft warm glow along the charged stretch
|
|
2858
|
+
const mid = pts[Math.floor((i0 + i1) / 2)], span = Math.hypot(pts[i1].x - pts[i0].x, pts[i1].y - pts[i0].y) * 0.7 + lw * 20;
|
|
2859
|
+
ctx.fillStyle = gradientOr(ctx, () => ctx.createRadialGradient(mid.x, mid.y, 0, mid.x, mid.y, span), [[0, 'rgba(255,225,160,0.14)'], [0.5, 'rgba(255,200,110,0.06)'], [1, 'rgba(255,180,80,0)']], 'rgba(255,215,140,0.04)');
|
|
2860
|
+
ctx.beginPath(); ctx.arc(mid.x, mid.y, span, 0, Math.PI * 2); ctx.fill();
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2863
|
+
// SHIMMER on the charged stretch: a thin white-gold core whose brightness flickers per segment
|
|
1576
2864
|
// on the frame clock, scaled by that segment's tint so it dies out exactly as the blue does.
|
|
1577
2865
|
//
|
|
1578
2866
|
// These were removed on 2026-09-12 ("the particle effects ... looks terrible ... get rid of the
|
|
@@ -1588,7 +2876,7 @@ function priceLine(ctx, view, axes) {
|
|
|
1588
2876
|
if (tint < 0.08) continue;
|
|
1589
2877
|
const p = pts[i], q = pts[i + 1];
|
|
1590
2878
|
const flick = 0.55 + 0.45 * Math.abs(Math.sin(now * 0.023 + i * 1.7) * Math.sin(now * 0.041 + i * 0.9));
|
|
1591
|
-
ctx.strokeStyle = `rgba(
|
|
2879
|
+
ctx.strokeStyle = `rgba(255,250,225,${(0.9 * tint * flick).toFixed(3)})`;
|
|
1592
2880
|
ctx.lineWidth = lw * 2.4;
|
|
1593
2881
|
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y); ctx.stroke();
|
|
1594
2882
|
|
|
@@ -1626,9 +2914,9 @@ function priceLine(ctx, view, axes) {
|
|
|
1626
2914
|
for (let m = 1; m <= upto; m++) ctx.lineTo(pl[m].x, pl[m].y);
|
|
1627
2915
|
ctx.stroke();
|
|
1628
2916
|
};
|
|
1629
|
-
arc(legs, 2.4, `rgba(
|
|
1630
|
-
arc(legs, 0.95, `rgba(
|
|
1631
|
-
arc(Math.max(1, legs - 1), 0.5, `rgba(
|
|
2917
|
+
arc(legs, 2.4, `rgba(255,170,60,${(0.3 * tint).toFixed(3)})`);
|
|
2918
|
+
arc(legs, 0.95, `rgba(255,225,150,${(0.92 * tint).toFixed(3)})`);
|
|
2919
|
+
arc(Math.max(1, legs - 1), 0.5, `rgba(255,255,240,${(0.95 * tint).toFixed(3)})`);
|
|
1632
2920
|
}
|
|
1633
2921
|
// PARTICLES: a spray of motes streaming off the charged wire, each on its own heading,
|
|
1634
2922
|
// spreading wider and fading as its stretch of the line ages. Placed by a HASH of segment and
|
|
@@ -1643,7 +2931,7 @@ function priceLine(ctx, view, axes) {
|
|
|
1643
2931
|
const ang = hash01(i * 17 + k * 13 + 101) * Math.PI * 2;
|
|
1644
2932
|
const dist = lw * (4 + 62 * age) * (0.6 + 0.4 * hash01(i + k * 3 + 7));
|
|
1645
2933
|
const r = lw * (0.6 + 1.3 * hash01(i * 5 + k + 41)) * (1 - 0.45 * age);
|
|
1646
|
-
ctx.fillStyle = `rgba(
|
|
2934
|
+
ctx.fillStyle = `rgba(255,235,190,${(0.75 * tint * (1 - 0.5 * age)).toFixed(3)})`;
|
|
1647
2935
|
ctx.beginPath(); ctx.arc(bx + Math.cos(ang) * dist, by + Math.sin(ang) * dist, r, 0, Math.PI * 2); ctx.fill();
|
|
1648
2936
|
}
|
|
1649
2937
|
}
|
|
@@ -1663,7 +2951,7 @@ function priceLine(ctx, view, axes) {
|
|
|
1663
2951
|
const r = lw * (0.6 + 1.3 * hash01(m * 7 + 19));
|
|
1664
2952
|
const a = 0.7 * hp.fade * (1 - back / (PULSE_TAIL * 0.6));
|
|
1665
2953
|
if (a <= 0.01) continue;
|
|
1666
|
-
ctx.fillStyle = `rgba(
|
|
2954
|
+
ctx.fillStyle = `rgba(255,235,190,${a.toFixed(3)})`;
|
|
1667
2955
|
ctx.beginPath();
|
|
1668
2956
|
ctx.arc(hp.x + Math.cos(ang) * spread, hp.y + Math.sin(ang) * spread, r, 0, Math.PI * 2);
|
|
1669
2957
|
ctx.fill();
|
|
@@ -1693,10 +2981,84 @@ function priceLine(ctx, view, axes) {
|
|
|
1693
2981
|
// ball with a blue halo, which is exactly what was reported. Every layer is blue-dominant
|
|
1694
2982
|
// now, the hot centre included -- it is the brightest, palest blue rather than white, so the
|
|
1695
2983
|
// head still reads as the hottest point on the line without going colourless.
|
|
1696
|
-
|
|
2984
|
+
// AN UNSTABLE, CRACKLING BALL OF PLASMA (operator, 2026-09-15: "It needs a lot more vibrance,
|
|
2985
|
+
// presence, and effect. An unstable crackling ball of plasma"): a sphere of one gradient,
|
|
2986
|
+
// white-hot heart through gold to a rim that is not quite there, breathing on two quick
|
|
2987
|
+
// sines; three warm wisps of nebula wobbling round it; filaments of discharge skittering over
|
|
2988
|
+
// its surface, re-rolled on a 60 ms beat; and every so often it SPITS -- a few sparks thrown
|
|
2989
|
+
// up and out that arc under gravity, slow, and fade (their flights are pure functions of the
|
|
2990
|
+
// clock and the moment they were spat, so nothing is kept between frames).
|
|
2991
|
+
const nowH = view.now ?? 0;
|
|
2992
|
+
const breathe = 1 + 0.08 * Math.sin(nowH * 0.011) + 0.05 * Math.sin(nowH * 0.023 + 1);
|
|
2993
|
+
const RB = lw * 16 * (0.6 + 0.4 * f) * breathe;
|
|
2994
|
+
const wispsH = [['255,170,60', 0.34, 1.0], ['255,120,80', 0.26, -0.7], ['255,230,150', 0.22, 0.5]];
|
|
2995
|
+
wispsH.forEach(([wc, wa, spin], k) => {
|
|
2996
|
+
const t = nowH * 0.0011;
|
|
2997
|
+
const wx = hp.x + Math.cos(t * spin * 0.6 + k * 2.1) * RB * 0.5, wy = hp.y + Math.sin(t * spin * 0.45 + k * 1.3) * RB * 0.4;
|
|
2998
|
+
const base = RB * (1.7 + 0.4 * k);
|
|
2999
|
+
ctx.beginPath();
|
|
3000
|
+
for (let i = 0; i <= 36; i++) {
|
|
3001
|
+
const th = (i / 36) * Math.PI * 2;
|
|
3002
|
+
const wob = 1 + 0.26 * Math.sin(th * 3 + t * spin * 3 + k) + 0.15 * Math.sin(th * 5 - t * 2.4 * spin + k * 3);
|
|
3003
|
+
const rr = base * wob, x = wx + Math.cos(th) * rr, y = wy + Math.sin(th) * rr;
|
|
3004
|
+
if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y);
|
|
3005
|
+
}
|
|
3006
|
+
ctx.closePath();
|
|
3007
|
+
ctx.fillStyle = gradientOr(ctx, () => ctx.createRadialGradient(wx, wy, 0, wx, wy, base * 1.4), [[0, `rgba(${wc},${(wa * f).toFixed(3)})`], [0.4, `rgba(${wc},${(wa * 0.6 * f).toFixed(3)})`], [1, `rgba(${wc},0)`]], `rgba(${wc},${(wa * 0.3 * f).toFixed(3)})`);
|
|
3008
|
+
ctx.fill();
|
|
3009
|
+
});
|
|
3010
|
+
ctx.fillStyle = gradientOr(ctx, () => ctx.createRadialGradient(hp.x, hp.y, 0, hp.x, hp.y, RB * 1.35), [
|
|
3011
|
+
[0, `rgba(255,255,255,${f.toFixed(3)})`], [0.16, `rgba(255,250,225,${f.toFixed(3)})`], [0.38, `rgba(255,232,150,${(0.95 * f).toFixed(3)})`],
|
|
3012
|
+
[0.6, `rgba(255,190,80,${(0.8 * f).toFixed(3)})`], [0.82, `rgba(255,140,50,${(0.4 * f).toFixed(3)})`], [1, 'rgba(255,120,40,0)'],
|
|
3013
|
+
], `rgba(255,232,150,${(0.95 * f).toFixed(3)})`);
|
|
3014
|
+
ctx.beginPath(); ctx.arc(hp.x, hp.y, RB * 1.35, 0, Math.PI * 2); ctx.fill();
|
|
3015
|
+
// the discs the tests know the head by: its hot centre and its white point, over the sphere
|
|
3016
|
+
for (const [r, c0, a0] of [[7, '255,232,150', 0.95], [3, '255,252,225', 1]]) {
|
|
1697
3017
|
ctx.fillStyle = `rgba(${c0},${(a0 * f).toFixed(3)})`;
|
|
1698
3018
|
ctx.beginPath(); ctx.arc(hp.x, hp.y, lw * r * (0.55 + 0.45 * f), 0, Math.PI * 2); ctx.fill();
|
|
1699
3019
|
}
|
|
3020
|
+
// the crackle over its surface: short filaments between two points on and just off the ball
|
|
3021
|
+
{
|
|
3022
|
+
const beat = Math.floor(nowH / 60);
|
|
3023
|
+
let x = ((fx.seed ?? 1) ^ (beat * 2654435761)) >>> 0 || 1;
|
|
3024
|
+
const rr = () => ((x = (Math.imul(x, 1103515245) + 12345) >>> 0) / 4294967296);
|
|
3025
|
+
for (let i = 0; i < 12; i++) {
|
|
3026
|
+
const ang = rr() * Math.PI * 2, r0 = RB * (0.35 + 0.5 * rr()), r1 = RB * (1.05 + 0.6 * rr());
|
|
3027
|
+
const p = { x: hp.x + Math.cos(ang) * r0, y: hp.y + Math.sin(ang) * r0 };
|
|
3028
|
+
const q = { x: hp.x + Math.cos(ang + (rr() - 0.5) * 0.9) * r1, y: hp.y + Math.sin(ang + (rr() - 0.5) * 0.9) * r1 };
|
|
3029
|
+
const mid = { x: (p.x + q.x) / 2 + (rr() - 0.5) * RB * 0.35, y: (p.y + q.y) / 2 + (rr() - 0.5) * RB * 0.35 };
|
|
3030
|
+
for (const [w, col] of [[2.2, `rgba(255,170,60,${(0.55 * f).toFixed(3)})`], [0.9, `rgba(255,250,225,${(0.95 * f).toFixed(3)})`]]) {
|
|
3031
|
+
ctx.strokeStyle = col; ctx.lineWidth = lw * w; ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(mid.x, mid.y); ctx.lineTo(q.x, q.y); ctx.stroke();
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
// THE SPIT: every 140 ms there is a one-in-three chance the ball throws three sparks; each
|
|
3036
|
+
// flight is (birth, angle, speed) and the clock, so it is drawn from those alone
|
|
3037
|
+
{
|
|
3038
|
+
const STEP = 140, LIFE = 1500;
|
|
3039
|
+
const t0 = nowH - (fx.u ?? 0) * (fx.ms ?? FX_MS.pulse);
|
|
3040
|
+
const kNow = Math.floor(nowH / STEP);
|
|
3041
|
+
for (let k = kNow - Math.ceil(LIFE / STEP); k <= kNow; k++) {
|
|
3042
|
+
if (hash01((fx.seed ?? 1) + k * 3) > 0.33) continue;
|
|
3043
|
+
const born = k * STEP, age = (nowH - born) / LIFE;
|
|
3044
|
+
if (age < 0 || age >= 1) continue;
|
|
3045
|
+
const uk = (born - t0) / (fx.ms ?? FX_MS.pulse);
|
|
3046
|
+
const bp = headPoint(pts, uk / PULSE_TRAVEL, OVERRUN);
|
|
3047
|
+
if (!bp) continue;
|
|
3048
|
+
for (let m = 0; m < 3; m++) {
|
|
3049
|
+
const hh = (q) => hash01((fx.seed ?? 1) + k * 31 + m * 7 + q);
|
|
3050
|
+
const ang = -Math.PI / 2 + (hh(1) - 0.5) * 2.2, sp = lw * (14 + 16 * hh(2));
|
|
3051
|
+
const ease = 1 - Math.exp(-3 * age);
|
|
3052
|
+
const sx = bp.x + Math.cos(ang) * sp * ease * 2.2, sy = bp.y + Math.sin(ang) * sp * ease * 2.2 + lw * 34 * age * age;
|
|
3053
|
+
const px = bp.x + Math.cos(ang) * sp * Math.max(0, ease - 0.12) * 2.2, py = bp.y + Math.sin(ang) * sp * Math.max(0, ease - 0.12) * 2.2 + lw * 34 * Math.max(0, age - 0.08) ** 2;
|
|
3054
|
+
const al = (1 - age) * f;
|
|
3055
|
+
ctx.strokeStyle = `rgba(255,200,100,${(0.7 * al).toFixed(3)})`; ctx.lineWidth = lw * 1.6;
|
|
3056
|
+
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(sx, sy); ctx.stroke();
|
|
3057
|
+
ctx.fillStyle = `rgba(255,250,225,${(0.95 * al).toFixed(3)})`;
|
|
3058
|
+
ctx.beginPath(); ctx.arc(sx, sy, lw * 1.3 * (1 - 0.5 * age), 0, Math.PI * 2); ctx.fill();
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
1700
3062
|
// THE HEAD CRACKLES (operator, 2026-09-13: "Did you copy the energy crackle from the grid
|
|
1701
3063
|
// effect, and add to the head of the energy ball travelling along the yellow price line?").
|
|
1702
3064
|
// It had not been. The crackle above is emitted per wire SEGMENT, gated on that segment's
|
|
@@ -1730,9 +3092,9 @@ function priceLine(ctx, view, axes) {
|
|
|
1730
3092
|
for (let m = 1; m <= upto; m++) ctx.lineTo(bp[m].x, bp[m].y);
|
|
1731
3093
|
ctx.stroke();
|
|
1732
3094
|
};
|
|
1733
|
-
bolt(legs, 2.4, `rgba(
|
|
1734
|
-
bolt(legs, 0.95, `rgba(
|
|
1735
|
-
bolt(Math.max(1, legs - 1), 0.5, `rgba(
|
|
3095
|
+
bolt(legs, 2.4, `rgba(255,170,60,${(0.32 * f).toFixed(3)})`);
|
|
3096
|
+
bolt(legs, 0.95, `rgba(255,225,150,${(0.9 * f).toFixed(3)})`);
|
|
3097
|
+
bolt(Math.max(1, legs - 1), 0.5, `rgba(255,255,245,${(0.95 * f).toFixed(3)})`);
|
|
1736
3098
|
}
|
|
1737
3099
|
}
|
|
1738
3100
|
}
|
|
@@ -2394,7 +3756,7 @@ function paintFrame(ctx, geom, frame, opts, view, gridN, blockRows, gridH = grid
|
|
|
2394
3756
|
// name and calls it with a hand-built view -- and the registry simply points at them.
|
|
2395
3757
|
const agentDraw = view.fx?.kind ? AGENTS[view.fx.kind]?.draw : null;
|
|
2396
3758
|
if (agentDraw) agentDraw(ctx, view, ctx.lineWidth, { drawCycles, drawBall, project });
|
|
2397
|
-
else { drawCycles(ctx, view, ctx.lineWidth); drawBall(ctx, view, ctx.lineWidth); }
|
|
3759
|
+
else { drawCycles(ctx, view, ctx.lineWidth); drawBall(ctx, view, ctx.lineWidth); drawFireworks(ctx, view, ctx.lineWidth); drawSupernova(ctx, view, ctx.lineWidth); drawBlackHole(ctx, view, ctx.lineWidth); drawScanCurtain(ctx, view, ctx.lineWidth); }
|
|
2398
3760
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
2399
3761
|
}
|
|
2400
3762
|
|
|
@@ -2784,8 +4146,12 @@ export function render3d(canvas, cells, options = {}) {
|
|
|
2784
4146
|
// Nothing can overtake the parked layout: scheduleFx treats a pending render as `busy` and
|
|
2785
4147
|
// re-arms its timer instead of starting another effect, so an effect cannot chain ahead of a
|
|
2786
4148
|
// refresh that is already waiting.
|
|
2787
|
-
|
|
2788
|
-
|
|
4149
|
+
// the cap runs from the later of the parking and the effect's own start: a layout parked
|
|
4150
|
+
// before an effect was forced (triggerIdle) must still wait that effect out, not lift off
|
|
4151
|
+
// under it because the clock started early
|
|
4152
|
+
const heldSince = st.pendingAt == null ? null : Math.max(st.pendingAt, st.fx?.t0 ?? 0);
|
|
4153
|
+
const fxHolding = (!!fxNow(st, now) || fxResting(st, now)) && sig !== st.sig
|
|
4154
|
+
&& (heldSince == null || now - heldSince < FX_DEFER_MAX);
|
|
2789
4155
|
if (!unchanged && st.plan && !still && st.raf != null && (now < st.plan.settleAt || fxHolding)) {
|
|
2790
4156
|
st.pending = { cells, options };
|
|
2791
4157
|
st.pendingAt ??= now;
|
|
@@ -2856,6 +4222,20 @@ export function render3d(canvas, cells, options = {}) {
|
|
|
2856
4222
|
// uses: the textured sphere is laid over all of it (drawGrid), and an
|
|
2857
4223
|
// arrival starts wholly outside it (offscreenLift)
|
|
2858
4224
|
if (opts.oblique) view.viewRect = obliqueFit(geom.pw, geom.ph, st.gridW, st.gridH, opts).rect;
|
|
4225
|
+
// how far the PANEL reaches beyond the board, so a sweeping front enters and leaves out of
|
|
4226
|
+
// sight instead of appearing in the middle of the picture (fxFront)
|
|
4227
|
+
if (view.fx && view.viewRect) {
|
|
4228
|
+
const r = view.viewRect;
|
|
4229
|
+
view.fx.margin = Math.max(0 - r.x0, r.x1 - st.gridW, 0 - r.y0, r.y1 - st.gridH, 4) + 3;
|
|
4230
|
+
// the scan's beam: its half-width, so fxAt lights exactly the swath the cone covers, and
|
|
4231
|
+
// whether it PANS -- a searchlight sweeping out and back, which is the top-down board's
|
|
4232
|
+
// motion; the price chart keeps its single pass along the hours
|
|
4233
|
+
if (view.fx.kind === 'scan') {
|
|
4234
|
+
const g = scanBeamGeom(view);
|
|
4235
|
+
view.fx.beamR = g.spread;
|
|
4236
|
+
view.fx.pan = !g.price;
|
|
4237
|
+
}
|
|
4238
|
+
}
|
|
2859
4239
|
const frame = frameAt(st.plan, t, view);
|
|
2860
4240
|
paintFrame(ctx, geom, frame, opts, view, st.gridW, st.blockRows, st.gridH);
|
|
2861
4241
|
st.lastFit = frame.__fit ?? st.lastFit;
|
|
@@ -2890,7 +4270,7 @@ export function render3d(canvas, cells, options = {}) {
|
|
|
2890
4270
|
// landed -- cutting the effect off exactly as before. It is also the live path: stars ship on
|
|
2891
4271
|
// by default, so this is the branch a real board takes, and guarding only the entry above
|
|
2892
4272
|
// would have looked correct and done nothing.
|
|
2893
|
-
if (starsOn(opts) && frame.settled && st.pending && !fxNow(st, t)) { const p = st.pending; st.pending = null; st.pendingAt = null; st.raf = null; render3d(canvas, p.cells, p.options); return; }
|
|
4273
|
+
if (starsOn(opts) && frame.settled && st.pending && !fxNow(st, t) && !fxResting(st, t)) { const p = st.pending; st.pending = null; st.pendingAt = null; st.raf = null; render3d(canvas, p.cells, p.options); return; }
|
|
2894
4274
|
// keep the loop alive while the choreography runs OR the camera is moving;
|
|
2895
4275
|
// park otherwise, because repainting a still picture is a heater
|
|
2896
4276
|
if (frame.settled && !st.dirty && !fxNow(st, t)) {
|
|
@@ -2898,13 +4278,18 @@ export function render3d(canvas, cells, options = {}) {
|
|
|
2898
4278
|
// but ONCE, on the frame it arrives, or a running loop would re-arm the timer for ever
|
|
2899
4279
|
if (!st.atRest) {
|
|
2900
4280
|
const afterEffect = st.fx != null; // an effect just ended -- or it has only now settled
|
|
4281
|
+
if (afterEffect) st.fxEndedAt = t; // the rest before a held refresh runs from here
|
|
2901
4282
|
st.fx = null;
|
|
2902
4283
|
st.atRest = true;
|
|
2903
4284
|
scheduleFx(canvas, st, opts, !afterEffect);
|
|
2904
4285
|
}
|
|
2905
4286
|
if (!starsOn(opts) && !glowAnimating(st, t)) {
|
|
2906
4287
|
st.raf = null;
|
|
2907
|
-
if (st.pending) {
|
|
4288
|
+
if (st.pending) {
|
|
4289
|
+
// the loop parks here (no stars to keep it running), so the rest is kept by a timer
|
|
4290
|
+
if (fxResting(st, t)) { setTimeout(() => { if (st.pending && st.raf == null) { const p = st.pending; st.pending = null; st.pendingAt = null; render3d(canvas, p.cells, p.options); } }, FX_REST_MS - (t - st.fxEndedAt) + 20); return; }
|
|
4291
|
+
const p = st.pending; st.pending = null; st.pendingAt = null; render3d(canvas, p.cells, p.options); return;
|
|
4292
|
+
}
|
|
2908
4293
|
return;
|
|
2909
4294
|
}
|
|
2910
4295
|
if (st.pending) { const p = st.pending; st.pending = null; st.pendingAt = null; st.raf = null; render3d(canvas, p.cells, p.options); return; }
|