mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
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:
co-authored by
Claude Fable 5
parent
749af31cc3
commit
95b0786725
@@ -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
|
||||
|
||||
|
||||
@@ -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 ("<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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user