From 95b0786725459eda3518caebaff05f29fd781487 Mon Sep 17 00:00:00 2001
From: Byron Gamatos
Date: Thu, 2 Jul 2026 08:45:09 +0200
Subject: [PATCH] =?UTF-8?q?feat(keys=5Fhighway=5F3d):=20hit=20FX=20?=
=?UTF-8?q?=E2=80=94=20vibrancy,=20timing-colored=20sparks,=20hit-line=20k?=
=?UTF-8?q?ick=20(K2)=20(#699)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Note vibrancy: gem opacity 0.8 -> slider-driven (default 0.92),
NOTE_EMISSIVE_BASE 0.08 -> 0.22, lane guides scale with the slider,
_applyVibrancy retints the live scene without a chart rebuild
- Sparks (PORTED highway_3d, pool 96) at the struck key, colored by
_timingHex/_classifyTiming (±100ms window, inner 40% = on-time; delta
recovered from judgeHit's noteKey prefix — contract untouched);
streak-scaled counts
- Hit-line brightness kick on scored presses (exp(-t*6) decay, hitFx
slider), folded into the existing pulse incl. the bloom damp gate
- Settings: Hit sparks / Timing colours / Streak feedback + Hit feedback
intensity + Note vibrancy sliders (keys3d_bg_*, live-applying)
- Tests: classifier boundaries, noteKey time round-trip, FX defaults
(26 total)
Co-authored-by: Claude Fable 5
---
CHANGELOG.md | 1 +
plugins/keys_highway_3d/README.md | 6 +
plugins/keys_highway_3d/screen.js | 165 +++++++++++++++++-
plugins/keys_highway_3d/settings.html | 77 +++++++-
.../keys_highway_3d/tests/fx_settings.test.js | 29 +++
5 files changed, 267 insertions(+), 11 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e1cc427..7f54e9b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **3D Drum Highway: hit FX — sparks, timing-colored lane flashes, kick camera pulse, approach glow, and open hi-hat notation (parity slice 2).** Striking a pad now *feels* struck: a pooled additive **spark burst** fires at the lane (ported from the guitar highway's Points-cloud system, pool 160), colored by **timing** — on-time green, early cyan, late amber (same `_timingHex` vocabulary as `highway_3d`, classified against the ±50 ms hit window with the inner 40% reading as on-time); with **Streak feedback** on, bursts grow with your combo. The **lane flash** feedback that was removed when note-recoloring landed is resurrected properly: pooled additive quads with a soft gaussian falloff light up the struck lane at the hit line (timing-colored; red for wrong-pad hits), and a **kick** hit fires triple amber bursts across the bar plus a subtle **camera dip + amber floor wash** that decays exponentially. Lanes also glow ahead of time: each stripe brightens as its next note approaches the hit line, so the eye is led to where the next hit lands. **Open hi-hat finally renders distinctly** — `hh_open` chart hits get a thin warm ring around the cymbal gem (standard notation's "o"), closing the long-standing TODO; the flag is orthogonal to accents/ghosts/flams so combined cues stack. All of it is settings-gated (Graphics → Hit sparks / Timing colours / Streak feedback / a 0–1 **Hit feedback intensity** slider driving flashes, approach glow and the kick pulse; everything on by default, `drum_h3d_bg_*` keys, live-applying) and GPU-frugal: every new visual is pooled or shares geometry/materials — zero per-note allocation on top of the per-frame notes rebuild, all registered in both dispose paths (kit-change renderer recreation included). Tests: timing-classifier boundaries + FX defaults added to `plugins/drum_highway_3d/tests/data_layer.test.js` (10 total).
+- **3D Keys Highway: hit FX — vibrant note gems, timing-colored sparks, and a hit-line that reacts to your playing (parity slice 2).** The washed-out note look is gone: gem opacity is now driven by a **Note vibrancy** slider (default 0.85 → opacity 0.92, up from a fixed 0.8; lane guides scale with it too, live-applying without a chart rebuild) and the resting emissive glow rises 0.08 → 0.22, so the falling notes finally read saturated against the dark floor. Scored key presses fire a pooled additive **spark burst** at the struck key (guitar-highway port, pool 96) **colored by timing** — on-time green, early cyan, late amber, classified against the ±100 ms window with the inner 40% reading as on-time (the timing delta is recovered from the matched note's key, so `judgeHit`'s tested contract is untouched); the per-pitch-class flame sprite keeps its identity color so pitch and timing stay separate signals. With **Streak feedback** on, bursts grow with the combo. The **hit line kicks brighter** for a beat on every scored press (exponential decay, scaled by a 0–1 **Hit feedback intensity** slider). All new controls live in the plugin's Graphics settings (on by default, `keys3d_bg_*` keys, live-applying), and the spark pool is disposed with the scene like every other GPU resource. Tests: timing-classifier boundaries, the noteKey time round-trip that the delta recovery relies on, and the new FX defaults (26 total).
- **3D Drum Highway: bloom glow + adaptive-resolution support — the first slice of visual parity with the guitar highway.** The drum highway now renders through the same post-processing path as `highway_3d`: an `UnrealBloomPass` (strength 0.65, radius 0.5, threshold 0.82 — high, so only emissive/bright surfaces bleed) on a multisampled HalfFloat target with ACES filmic tone mapping, so the white hit-line bar and proximity-lit notes get a real glow instead of a flat emissive tint. **On by default**, with a new **Graphics → "Glow (bloom)"** toggle in the plugin settings (`drum_h3d_bg_bloom`, applies live, no reload); if the vendored postprocessing addons can't load (older self-hosted core), the plugin silently falls back to the direct render path. The plugin also now honors the host's **adaptive render scale** (`bundle.renderScale` — the Quality/"Min res" controls that the guitar highway already respected), multiplying it into the device pixel ratio, and caps DPR at 1.25 when more than one viz instance is live (splitscreen) so two panels don't double the GPU fill cost. Groundwork for the rest of the parity series: an FX-settings scaffold (`FX_DEFAULTS`/`readFxSettings`/`window.drumH3dSetFx`, `drum_h3d_bg_*` localStorage keys) that the sparks/themes/backgrounds PRs extend, plus a first node test suite for the plugin (`plugins/drum_highway_3d/tests/data_layer.test.js` — vm-loaded like the keys plugin's, covering the hit-variant precedence, the Auto-mode steal-guard predicate, and FX defaults; 8 tests, runs in CI via the `plugins/*/tests/*.test.js` glob).
- **3D Keys Highway: sharp HiDPI rendering, bloom glow, a live combo HUD, and a graphics settings panel — the first slice of visual parity with the guitar highway.** The biggest single fix is resolution: the plugin never called `setPixelRatio`, so on HiDPI/retina displays (and Windows display scaling) it rendered at CSS resolution and was upscaled — soft and aliased. It now multiplies the device pixel ratio (capped at 2, or 1.25 when two viz panels are live in splitscreen) with the host's **adaptive render scale** (`bundle.renderScale`, the Quality/"Min res" controls), exactly like `highway_3d`. On top of that: the same **bloom** post-processing path as the guitar highway (UnrealBloomPass 0.65/0.5/0.82 on a multisampled HalfFloat target + ACES tone mapping — the cyan hit-line, hit flames and the sustain "consume" glow finally bleed light instead of reading flat), **on by default** with a graceful direct-render fallback when the vendored addons can't load. The plugin gains its first **settings panel** (`settings.html`, Settings → graphics category, `"settings"` block in plugin.json) with a live-applying "Glow (bloom)" toggle (`keys3d_bg_bloom`), plus the FX scaffold (`FX_DEFAULTS`/`readFxSettings`/`window.keys3dSetFx`, `keys3d_bg_*` keys) the later parity PRs extend. And the score state the plugin was already tracking is finally visible: a **combo / accuracy / best-streak HUD** overlay (drum-highway pattern), shown only while a MIDI keyboard session is wired so it never renders a frozen 0× combo. Tests: `plugins/keys_highway_3d/tests/fx_settings.test.js` (defaults, localStorage overrides + type coercion, setter persist/dispatch/unknown-key guard; 3 tests alongside the existing 20).
- **The 3D Drum Highway and 3D Keys Highway are now bundled core plugins** (`plugins/drum_highway_3d/`, `plugins/keys_highway_3d/`), imported from their former standalone repos (`feedBack-plugin-drum-highway-3d`, `feedBack-plugin-keys-highway-3d`, now archived) via `git subtree` so their history is preserved. They join the other in-tree plugins-as-plugins: the loader treats them identically to user-installed ones, both are marked `"bundled": true` in their manifests, and `.gitignore` gains the matching `!plugins//` exceptions. This puts all three 3D highways (guitar, drums, keys) in one repo ahead of a visual-parity pass that ports the guitar highway's polish (bloom, sparks, themes, reactive backgrounds) to the other two — shared helper code and theme tables can now be reviewed and kept in sync in a single place. The keys plugin's existing node test suite is wired into CI (the JS test step gains a `plugins/*/tests/*.test.js` glob, +20 tests), and `static/tailwind.min.css` is regenerated since the core Tailwind build scans `plugins/**`. One deliberate behavior change ships with the bundling: the drum highway's Auto-mode predicate is **narrowed** (it used to claim any pack with `has_drum_tab` — a pack-level flag — which, now that the plugin ships to everyone and sorts before `highway_3d` in first-match-wins Auto order, would have stolen full-band packs from the guitar highway even on Lead/Bass arrangements; it now claims only drum arrangements, or packs nothing more specific can render). Picking the drum highway manually from the viz picker is unchanged.
diff --git a/plugins/keys_highway_3d/README.md b/plugins/keys_highway_3d/README.md
index e7f9c85..7f8fd51 100644
--- a/plugins/keys_highway_3d/README.md
+++ b/plugins/keys_highway_3d/README.md
@@ -31,6 +31,12 @@ tunes one of these, mirror the change here (and in `drum_highway_3d`):
mapping switch in `draw()`; addons dynamic-imported from
`/static/vendor/three/addons/` (no CDN fallback — direct render is the
graceful degrade)
+- `_sparkBurst()` / `_sparkUpdate()` — pooled additive Points hit sparks
+ (pool 96 here — the flame sprites carry most of the hit feedback)
+- `_timingHex()` / `_classifyTiming()` — early/late/on-time feedback
+ colors (green/cyan/amber) + the 40%-window classifier
+- `_ssActive()` — host splitscreen probe (minus the guitar's focus-API
+ checks, which it needs for input routing and we don't)
## License
diff --git a/plugins/keys_highway_3d/screen.js b/plugins/keys_highway_3d/screen.js
index 6e392af..f642551 100644
--- a/plugins/keys_highway_3d/screen.js
+++ b/plugins/keys_highway_3d/screen.js
@@ -88,7 +88,10 @@
// on their edges — the RS+ reference look.
const NOTE_H = 4 * K;
const NOTE_BEVEL = 0.55 * K;
- const NOTE_EMISSIVE_BASE = 0.08; // resting note glow
+ // Resting note glow. Raised from the original 0.08 in the hit-FX parity
+ // pass — combined with the vibrancy-driven opacity it fixes the
+ // washed-out, semi-transparent look the note gems had.
+ const NOTE_EMISSIVE_BASE = 0.22;
const CONSUME_GLOW = 5.0; // peak glow as a note is eaten at the hit-line
const LABEL_FADE_DIST = 80 * K; // note-name fades out over this distance past the hit-line (~0.6s)
// Gem vertical gradient (bottom shade → top highlight), baked per-vertex into
@@ -869,6 +872,17 @@
_writeStore(STORE_KEYS.transpose, String(_cfg.transpose));
};
+ // Classify a hit's timing against its matched chart note. delta =
+ // note.t - now: positive → struck before the note crossed the line
+ // (EARLY), negative → after (LATE); the inner 40% of the hit window
+ // reads as on-time. PORTED FROM drum_highway_3d (same proportions as
+ // highway_3d's timing verdicts) — keep in sync.
+ function _classifyTiming(delta, tol) {
+ if (!Number.isFinite(delta) || !Number.isFinite(tol)) return 'OK';
+ if (Math.abs(delta) <= tol * 0.4) return 'OK';
+ return delta > 0 ? 'EARLY' : 'LATE';
+ }
+
// Host splitscreen state (PORTED FROM highway_3d _ssActive, minus the
// focus-API checks the guitar needs for input routing — here it only
// gates GPU cost, so "is a split active at all" is the right question;
@@ -889,6 +903,11 @@
// Everything defaults ON — the settings screen is the opt-out.
const FX_DEFAULTS = {
bloom: true,
+ sparks: true, // pooled hit-spark bursts at the struck key
+ timingFx: true, // early/late/on-time coloring of the sparks
+ streakFx: true, // consecutive-hit escalation (bigger bursts)
+ hitFx: 0.7, // 0–1 master intensity for the hit-line kick
+ vibrancy: 0.85, // note-gem opacity + lane-guide strength
};
const FX_LS_PREFIX = 'keys3d_bg_';
@@ -906,7 +925,10 @@
else if (raw === '0' || raw === 'false') fx[k] = false;
} else {
const n = parseFloat(raw);
- if (Number.isFinite(n)) fx[k] = n;
+ // All numeric FX keys are 0-1 sliders — clamp so a
+ // corrupt/foreign write can't overdrive opacities
+ // or the camera pulse.
+ if (Number.isFinite(n)) fx[k] = Math.min(1, Math.max(0, n));
}
}
} catch (_) { /* localStorage unavailable — use defaults */ }
@@ -926,6 +948,7 @@
} else {
v = Number(value);
if (!Number.isFinite(v)) return;
+ v = Math.min(1, Math.max(0, v)); // all numeric FX keys are 0-1
}
try {
localStorage.setItem(FX_LS_PREFIX + key, typeof v === 'boolean' ? (v ? '1' : '0') : String(v));
@@ -1145,6 +1168,15 @@
let _hudEl = null;
let _hudParentOrigPosition = null;
+ // Hit-FX state. Sparks are PORTED FROM highway_3d (keep in sync);
+ // pool 96 — keys hits also fire a flame sprite, so sparks are the
+ // accent, not the whole show.
+ const _SPARK_N = 96;
+ let _sparkPts = null, _sparkPos = null, _sparkCol = null, _sparkVel = null, _sparkLife = null;
+ let _fxLastWall = 0; // wall clock for FX integration
+ let _hitGlowKick = 0; // hit-line brightness kick, decays exp(-t*6)
+ const _laneGuideMats = []; // lane guide materials (vibrancy slider)
+
// ── MIDI scoring + live feedback state ──────────────────────────
let _layoutInfo = null; // {layout, whiteCount} of current chart
let _hits = 0, _misses = 0, _streak = 0, _bestStreak = 0;
@@ -1269,6 +1301,20 @@
_flamesGroup = new T.Group();
scene.add(keyboardGroup, notesGroup, markersGroup, _flamesGroup);
_buildFlamePool();
+
+ // Hit sparks (PORTED FROM highway_3d): pooled additive Points
+ // cloud, burst at the struck key, integrated in draw(). Same
+ // coordinate space as the flames (keyX / hit-line z).
+ _sparkPos = new Float32Array(_SPARK_N * 3); _sparkCol = new Float32Array(_SPARK_N * 3);
+ _sparkVel = new Float32Array(_SPARK_N * 3); _sparkLife = new Float32Array(_SPARK_N);
+ {
+ const sg = new T.BufferGeometry();
+ sg.setAttribute('position', new T.BufferAttribute(_sparkPos, 3).setUsage(T.DynamicDrawUsage));
+ sg.setAttribute('color', new T.BufferAttribute(_sparkCol, 3).setUsage(T.DynamicDrawUsage));
+ const sm = new T.PointsMaterial({ size: 1.0 * K, vertexColors: true, transparent: true, opacity: 0.8, depthWrite: false, blending: T.AdditiveBlending, sizeAttenuation: true });
+ _sparkPts = new T.Points(sg, sm); _sparkPts.frustumCulled = false; _sparkPts.renderOrder = 8;
+ scene.add(_sparkPts);
+ }
}
// Drop every child of a group (recursively — key glyphs are children
@@ -1396,12 +1442,35 @@
roughness: 0.78, // matte — kills the glossy "plastic" highlight
metalness: 0.0,
transparent: true,
- opacity: 0.8, // see the keys through the notes
+ // Vibrancy-driven: 0.72 (airy, keys clearly visible through
+ // the gems) → 0.94 (solid, saturated). The old fixed 0.8
+ // read washed-out against the dim floor.
+ opacity: _noteOpacity(),
});
_noteMatCache.set(key, mat);
return mat;
}
+ function _noteOpacity() {
+ return 0.72 + 0.22 * Math.min(1, Math.max(0, fx.vibrancy));
+ }
+ function _laneGuideOpacity() {
+ return 0.10 + 0.12 * Math.min(1, Math.max(0, fx.vibrancy));
+ }
+
+ // Live vibrancy slider: retint everything already built — the
+ // per-note clones, the material cache (future clones), and the lane
+ // guides — without a chart rebuild.
+ function _applyVibrancy() {
+ const op = _noteOpacity();
+ for (const m of _noteMatCache.values()) m.opacity = op;
+ for (const nm of noteMeshes) {
+ if (nm.mesh && nm.mesh.material) nm.mesh.material.opacity = op;
+ }
+ const lop = _laneGuideOpacity();
+ for (const m of _laneGuideMats) m.opacity = lop;
+ }
+
function _barNumberTexture(idx) {
let tex = _barTexCache.get(idx);
if (tex) return tex;
@@ -1492,6 +1561,59 @@
}
}
+ // Hit sparks (PORTED FROM highway_3d _sparkBurst/_sparkUpdate — keep
+ // in sync): pooled additive Points; a timing-colored burst fires at
+ // the struck key alongside the flame sprite.
+ function _sparkBurst(x, y, z, hex, count) {
+ if (!_sparkPts || count <= 0) return;
+ const r = ((hex >> 16) & 255) / 255, g = ((hex >> 8) & 255) / 255, b = (hex & 255) / 255;
+ let made = 0;
+ for (let i = 0; i < _SPARK_N && made < count; i++) {
+ if (_sparkLife[i] > 0) continue;
+ const j = i * 3, ang = Math.random() * Math.PI * 2, sp = (5 + Math.random() * 12) * K;
+ _sparkPos[j] = x; _sparkPos[j + 1] = y; _sparkPos[j + 2] = z;
+ _sparkVel[j] = Math.cos(ang) * sp; _sparkVel[j + 1] = (12 + Math.random() * 24) * K; _sparkVel[j + 2] = Math.sin(ang) * sp * 0.55;
+ _sparkCol[j] = r; _sparkCol[j + 1] = g; _sparkCol[j + 2] = b;
+ _sparkLife[i] = 0.30 + Math.random() * 0.16; made++;
+ }
+ }
+ function _sparkUpdate(dt) {
+ if (!_sparkPts) return;
+ const grav = 55 * K; let any = false;
+ for (let i = 0; i < _SPARK_N; i++) {
+ if (_sparkLife[i] <= 0) continue;
+ const j = i * 3;
+ _sparkLife[i] -= dt;
+ if (_sparkLife[i] <= 0) { _sparkCol[j] = _sparkCol[j + 1] = _sparkCol[j + 2] = 0; continue; }
+ any = true;
+ _sparkVel[j + 1] -= grav * dt;
+ _sparkPos[j] += _sparkVel[j] * dt; _sparkPos[j + 1] += _sparkVel[j + 1] * dt; _sparkPos[j + 2] += _sparkVel[j + 2] * dt;
+ const fade = 1 - Math.min(1, dt * 3.2);
+ _sparkCol[j] *= fade; _sparkCol[j + 1] *= fade; _sparkCol[j + 2] *= fade;
+ }
+ _sparkPts.geometry.attributes.position.needsUpdate = true;
+ _sparkPts.geometry.attributes.color.needsUpdate = true;
+ _sparkPts.visible = any;
+ }
+
+ // Timing → color (PORTED FROM highway_3d _timingHex — keep in sync).
+ function _timingHex(ts) {
+ if (!fx.timingFx || !ts || ts === 'OK') return 0x22ff88;
+ if (ts === 'EARLY') return 0x35d6ff;
+ if (ts === 'LATE') return 0xffb84d;
+ return 0x22ff88;
+ }
+
+ function _spawnSparks(midi, ts) {
+ if (!fx.sparks || !_sparkPts || !_layoutInfo) return;
+ const entry = _layoutInfo.layout.get(midi);
+ if (!entry) return;
+ let count = 8;
+ if (fx.streakFx) count += Math.round(8 * Math.min(1, _streak / 16));
+ const y = (entry.black ? BLACK_H + WHITE_H * 0.6 : WHITE_H) + 1 * K;
+ _sparkBurst(keyX(entry, _layoutInfo.whiteCount), y, -WHITE_L / 2, _timingHex(ts), count);
+ }
+
function _spawnFlame(midi, wallNow) {
if (!_layoutInfo || !_flamePool.length) return;
const entry = _layoutInfo.layout.get(midi);
@@ -1509,6 +1631,7 @@
function buildKeyboardAndHighway() {
_clearGroup(keyboardGroup);
_hitGlowMats.length = 0;
+ _laneGuideMats.length = 0;
keyMeshes = new Map();
_keyAnim.clear();
_keyFlash.clear();
@@ -1574,8 +1697,9 @@
if (entry.black) continue; // one strip per semitone-slot lands on whites
const gmat = new T.MeshBasicMaterial({
color: noteColor(midi, 'rh'), transparent: true,
- opacity: 0.16, depthWrite: false,
+ opacity: _laneGuideOpacity(), depthWrite: false,
});
+ _laneGuideMats.push(gmat);
const strip = new T.Mesh(new T.PlaneGeometry(WHITE_W * 0.84, guideLen), gmat);
strip.rotation.x = -Math.PI / 2;
strip.position.set(keyX(entry, whiteCount), laneY, hitZ - guideLen / 2);
@@ -1806,6 +1930,13 @@
if (_streak > _bestStreak) _bestStreak = _streak;
_keyFlash.delete(playedMidi); // a hit cancels a lingering red
_spawnFlame(playedMidi, wall);
+ // Timing verdict: noteKey() serializes the matched note's t
+ // as its prefix ("|"), so parseFloat
+ // recovers it without changing judgeHit's tested contract.
+ const ts = _classifyTiming(parseFloat(key) - t, HIT_TOLERANCE_S);
+ _spawnSparks(playedMidi, ts);
+ // Brief hit-line brightness kick (decays exp(-t*6) in draw).
+ _hitGlowKick = 1;
_ndReport(true, playedMidi, _ndBindingId);
} else {
_misses++;
@@ -2044,7 +2175,10 @@
// splitscreen checks) — damping on the direct-render path would
// leave the hit line visibly dimmer.
const glowScale = (_bloomGateOk() && _composer) ? 0.45 : 1;
- const pulse = (0.72 + 0.18 * Math.sin(now * 5.0)) * glowScale;
+ // Base pulse + the per-hit brightness kick (decayed in draw()'s
+ // wall-clock FX step, scaled by the Hit feedback slider).
+ const pulse = Math.min(1,
+ (0.72 + 0.18 * Math.sin(now * 5.0) + 0.5 * _hitGlowKick * fx.hitFx) * glowScale);
for (let i = 0; i < _hitGlowMats.length; i++) _hitGlowMats[i].opacity = pulse;
// Missed-note sweep — only while a MIDI device is connected
@@ -2340,6 +2474,13 @@
_clearGroup(keyboardGroup);
_clearGroup(_flamesGroup);
_flamePool.length = 0;
+ if (_sparkPts) {
+ try { _sparkPts.geometry.dispose(); _sparkPts.material.dispose(); } catch (_) {}
+ _sparkPts = null;
+ _sparkPos = _sparkCol = _sparkVel = _sparkLife = null;
+ }
+ _laneGuideMats.length = 0; // owned + disposed with keyboardGroup
+ _hitGlowKick = 0;
_clearNoteCaches();
_clearBarTextures();
_clearFlameTextures();
@@ -2386,6 +2527,9 @@
for (const k of Object.keys(d.fx)) {
if (k in FX_DEFAULTS) fx[k] = d.fx[k];
}
+ // Vibrancy is baked into built materials — retint
+ // the live scene without a chart rebuild.
+ if ('vibrancy' in d.fx) _applyVibrancy();
};
window.addEventListener('keys3d:settings', _fxHandler);
_injectHud();
@@ -2438,6 +2582,16 @@
const now = (bundle && typeof bundle.currentTime === 'number') ? bundle.currentTime : 0;
if (_notation) updateScene(now);
_animateFeedback(performance.now());
+ // Wall-clock FX step (sparks, hit-line kick decay) —
+ // decoupled from song time so effects settle while paused.
+ {
+ const nowMs = performance.now();
+ const fdt = _fxLastWall === 0 ? 1 / 60 : Math.min(0.05, (nowMs - _fxLastWall) / 1000);
+ _fxLastWall = nowMs;
+ _sparkUpdate(fdt);
+ if (_hitGlowKick > 0.001) _hitGlowKick *= Math.exp(-fdt * 6);
+ else if (_hitGlowKick !== 0) _hitGlowKick = 0;
+ }
_refreshHud();
// Bloom path (PORTED FROM highway_3d): composer + ACES tone
// mapping when enabled and single-instance; direct render
@@ -2534,6 +2688,7 @@
sweepMissed,
readFxSettings,
FX_DEFAULTS,
+ _classifyTiming,
};
// Headless verification hook: lets Playwright drive synthetic note-ons
diff --git a/plugins/keys_highway_3d/settings.html b/plugins/keys_highway_3d/settings.html
index c2bf26a..d75e65c 100644
--- a/plugins/keys_highway_3d/settings.html
+++ b/plugins/keys_highway_3d/settings.html
@@ -21,6 +21,59 @@
sustains. Applies live; turn off to reclaim GPU headroom on
weak machines.
+
+
+
+ A small particle burst on every scored key press, beside the
+ pitch-colored flame.
+
+
+
+
+ Tint the sparks by timing — on-time green, early cyan, late
+ amber. Off = always green.
+
+
+
+
+ Spark bursts grow with your combo.
+
+
+
+
+
+ Drives the hit-line brightness kick on scored presses. 0 turns
+ it off.
+
+
+
+
+
+ Note-gem solidity and lane-guide strength — lower to see more of
+ the keyboard through the notes.
+