feat(drum_highway_3d): hit FX — sparks, timing colors, lane flashes, kick pulse, open hi-hat (D2) (#697)

- Pooled spark bursts (PORTED highway_3d, pool 160) at the struck lane,
  timing-colored via _timingHex/_classifyTiming (±50ms window, inner 40%
  = on-time); streak-scaled counts (streakFx)
- Lane flashes resurrected as pooled additive gauss-tex quads at the hit
  line (timing-colored; red for wrong hits); kick = full-width quad
- Kick pulse: camera dip + amber floor wash, exp decay, hitFx-scaled
- Approach highlight: lane stripes brighten toward their next note
- Open hi-hat: hh_open renders a warm ring around the gem (closes TODO);
  orthogonal to accent/ghost/flam variants
- Settings: Hit sparks / Timing colours / Streak feedback toggles +
  Hit feedback intensity slider (drum_h3d_bg_*, live-applying)
- All FX resources pooled/shared, disposed in BOTH teardown paths
- Tests: _classifyTiming boundaries + FX defaults (10 total)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-07-02 01:07:50 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent beefd7bc09
commit 56419dc789
5 changed files with 443 additions and 19 deletions
@@ -92,3 +92,26 @@ test('MIDI map: open hi-hat is a first-class piece (46 → hh_open)', () => {
// ±50 ms window matches the 2D drums plugin.
assert.equal(HIT_TOLERANCE_S, 0.05);
});
test('_classifyTiming: OK band is 40% of the window, sign maps early/late', () => {
const { _classifyTiming, HIT_TOLERANCE_S } = load().__test;
const tol = HIT_TOLERANCE_S; // 0.05
assert.equal(_classifyTiming(0, tol), 'OK');
assert.equal(_classifyTiming(tol * 0.4, tol), 'OK'); // boundary inclusive
assert.equal(_classifyTiming(-tol * 0.4, tol), 'OK');
// delta = note.t - now: positive → struck before the note → EARLY.
assert.equal(_classifyTiming(tol * 0.41, tol), 'EARLY');
assert.equal(_classifyTiming(-tol * 0.41, tol), 'LATE');
assert.equal(_classifyTiming(tol, tol), 'EARLY');
assert.equal(_classifyTiming(-tol, tol), 'LATE');
// Degenerate inputs read as on-time rather than throwing.
assert.equal(_classifyTiming(NaN, tol), 'OK');
});
test('FX defaults: hit-FX controls ship enabled', () => {
const { FX_DEFAULTS } = load().__test;
assert.equal(FX_DEFAULTS.sparks, true);
assert.equal(FX_DEFAULTS.timingFx, true);
assert.equal(FX_DEFAULTS.streakFx, true);
assert.equal(FX_DEFAULTS.hitFx, 0.7);
});