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
co-authored by Claude Fable 5
parent 749af31cc3
commit 95b0786725
5 changed files with 267 additions and 11 deletions
+6
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
+160 -5
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
+71 -6
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);
}
@@ -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);
});