|
108 | 108 | // cells barely respond and fast cells light up sharply. |
109 | 109 | const MOMENTUM_REF = 3.0; // |thetaDot| (rad/s) at saturation |
110 | 110 | const MOMENTUM_BOOST = 0.5; // peak additive alpha at saturation |
111 | | - // Path-light flares: once in a while pick two random cells on the |
112 | | - // grid boundary (padding-inclusive) and trace a Bresenham-grid path |
113 | | - // between them; each cell on the path ignites at full alpha after a |
114 | | - // small delay, then fades. Multiple paths can overlap; the per-cell |
115 | | - // lit alpha takes max-blend with the veil base so the path stays |
116 | | - // crisp on top of the existing tint. |
117 | | - const PATH_SPAWN_INTERVAL_MS = 8000; // mean ms between spawns (Poisson) |
118 | | - const PATH_STEP_MS = 110; // delay between successive cell ignitions |
119 | | - const PATH_FADE_MS = 1800; // each cell's fade duration |
| 111 | + // Path-light flares: pick two random cells on the grid boundary |
| 112 | + // (padding-inclusive) with a minimum end-to-end span, trace a |
| 113 | + // Bresenham-grid line between them, and ignite cells along the way. |
| 114 | + // The line itself is 2 cells wide (the Bresenham trace + a single |
| 115 | + // cell offset on the perpendicular). Light then bleeds outward |
| 116 | + // perpendicular to the line, peak alpha decaying with distance and |
| 117 | + // each spread layer ignited a bit later than the one before — so |
| 118 | + // the flare reads as a moving glow that diffuses sideways. Multiple |
| 119 | + // paths can overlap; per-cell lit alpha takes max-blend, then |
| 120 | + // max-blends again with the veil + momentum boost. |
| 121 | + const PATH_SPAWN_INTERVAL_MS = 5000; // mean ms between spawns (Poisson) |
| 122 | + const PATH_STEP_MS = 110; // ms between successive cell ignitions along the line |
| 123 | + const PATH_FADE_MS = 3500; // each cell's fade duration |
| 124 | + const PATH_PEAK_ALPHA = 0.70; // peak alpha at the line core (full opacity ≡ 1) |
| 125 | + const PATH_MIN_LENGTH = 7; // Chebyshev cells; below this we skip & retry |
| 126 | + const PATH_MAX_SPREAD = 3; // perpendicular cells the flare bleeds into |
| 127 | + const PATH_SPREAD_DECAY = 0.60; // peak alpha ratio per cell of spread distance |
| 128 | + const PATH_SPREAD_STEP_MS = 200; // ms delay per cell of spread distance |
120 | 129 |
|
121 | 130 | // ----- Pre-rendered orbital point sprite (true exp decay) ----- |
122 | 131 | const DOT_HALF = 11; |
|
317 | 326 | } |
318 | 327 |
|
319 | 328 | // ----- Path-light flares ----- |
| 329 | + type PathCell = { litAt: number; peak: number }; |
320 | 330 | type PathAnim = { |
321 | | - cellLitAt: Map<string, number>; // cellKey → ms when this cell ignites |
322 | | - endTime: number; // ms when the last cell finishes fading |
| 331 | + cells: Map<string, PathCell>; |
| 332 | + endTime: number; |
323 | 333 | }; |
324 | 334 | const activePaths: PathAnim[] = []; |
325 | 335 |
|
|
331 | 341 | if (side === 2) return [iMin + Math.floor(Math.random() * (iMax - iMin + 1)), jMin]; |
332 | 342 | return [iMin + Math.floor(Math.random() * (iMax - iMin + 1)), jMax]; |
333 | 343 | }; |
334 | | - let [i0, j0] = pickBoundary(); |
335 | | - let [i1, j1] = pickBoundary(); |
336 | | - let safety = 8; |
337 | | - while (i0 === i1 && j0 === j1 && safety-- > 0) { |
| 344 | + // Reject endpoint pairs whose Chebyshev distance falls below |
| 345 | + // PATH_MIN_LENGTH so flares always span a substantial portion of |
| 346 | + // the grid. Bound the retry loop — for very narrow viewports the |
| 347 | + // constraint may be unsatisfiable; we just skip the spawn. |
| 348 | + let i0 = 0, j0 = 0, i1 = 0, j1 = 0; |
| 349 | + let found = false; |
| 350 | + for (let attempt = 0; attempt < 12; attempt++) { |
| 351 | + [i0, j0] = pickBoundary(); |
338 | 352 | [i1, j1] = pickBoundary(); |
| 353 | + const cheb = Math.max(Math.abs(i1 - i0), Math.abs(j1 - j0)); |
| 354 | + if (cheb >= PATH_MIN_LENGTH) { found = true; break; } |
339 | 355 | } |
340 | | - if (i0 === i1 && j0 === j1) return; |
341 | | - |
342 | | - // Bresenham line on the integer grid: each step lands on an |
343 | | - // adjacent cell (4- or 8-neighbor), which on this scale lines up |
344 | | - // with Voronoi adjacency for the seeds. Path ignites cell-by-cell. |
345 | | - const cellLitAt = new Map<string, number>(); |
| 356 | + if (!found) return; |
| 357 | + |
| 358 | + // Per-path perpendicular: 90° rotation of the overall direction |
| 359 | + // sign vector. Constant across the path so the flare reads as a |
| 360 | + // single straight band rather than a zigzag of per-step perps. |
| 361 | + const sgnDI = Math.sign(i1 - i0); |
| 362 | + const sgnDJ = Math.sign(j1 - j0); |
| 363 | + const perpI = -sgnDJ; |
| 364 | + const perpJ = sgnDI; |
| 365 | + |
| 366 | + // Bresenham trace from start to end. |
| 367 | + const linePath: { i: number; j: number }[] = []; |
346 | 368 | const di = Math.abs(i1 - i0); |
347 | 369 | const dj = Math.abs(j1 - j0); |
348 | 370 | const si = i0 < i1 ? 1 : -1; |
349 | 371 | const sj = j0 < j1 ? 1 : -1; |
350 | 372 | let err = di - dj; |
351 | 373 | let i = i0, j = j0; |
352 | | - let step = 0; |
353 | | - // Safety bound just in case. |
354 | 374 | const maxSteps = di + dj + 2; |
355 | | - while (step < maxSteps) { |
356 | | - cellLitAt.set(cellKey(i, j), now + step * PATH_STEP_MS); |
357 | | - step++; |
| 375 | + while (linePath.length < maxSteps) { |
| 376 | + linePath.push({ i, j }); |
358 | 377 | if (i === i1 && j === j1) break; |
359 | 378 | const e2 = 2 * err; |
360 | 379 | if (e2 > -dj) { err -= dj; i += si; } |
361 | 380 | if (e2 < di) { err += di; j += sj; } |
362 | 381 | } |
| 382 | + |
| 383 | + // Core of the band is 2 cells wide: d=0 is the Bresenham trace, |
| 384 | + // d=1 is one perpendicular step. Both ignite at the same moment |
| 385 | + // (no spread delay). For |d| beyond the core, distance from the |
| 386 | + // core grows by 1 per step on each side; that distance feeds both |
| 387 | + // the peak-alpha decay and the per-layer ignition delay so the |
| 388 | + // flare diffuses outward over time. |
| 389 | + const spreadDist = (d: number): number => { |
| 390 | + if (d === 0 || d === 1) return 0; |
| 391 | + return d > 1 ? d - 1 : -d; |
| 392 | + }; |
| 393 | + const cells = new Map<string, PathCell>(); |
| 394 | + let maxSpreadDist = 0; |
| 395 | + for (let s = 0; s < linePath.length; s++) { |
| 396 | + const p = linePath[s]; |
| 397 | + for (let d = -PATH_MAX_SPREAD; d <= PATH_MAX_SPREAD + 1; d++) { |
| 398 | + const ci = p.i + d * perpI; |
| 399 | + const cj = p.j + d * perpJ; |
| 400 | + const dist = spreadDist(d); |
| 401 | + if (dist > maxSpreadDist) maxSpreadDist = dist; |
| 402 | + const peak = PATH_PEAK_ALPHA * Math.pow(PATH_SPREAD_DECAY, dist); |
| 403 | + const litAt = now + s * PATH_STEP_MS + dist * PATH_SPREAD_STEP_MS; |
| 404 | + const key = cellKey(ci, cj); |
| 405 | + const existing = cells.get(key); |
| 406 | + // Earliest non-zero peak wins — a cell touched by a later |
| 407 | + // step would otherwise overwrite the brighter earlier hit. |
| 408 | + if (!existing || peak > existing.peak) { |
| 409 | + cells.set(key, { litAt, peak }); |
| 410 | + } |
| 411 | + } |
| 412 | + } |
363 | 413 | activePaths.push({ |
364 | | - cellLitAt, |
365 | | - endTime: now + (step - 1) * PATH_STEP_MS + PATH_FADE_MS, |
| 414 | + cells, |
| 415 | + endTime: |
| 416 | + now + |
| 417 | + (linePath.length - 1) * PATH_STEP_MS + |
| 418 | + maxSpreadDist * PATH_SPREAD_STEP_MS + |
| 419 | + PATH_FADE_MS, |
366 | 420 | }); |
367 | 421 | } |
368 | 422 |
|
|
560 | 614 | const m = Math.min(1, Math.abs(c.thetaDot) / MOMENTUM_REF); |
561 | 615 | const boost = MOMENTUM_BOOST * m * m; |
562 | 616 | // Path-light: max-blend the largest active path contribution |
563 | | - // for this cell against the veil. Linear fade from 1 → 0 over |
564 | | - // PATH_FADE_MS starting at this cell's individual ignition time. |
| 617 | + // for this cell. Each cell on a path carries its own peak |
| 618 | + // (higher near the line core, lower in the spread halo); fade |
| 619 | + // is linear from peak → 0 across PATH_FADE_MS, starting at the |
| 620 | + // cell's own ignition time. |
565 | 621 | let lit = 0; |
566 | 622 | for (let pi = 0; pi < activePaths.length; pi++) { |
567 | | - const t = activePaths[pi].cellLitAt.get(v.key); |
568 | | - if (t === undefined) continue; |
569 | | - const age = now - t; |
| 623 | + const entry = activePaths[pi].cells.get(v.key); |
| 624 | + if (!entry) continue; |
| 625 | + const age = now - entry.litAt; |
570 | 626 | if (age < 0 || age > PATH_FADE_MS) continue; |
571 | | - const a = 1 - age / PATH_FADE_MS; |
| 627 | + const a = entry.peak * (1 - age / PATH_FADE_MS); |
572 | 628 | if (a > lit) lit = a; |
573 | 629 | } |
574 | 630 | v.fillAlpha = Math.max(Math.min(1, baseAlpha + boost), lit); |
|
0 commit comments