|
121 | 121 | const PATH_SPAWN_INTERVAL_MS = 5000; // mean ms between spawns (Poisson) |
122 | 122 | const PATH_STEP_MS = 110; // ms between successive cell ignitions along the line |
123 | 123 | const PATH_FADE_MS = 3500; // each cell's fade duration |
| 124 | + const PATH_FADE_GAMMA = 0.5; // <1 sustains brightness, falls fast at end (sqrt curve) |
124 | 125 | const PATH_PEAK_ALPHA = 0.70; // peak alpha at the line core (full opacity ≡ 1) |
125 | 126 | const PATH_MIN_LENGTH = 7; // Chebyshev cells; below this we skip & retry |
126 | 127 | const PATH_MAX_SPREAD = 3; // perpendicular cells the flare bleeds into |
127 | 128 | const PATH_SPREAD_DECAY = 0.60; // peak alpha ratio per cell of spread distance |
128 | 129 | const PATH_SPREAD_STEP_MS = 200; // ms delay per cell of spread distance |
| 130 | + const PATH_LIT_KICK = 2.0; // angular accel (rad/s²) per unit lit alpha — drives the cell's pendulum |
129 | 131 |
|
130 | 132 | // ----- Pre-rendered orbital point sprite (true exp decay) ----- |
131 | 133 | const DOT_HALF = 11; |
|
489 | 491 | const flatNeeded = 2 * cellCount; |
490 | 492 | if (flatBuf.length < flatNeeded) flatBuf = new Float64Array(flatNeeded * 2); |
491 | 493 |
|
492 | | - type V = { cx: number; cy: number; r: number; px: number; py: number; c: Cell; key: string; fill: string; fillAlpha: number }; |
| 494 | + type V = { cx: number; cy: number; r: number; px: number; py: number; c: Cell; lit: number; fill: string; fillAlpha: number }; |
493 | 495 | const visible: V[] = []; |
494 | 496 | let n = 0; |
495 | 497 |
|
496 | 498 | for (let j = jMin; j <= jMax; j++) { |
497 | 499 | for (let i = iMin; i <= iMax; i++) { |
498 | 500 | const c = ensureCell(i, j); |
| 501 | + |
| 502 | + // Path-lit alpha for this cell at `now`. Computed here (before |
| 503 | + // physics integration) so it can both drive the pendulum kick |
| 504 | + // and be stored on V for the render pass — single lookup |
| 505 | + // instead of two. Non-linear fade: pow(1 - age/fade, gamma) |
| 506 | + // with gamma < 1 sustains brightness through most of fadeMs |
| 507 | + // and snaps off at the end. |
| 508 | + let curLit = 0; |
| 509 | + if (activePaths.length > 0) { |
| 510 | + const key = cellKey(i, j); |
| 511 | + for (let pi = 0; pi < activePaths.length; pi++) { |
| 512 | + const entry = activePaths[pi].cells.get(key); |
| 513 | + if (!entry) continue; |
| 514 | + const age = now - entry.litAt; |
| 515 | + if (age < 0 || age > PATH_FADE_MS) continue; |
| 516 | + const a = entry.peak * Math.pow(1 - age / PATH_FADE_MS, PATH_FADE_GAMMA); |
| 517 | + if (a > curLit) curLit = a; |
| 518 | + } |
| 519 | + } |
| 520 | + |
499 | 521 | const cwx = (i + 0.5) * CELL_SIZE; |
500 | 522 | const cwy = (j + 0.5) * CELL_SIZE; |
501 | 523 | const cx = cwx - camX + halfW; |
|
513 | 535 | const tc = Math.atan2(dy, dx); |
514 | 536 | dd += -k * Math.sin(c.theta - tc); |
515 | 537 | } |
| 538 | + // Path-lit kick: angular accel proportional to current lit |
| 539 | + // alpha, in the cell's natural rotation direction. As the |
| 540 | + // flare advances, lit cells spin up; combined with the |
| 541 | + // existing momentum→alpha boost in the bbox loop, a lit |
| 542 | + // cell visibly rolls and stays bright via positive feedback. |
| 543 | + if (curLit > 0) { |
| 544 | + dd += Math.sign(c.omegaBase) * curLit * PATH_LIT_KICK; |
| 545 | + } |
516 | 546 | c.thetaDot += dd * stepDt; |
517 | 547 | c.theta += c.thetaDot * stepDt; |
518 | 548 | } |
|
523 | 553 | flatBuf[n * 2 + 1] = py; |
524 | 554 | // fill / fillAlpha are filled in below, after Voronoi build, |
525 | 555 | // when each cell's polygon-canvas overlap is known. |
526 | | - visible.push({ cx, cy, r, px, py, c, key: cellKey(i, j), fill: '', fillAlpha: 0 }); |
| 556 | + visible.push({ cx, cy, r, px, py, c, lit: curLit, fill: '', fillAlpha: 0 }); |
527 | 557 | n++; |
528 | 558 | } |
529 | 559 | } |
|
613 | 643 | // fully decayed cells light up under the cursor. |
614 | 644 | const m = Math.min(1, Math.abs(c.thetaDot) / MOMENTUM_REF); |
615 | 645 | const boost = MOMENTUM_BOOST * m * m; |
616 | | - // Path-light: max-blend the largest active path contribution |
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. |
621 | | - let lit = 0; |
622 | | - for (let pi = 0; pi < activePaths.length; pi++) { |
623 | | - const entry = activePaths[pi].cells.get(v.key); |
624 | | - if (!entry) continue; |
625 | | - const age = now - entry.litAt; |
626 | | - if (age < 0 || age > PATH_FADE_MS) continue; |
627 | | - const a = entry.peak * (1 - age / PATH_FADE_MS); |
628 | | - if (a > lit) lit = a; |
629 | | - } |
630 | | - v.fillAlpha = Math.max(Math.min(1, baseAlpha + boost), lit); |
| 646 | + // Path-light contribution was already computed in the visible |
| 647 | + // loop (so the same value drove the pendulum kick); reuse it |
| 648 | + // here as a max-blend over the veil + momentum base. |
| 649 | + v.fillAlpha = Math.max(Math.min(1, baseAlpha + boost), v.lit); |
631 | 650 | v.fill = `rgba(${ACCENT[0]},${ACCENT[1]},${ACCENT[2]},${v.fillAlpha})`; |
632 | 651 |
|
633 | 652 | bboxesBuf[k * 4] = Math.max(0, Math.floor(bbMinX)); |
|
0 commit comments