feat(keys_highway_3d): hit FX — vibrancy, timing-colored sparks, hit-line kick (K2) (#699)

- 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 <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos 2026-07-02 08:45:09 +02:00 committed by GitHub
parent 749af31cc3
commit 95b0786725
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 267 additions and 11 deletions

View File

@ -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 01 **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 01 **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/<id>/` 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.

View File

@ -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

View File

@ -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, // 01 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 ("<t.toFixed(3)>|<midi>"), 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

View File

@ -21,6 +21,59 @@
sustains. Applies live; turn off to reclaim GPU headroom on
weak machines.
</p>
<label for="keysh3d-fx-sparks" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="keysh3d-fx-sparks" checked
onchange="window.keys3dSetFx && window.keys3dSetFx('sparks', this.checked)">
Hit sparks
</label>
<p class="text-xs text-gray-500 mt-1">
A small particle burst on every scored key press, beside the
pitch-colored flame.
</p>
<label for="keysh3d-fx-timing" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="keysh3d-fx-timing" checked
onchange="window.keys3dSetFx && window.keys3dSetFx('timingFx', this.checked)">
Timing colours
</label>
<p class="text-xs text-gray-500 mt-1">
Tint the sparks by timing — on-time green, early cyan, late
amber. Off = always green.
</p>
<label for="keysh3d-fx-streak" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="keysh3d-fx-streak" checked
onchange="window.keys3dSetFx && window.keys3dSetFx('streakFx', this.checked)">
Streak feedback
</label>
<p class="text-xs text-gray-500 mt-1">
Spark bursts grow with your combo.
</p>
<label for="keysh3d-fx-hitfx" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
Hit feedback intensity <span id="keysh3d-fx-hitfx-val" class="text-gray-500 font-mono">0.70</span>
</label>
<input type="range" id="keysh3d-fx-hitfx"
min="0" max="1" step="0.05" value="0.7"
oninput="window.keys3dSetFx && window.keys3dSetFx('hitFx', this.value); document.getElementById('keysh3d-fx-hitfx-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1">
Drives the hit-line brightness kick on scored presses. 0 turns
it off.
</p>
<label for="keysh3d-fx-vibrancy" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
Note vibrancy <span id="keysh3d-fx-vibrancy-val" class="text-gray-500 font-mono">0.85</span>
</label>
<input type="range" id="keysh3d-fx-vibrancy"
min="0" max="1" step="0.05" value="0.85"
oninput="window.keys3dSetFx && window.keys3dSetFx('vibrancy', this.value); document.getElementById('keysh3d-fx-vibrancy-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1">
Note-gem solidity and lane-guide strength — lower to see more of
the keyboard through the notes.
</p>
</div>
<script>
@ -33,12 +86,24 @@
// FX toggles (keys3d_bg_* — guitar-parity graphics controls).
// Only explicit values override; absent/corrupt keys keep the
// default (ON), matching screen.js readFxSettings.
const storedBloom = localStorage.getItem('keys3d_bg_bloom');
if (storedBloom === '1' || storedBloom === 'true') {
document.getElementById('keysh3d-fx-bloom').checked = true;
} else if (storedBloom === '0' || storedBloom === 'false') {
document.getElementById('keysh3d-fx-bloom').checked = false;
}
const hydrateFxBool = (key, elId) => {
const raw = localStorage.getItem('keys3d_bg_' + key);
if (raw === '1' || raw === 'true') document.getElementById(elId).checked = true;
else if (raw === '0' || raw === 'false') document.getElementById(elId).checked = false;
};
hydrateFxBool('bloom', 'keysh3d-fx-bloom');
hydrateFxBool('sparks', 'keysh3d-fx-sparks');
hydrateFxBool('timingFx', 'keysh3d-fx-timing');
hydrateFxBool('streakFx', 'keysh3d-fx-streak');
const hydrateFxRange = (key, elId, valId) => {
const n = parseFloat(localStorage.getItem('keys3d_bg_' + key));
if (!Number.isFinite(n)) return;
const v = Math.min(1, Math.max(0, n));
document.getElementById(elId).value = String(v);
document.getElementById(valId).textContent = v.toFixed(2);
};
hydrateFxRange('hitFx', 'keysh3d-fx-hitfx', 'keysh3d-fx-hitfx-val');
hydrateFxRange('vibrancy', 'keysh3d-fx-vibrancy', 'keysh3d-fx-vibrancy-val');
} catch (e) {
console.warn('[Keys-Hwy3D settings] hydration failed:', e);
}

View File

@ -80,3 +80,32 @@ test('keys3dSetFx: persists, coerces, and ignores unknown keys', () => {
assert.equal(events.length, 4);
assert.ok(!('keys3d_bg_nonsense' in store));
});
test('_classifyTiming: OK band is 40% of the window, sign maps early/late', () => {
const { _classifyTiming } = load().slopsmithViz_keys_highway_3d.__test;
const tol = 0.10; // keys HIT_TOLERANCE_S
assert.equal(_classifyTiming(0, tol), 'OK');
assert.equal(_classifyTiming(tol * 0.4, tol), 'OK');
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(NaN, tol), 'OK');
});
test('noteKey prefix round-trips the matched note time (timing-delta source)', () => {
const { noteKey } = load().slopsmithViz_keys_highway_3d.__test;
// _checkHit derives the timing delta as parseFloat(judgeHit's key) - t;
// this pins the serialization that makes that recovery valid.
assert.equal(parseFloat(noteKey(12.3456, 60)), 12.346);
assert.equal(parseFloat(noteKey(0, 21)), 0);
});
test('FX defaults: hit-FX + vibrancy controls ship enabled', () => {
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__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);
assert.equal(FX_DEFAULTS.vibrancy, 0.85);
});