mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
feat(keys_highway_3d): foundation for guitar-highway visual parity (K1) (#696)
- setPixelRatio at last: DPR (cap 2; 1.25 when >1 instance) x host adaptive bundle.renderScale — HiDPI displays were rendering at CSS resolution and upscaling (soft/aliased) - Bloom: port _bloomEnsure/_bloomDispose (UnrealBloomPass 0.65/0.5/0.82, MSAA HalfFloat target, ACES<->None switch); hit-line, flames and consume-glow benefit immediately; direct render is the degrade path - First settings panel: settings.html (graphics category) with a live Glow (bloom) toggle; FX scaffold (FX_DEFAULTS/readFxSettings/ window.keys3dSetFx, keys3d_bg_* keys, keys3d:settings event) - Combo/accuracy/best-streak DOM HUD (drum_highway_3d pattern), gated on a live MIDI session - tests/fx_settings.test.js (3 tests; vm harness) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a7ea719652
commit
beefd7bc09
@@ -19,6 +19,19 @@ RS+-style falling-note 3D piano highway for [Slopsmith](https://github.com/got-f
|
||||
node --test tests/*.test.js
|
||||
```
|
||||
|
||||
## Ported helpers (keep in sync with highway_3d)
|
||||
|
||||
Visual-parity code copied from `plugins/highway_3d/screen.js` — same
|
||||
function names, signatures, and constants on purpose, marked with
|
||||
`PORTED FROM highway_3d` comments at each site. If the guitar highway
|
||||
tunes one of these, mirror the change here (and in `drum_highway_3d`):
|
||||
|
||||
- `_bloomEnsure()` / `_bloomDispose()` — EffectComposer + UnrealBloomPass
|
||||
(0.65/0.5/0.82) on a multisampled HalfFloat target, ACES↔None tone-
|
||||
mapping switch in `draw()`; addons dynamic-imported from
|
||||
`/static/vendor/three/addons/` (no CDN fallback — direct render is the
|
||||
graceful degrade)
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0.
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
"settings": {
|
||||
"html": "settings.html",
|
||||
"category": "graphics"
|
||||
},
|
||||
"standards": [
|
||||
"capability-pipelines.v1",
|
||||
"plugin-runtime-idempotent.v1"
|
||||
|
||||
@@ -869,6 +869,72 @@
|
||||
_writeStore(STORE_KEYS.transpose, String(_cfg.transpose));
|
||||
};
|
||||
|
||||
// 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;
|
||||
// a mixed split (this viz + another renderer) must count too).
|
||||
function _ssActive() {
|
||||
const ss = window.feedBackSplitscreen;
|
||||
return !!(ss && typeof ss.isActive === 'function' && ss.isActive());
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Visual-FX settings — guitar-highway parity controls
|
||||
* ====================================================================== */
|
||||
|
||||
// Defaults for the graphics/FX controls this plugin exposes. Keys mirror
|
||||
// the guitar highway's `h3d_bg_*` vocabulary under this plugin's own
|
||||
// `keys3d_bg_*` localStorage prefix; later parity PRs (sparks, themes,
|
||||
// background styles, score FX) extend this object with their own keys.
|
||||
// Everything defaults ON — the settings screen is the opt-out.
|
||||
const FX_DEFAULTS = {
|
||||
bloom: true,
|
||||
};
|
||||
const FX_LS_PREFIX = 'keys3d_bg_';
|
||||
|
||||
function readFxSettings() {
|
||||
const fx = Object.assign({}, FX_DEFAULTS);
|
||||
try {
|
||||
for (const k of Object.keys(FX_DEFAULTS)) {
|
||||
const raw = localStorage.getItem(FX_LS_PREFIX + k);
|
||||
if (raw === null) continue;
|
||||
if (typeof FX_DEFAULTS[k] === 'boolean') {
|
||||
// Explicit values only — anything else (corrupt/foreign
|
||||
// write) keeps the default rather than silently
|
||||
// disabling an effect.
|
||||
if (raw === '1' || raw === 'true') fx[k] = true;
|
||||
else if (raw === '0' || raw === 'false') fx[k] = false;
|
||||
} else {
|
||||
const n = parseFloat(raw);
|
||||
if (Number.isFinite(n)) fx[k] = n;
|
||||
}
|
||||
}
|
||||
} catch (_) { /* localStorage unavailable — use defaults */ }
|
||||
return fx;
|
||||
}
|
||||
|
||||
// Single setter for every FX key — settings.html calls
|
||||
// window.keys3dSetFx('bloom', checked). Coerces to the default's type so
|
||||
// a slider string can't poison a boolean toggle.
|
||||
window.keys3dSetFx = function (key, value) {
|
||||
if (!(key in FX_DEFAULTS)) return;
|
||||
let v;
|
||||
if (typeof FX_DEFAULTS[key] === 'boolean') {
|
||||
// Same accepted representations as readFxSettings so the
|
||||
// setter/reader round-trip is consistent ('0'/'false' → false).
|
||||
v = value === true || value === 1 || value === '1' || value === 'true';
|
||||
} else {
|
||||
v = Number(value);
|
||||
if (!Number.isFinite(v)) return;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(FX_LS_PREFIX + key, typeof v === 'boolean' ? (v ? '1' : '0') : String(v));
|
||||
} catch (_) {}
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('keys3d:settings', { detail: { fx: { [key]: v } } }));
|
||||
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
||||
};
|
||||
|
||||
/* ======================================================================
|
||||
* Capability wiring — note-detection + audio-input domains
|
||||
*
|
||||
@@ -1063,6 +1129,22 @@
|
||||
let _glowTex = null; // hit-line gradient (lazy, reused)
|
||||
const _hitGlowMats = []; // additive glow materials pulsed in draw()
|
||||
|
||||
// ── Visual FX state (guitar-highway parity) ─────────────────────
|
||||
let fx = readFxSettings(); // live snapshot, mutated by 'keys3d:settings'
|
||||
let _fxHandler = null;
|
||||
// Host adaptive-quality scale (bundle.renderScale, 0.25–1) —
|
||||
// multiplied into the device pixel ratio like highway_3d does.
|
||||
let _renderScale = 1;
|
||||
// Bloom composer state (PORTED FROM highway_3d/screen.js — keep in sync).
|
||||
let _composer = null;
|
||||
let _bloomPass = null;
|
||||
let _bloomLoad = null;
|
||||
let _bloomW = 0, _bloomH = 0;
|
||||
let _bloomGen = 0; // bumped by _bloomDispose so stale loads no-op
|
||||
// DOM HUD (combo / accuracy / best streak — drum_highway_3d pattern).
|
||||
let _hudEl = null;
|
||||
let _hudParentOrigPosition = null;
|
||||
|
||||
// ── MIDI scoring + live feedback state ──────────────────────────
|
||||
let _layoutInfo = null; // {layout, whiteCount} of current chart
|
||||
let _hits = 0, _misses = 0, _streak = 0, _bestStreak = 0;
|
||||
@@ -1162,6 +1244,13 @@
|
||||
|
||||
function buildScene() {
|
||||
scene = new T.Scene();
|
||||
// Explicit scene.background (not just the renderer clear color):
|
||||
// the bloom EffectComposer's RenderPass + OutputPass handle a
|
||||
// scene background color-space-correctly, while a bare clear
|
||||
// color comes out visibly lightened under the ACES output
|
||||
// transform (washed-out purple instead of the dark navy).
|
||||
// drum_highway_3d / highway_3d set it the same way.
|
||||
scene.background = new T.Color(FOG_COLOR);
|
||||
scene.fog = new T.Fog(FOG_COLOR, FOG_START, FOG_END);
|
||||
|
||||
cam = new T.PerspectiveCamera(CAM_FOV, 1, 0.1, 2000 * K);
|
||||
@@ -1946,8 +2035,16 @@
|
||||
entry.sprite.position.z = z;
|
||||
}
|
||||
// Gentle hit-line pulse — two material opacity writes, no
|
||||
// allocations.
|
||||
const pulse = 0.72 + 0.18 * Math.sin(now * 5.0);
|
||||
// allocations. With bloom active the additive planes accumulate
|
||||
// into an HDR target and the UnrealBloomPass amplifies them —
|
||||
// full-strength they flood the whole keyboard white — so damp
|
||||
// the pulse on that path; the composer's glow makes up the
|
||||
// difference.
|
||||
// Must mirror draw()'s composer gate exactly (incl. the
|
||||
// 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;
|
||||
for (let i = 0; i < _hitGlowMats.length; i++) _hitGlowMats[i].opacity = pulse;
|
||||
|
||||
// Missed-note sweep — only while a MIDI device is connected
|
||||
@@ -2049,14 +2146,183 @@
|
||||
_ndBindingId = bindingId;
|
||||
}
|
||||
|
||||
// The one condition under which the bloom composer path may render:
|
||||
// enabled, single instance of this viz, and no host splitscreen
|
||||
// (mixed splits with another renderer included). updateScene's
|
||||
// hit-line damping and draw()'s render tail must agree on this.
|
||||
function _bloomGateOk() {
|
||||
return fx.bloom && _instances.size === 1 && !_ssActive();
|
||||
}
|
||||
|
||||
/* ── Bloom (PORTED FROM highway_3d/screen.js _bloomEnsure — keep in
|
||||
* sync; deliberate delta: this copy tracks pixel-ratio changes via
|
||||
* composer.setPixelRatio (here and in applySize) because renderScale
|
||||
* changes the ratio at runtime — the upstream composer never learns
|
||||
* about ratio changes after construction, a candidate fix to port
|
||||
* back to highway_3d) ── */
|
||||
// Lazy-load the vendored postprocessing addons and build an
|
||||
// EffectComposer (RenderPass -> UnrealBloomPass -> OutputPass/ACES).
|
||||
// Returns the composer once ready, or null (caller falls back to a
|
||||
// direct render — also the permanent path if the addons are missing,
|
||||
// e.g. an older self-hosted core without static/vendor/three/addons).
|
||||
function _bloomEnsure() {
|
||||
if (_composer) return _composer;
|
||||
if (_bloomLoad || !ren || !scene || !cam) return null;
|
||||
const A = '/static/vendor/three/addons/';
|
||||
const myGen = _bloomGen; // superseded by any _bloomDispose()
|
||||
_bloomLoad = Promise.all([
|
||||
import(A + 'postprocessing/EffectComposer.js'),
|
||||
import(A + 'postprocessing/RenderPass.js'),
|
||||
import(A + 'postprocessing/UnrealBloomPass.js'),
|
||||
import(A + 'postprocessing/OutputPass.js'),
|
||||
]).then(([EC, RP, UB, OP]) => {
|
||||
try {
|
||||
// Torn down or superseded mid-load (a dispose clears
|
||||
// _bloomLoad, letting a NEW load start against the new
|
||||
// renderer — this stale completion must not also build
|
||||
// and orphan a composer).
|
||||
if (myGen !== _bloomGen || _composer) return;
|
||||
if (!ren || !scene || !cam || !highwayCanvas) return;
|
||||
const w = Math.max(2, (highwayCanvas.clientWidth || highwayCanvas.width || 1280) | 0);
|
||||
const h = Math.max(2, (highwayCanvas.clientHeight || highwayCanvas.height || 720) | 0);
|
||||
// Multisampled (WebGL2 MSAA) HalfFloat target so anti-aliasing
|
||||
// survives the bloom path — EffectComposer's default target has
|
||||
// no `samples`.
|
||||
const rt = new T.WebGLRenderTarget(w, h, { type: T.HalfFloatType, samples: 4 });
|
||||
const comp = new EC.EffectComposer(ren, rt);
|
||||
comp.setPixelRatio(ren.getPixelRatio());
|
||||
comp.addPass(new RP.RenderPass(scene, cam));
|
||||
_bloomPass = new UB.UnrealBloomPass(new T.Vector2(w, h), 0.65, 0.5, 0.82); // strength, radius, threshold (high → only emissive blooms)
|
||||
comp.addPass(_bloomPass);
|
||||
comp.addPass(new OP.OutputPass());
|
||||
comp.setSize(w, h);
|
||||
_bloomW = w; _bloomH = h; _composer = comp;
|
||||
} catch (e) { console.warn('[Keys-Hwy3D] bloom init failed', e); _composer = null; }
|
||||
}).catch((e) => console.warn('[Keys-Hwy3D] bloom modules failed', e));
|
||||
return null;
|
||||
}
|
||||
|
||||
// Drop the composer + its render targets (teardown path). Nulling
|
||||
// _bloomLoad lets _bloomEnsure rebuild lazily on a later init.
|
||||
function _bloomDispose() {
|
||||
if (_composer) {
|
||||
// EffectComposer.dispose() only frees its own read/write
|
||||
// buffers — passes own additional GPU resources (UnrealBloom
|
||||
// keeps several render targets + materials, OutputPass a
|
||||
// material), so dispose each pass explicitly first.
|
||||
try {
|
||||
for (const p of _composer.passes || []) {
|
||||
if (p && typeof p.dispose === 'function') p.dispose();
|
||||
}
|
||||
} catch (_) {}
|
||||
try { _composer.dispose(); } catch (_) {}
|
||||
}
|
||||
_composer = null;
|
||||
_bloomPass = null;
|
||||
_bloomLoad = null;
|
||||
_bloomW = 0; _bloomH = 0;
|
||||
_bloomGen++; // invalidate any in-flight addon load
|
||||
}
|
||||
|
||||
/* ── HUD overlay (combo / accuracy / streak — drum_highway_3d pattern) ── */
|
||||
function _injectHud() {
|
||||
if (_hudEl || !highwayCanvas) return;
|
||||
const parent = highwayCanvas.parentElement;
|
||||
if (!parent) return;
|
||||
// Position the parent relative so the absolute HUD anchors to the
|
||||
// canvas. Read-only check first so we don't clobber an existing
|
||||
// position the host page set.
|
||||
const cur = parent.style.position || getComputedStyle(parent).position;
|
||||
if (cur === 'static' || !cur) {
|
||||
_hudParentOrigPosition = parent.style.position;
|
||||
parent.style.position = 'relative';
|
||||
}
|
||||
_hudEl = document.createElement('div');
|
||||
_hudEl.className = 'keys-h3d-hud';
|
||||
_hudEl.style.cssText = [
|
||||
// Below the host's top-left song-info block (title /
|
||||
// arrangement / tuning, ~3 lines) so the two never overlap.
|
||||
'position:absolute', 'top:96px', 'left:14px',
|
||||
'font-family:system-ui,sans-serif', 'font-size:13px',
|
||||
'color:#e2e8f0', 'pointer-events:none', 'z-index:6',
|
||||
'text-shadow:0 1px 2px rgba(0,0,0,0.8)',
|
||||
'min-width:140px', 'line-height:1.4',
|
||||
].join(';');
|
||||
parent.appendChild(_hudEl);
|
||||
_refreshHud();
|
||||
}
|
||||
|
||||
function _removeHud() {
|
||||
if (_hudEl) {
|
||||
const parent = _hudEl.parentNode;
|
||||
if (parent) {
|
||||
parent.removeChild(_hudEl);
|
||||
// Restore position only if _injectHud changed it.
|
||||
if (_hudParentOrigPosition !== null) {
|
||||
parent.style.position = _hudParentOrigPosition;
|
||||
_hudParentOrigPosition = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
_hudEl = null;
|
||||
}
|
||||
|
||||
function _refreshHud() {
|
||||
if (!_hudEl) return;
|
||||
// Only meaningful while a wired MIDI session can score — same
|
||||
// gate as the miss sweep. Without one, show nothing rather than
|
||||
// a frozen 0× combo.
|
||||
if (!_midiHandle) {
|
||||
if (_hudEl.innerHTML) _hudEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const total = _hits + _misses;
|
||||
const pct = total ? Math.round((_hits / total) * 100) : 0;
|
||||
const comboColor = _streak >= 30 ? '#fde047' :
|
||||
_streak >= 10 ? '#86efac' : '#cbd5e1';
|
||||
_hudEl.innerHTML =
|
||||
`<div style="color:${comboColor};font-weight:600;font-size:18px">${_streak}× combo</div>` +
|
||||
`<div>${_hits}/${total} (${pct}%)</div>` +
|
||||
(_bestStreak ? `<div style="color:#94a3b8;font-size:11px">best ${_bestStreak}</div>` : '');
|
||||
}
|
||||
|
||||
function applySize(w, h) {
|
||||
if (!ren || !cam || !w || !h) return;
|
||||
// Splitscreen: cap the base DPR harder (1.25 vs 2, mirroring
|
||||
// highway_3d) so two panels don't double the fill cost. Checks
|
||||
// the host split state (covers a mixed split with another
|
||||
// renderer) plus our own instance count (covers multi-instance
|
||||
// without host state). Before this PR no setPixelRatio was ever
|
||||
// called — HiDPI displays rendered at CSS resolution and looked
|
||||
// soft/aliased.
|
||||
const baseDPR = (_ssActive() || _instances.size > 1)
|
||||
? Math.min(window.devicePixelRatio || 1, 1.25)
|
||||
: Math.min(window.devicePixelRatio || 1, 2);
|
||||
ren.setPixelRatio(_renderScale * baseDPR);
|
||||
ren.setSize(w, h, false);
|
||||
if (_composer) {
|
||||
// EffectComposer snapshots the renderer's pixelRatio — it must
|
||||
// be told about both ratio and box changes or bloom renders at
|
||||
// the wrong resolution.
|
||||
_composer.setPixelRatio(ren.getPixelRatio());
|
||||
_composer.setSize(w, h);
|
||||
_bloomW = w; _bloomH = h;
|
||||
}
|
||||
cam.aspect = w / h;
|
||||
cam.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
_bloomDispose();
|
||||
// HUD cleanup lives here (not only destroy): init() re-runs
|
||||
// teardown() for renderer re-initialization, possibly against a
|
||||
// different canvas — a stale _hudEl would both linger in the old
|
||||
// parent and make the next _injectHud() an early-return no-op.
|
||||
_removeHud();
|
||||
if (_fxHandler) {
|
||||
window.removeEventListener('keys3d:settings', _fxHandler);
|
||||
_fxHandler = null;
|
||||
}
|
||||
const sm = window.slopsmith;
|
||||
if (sm && typeof sm.off === 'function') {
|
||||
if (_songHandler) sm.off('song:loaded', _songHandler);
|
||||
@@ -2099,6 +2365,7 @@
|
||||
init(canvas, _bundle) {
|
||||
if (_isReady) teardown();
|
||||
highwayCanvas = canvas;
|
||||
fx = readFxSettings();
|
||||
loadThree().then(() => {
|
||||
if (!highwayCanvas) return; // destroyed before load resolved
|
||||
try {
|
||||
@@ -2111,6 +2378,17 @@
|
||||
}
|
||||
buildScene();
|
||||
applySize(highwayCanvas.clientWidth, highwayCanvas.clientHeight);
|
||||
_fxHandler = (ev) => {
|
||||
const d = ev && ev.detail;
|
||||
if (!d || !d.fx) return;
|
||||
// FX toggles are consumed per-frame in draw() — no
|
||||
// rebuild needed; the composer stays cached while off.
|
||||
for (const k of Object.keys(d.fx)) {
|
||||
if (k in FX_DEFAULTS) fx[k] = d.fx[k];
|
||||
}
|
||||
};
|
||||
window.addEventListener('keys3d:settings', _fxHandler);
|
||||
_injectHud();
|
||||
_isReady = true;
|
||||
loadNotationForCurrentSong();
|
||||
if (window.slopsmith && typeof window.slopsmith.on === 'function') {
|
||||
@@ -2130,6 +2408,16 @@
|
||||
// destroy() before it lands can't wire a dead handler.
|
||||
_instances.add(instance);
|
||||
_activeInstance = instance;
|
||||
// Re-apply size now that this instance is counted in
|
||||
// _instances: the applySize above ran before the add, so
|
||||
// its splitscreen DPR check (size > 1) undercounted — the
|
||||
// second panel of a splitscreen mount would otherwise keep
|
||||
// full 2x DPR until some later resize. The already-mounted
|
||||
// panel is corrected by the host's own layout resize when
|
||||
// the split activates (panels change box size).
|
||||
if (_instances.size > 1 || _ssActive()) {
|
||||
applySize(highwayCanvas.clientWidth, highwayCanvas.clientHeight);
|
||||
}
|
||||
_midiInit();
|
||||
_midiResume();
|
||||
}).catch(() => {
|
||||
@@ -2139,10 +2427,33 @@
|
||||
|
||||
draw(bundle) {
|
||||
if (!_isReady || !ren || !scene || !cam) return;
|
||||
// Host adaptive quality: consume bundle.renderScale like
|
||||
// highway_3d — the host lowers it under GPU load ("Quality"
|
||||
// + "Min res" controls) and applySize folds it into the DPR.
|
||||
const newScale = (bundle && bundle.renderScale) || 1;
|
||||
if (newScale !== _renderScale) {
|
||||
_renderScale = newScale;
|
||||
applySize(highwayCanvas.clientWidth, highwayCanvas.clientHeight);
|
||||
}
|
||||
const now = (bundle && typeof bundle.currentTime === 'number') ? bundle.currentTime : 0;
|
||||
if (_notation) updateScene(now);
|
||||
_animateFeedback(performance.now());
|
||||
ren.render(scene, cam);
|
||||
_refreshHud();
|
||||
// Bloom path (PORTED FROM highway_3d): composer + ACES tone
|
||||
// mapping when enabled and single-instance; direct render
|
||||
// otherwise (including the frames while addons stream in).
|
||||
const comp = _bloomGateOk() ? _bloomEnsure() : null;
|
||||
if (comp) {
|
||||
const w = highwayCanvas.clientWidth | 0, h = highwayCanvas.clientHeight | 0;
|
||||
if (w > 0 && h > 0 && (w !== _bloomW || h !== _bloomH)) {
|
||||
comp.setSize(w, h); _bloomW = w; _bloomH = h;
|
||||
}
|
||||
if (ren.toneMapping !== T.ACESFilmicToneMapping) ren.toneMapping = T.ACESFilmicToneMapping;
|
||||
comp.render();
|
||||
} else {
|
||||
if (ren.toneMapping !== T.NoToneMapping) ren.toneMapping = T.NoToneMapping;
|
||||
ren.render(scene, cam);
|
||||
}
|
||||
},
|
||||
|
||||
resize(w, h) {
|
||||
@@ -2161,7 +2472,7 @@
|
||||
for (const inst of _instances) { _activeInstance = inst; break; }
|
||||
}
|
||||
if (_instances.size === 0) _midiReleaseSession();
|
||||
teardown();
|
||||
teardown(); // includes _removeHud()
|
||||
highwayCanvas = null;
|
||||
},
|
||||
|
||||
@@ -2221,6 +2532,8 @@
|
||||
scoreOf,
|
||||
judgeHit,
|
||||
sweepMissed,
|
||||
readFxSettings,
|
||||
FX_DEFAULTS,
|
||||
};
|
||||
|
||||
// Headless verification hook: lets Playwright drive synthetic note-ons
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<div role="group" aria-labelledby="keysh3d-heading">
|
||||
<h3 id="keysh3d-heading" class="text-sm font-medium text-gray-400 mb-2">3D Keys Highway</h3>
|
||||
<p class="text-xs text-gray-500 mb-3">
|
||||
Pick a song with keys notation (sloppak-spec §5.3) and choose
|
||||
<em>Keys Highway 3D</em> from the viz picker (Auto selects it for
|
||||
notation charts). The plugin auto-attaches to your MIDI keyboard;
|
||||
device, channel and transpose are configured from the player for
|
||||
now — this panel holds the graphics controls.
|
||||
</p>
|
||||
|
||||
<!-- Graphics -->
|
||||
<div class="mt-3">
|
||||
<h4 class="text-xs font-medium text-gray-300 mb-2">Graphics</h4>
|
||||
<label for="keysh3d-fx-bloom" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
|
||||
<input type="checkbox" id="keysh3d-fx-bloom" checked
|
||||
onchange="window.keys3dSetFx && window.keys3dSetFx('bloom', this.checked)">
|
||||
Glow (bloom)
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Soft light-bleed around the hit line, hit flames and consumed
|
||||
sustains. Applies live; turn off to reclaim GPU headroom on
|
||||
weak machines.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
// Hydrate controls from stored config on first paint. Narrow the
|
||||
// try/catch to just the localStorage reads (drum_highway_3d
|
||||
// settings-hydration convention).
|
||||
try {
|
||||
// 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;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Keys-Hwy3D settings] hydration failed:', e);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
@@ -0,0 +1,82 @@
|
||||
// FX-settings scaffold tests (guitar-highway parity controls). Same bare-vm
|
||||
// harness as data_layer.test.js — no DOM, no localStorage — which doubles as
|
||||
// a lint that the new module-scope FX code stays side-effect safe.
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
function load(extraWindow) {
|
||||
const window = {
|
||||
console,
|
||||
location: { protocol: 'http:', host: 'localhost' },
|
||||
slopsmith: {},
|
||||
...extraWindow,
|
||||
};
|
||||
window.window = window;
|
||||
window.globalThis = window;
|
||||
const context = vm.createContext(window);
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
|
||||
vm.runInContext(src, context, { filename: 'screen.js' });
|
||||
return window;
|
||||
}
|
||||
|
||||
test('readFxSettings: defaults survive a localStorage-less environment', () => {
|
||||
const { readFxSettings, FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual(readFxSettings(), FX_DEFAULTS);
|
||||
assert.equal(FX_DEFAULTS.bloom, true); // effects on by default
|
||||
});
|
||||
|
||||
test('readFxSettings: reads keys3d_bg_* overrides and coerces types', () => {
|
||||
const store = { keys3d_bg_bloom: '0' };
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
});
|
||||
const { readFxSettings } = win.slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(readFxSettings().bloom, false);
|
||||
store.keys3d_bg_bloom = 'true';
|
||||
assert.equal(readFxSettings().bloom, true);
|
||||
store.keys3d_bg_bloom = 'false';
|
||||
assert.equal(readFxSettings().bloom, false);
|
||||
// Corrupt/foreign value → keep the default rather than silently
|
||||
// disabling the effect.
|
||||
store.keys3d_bg_bloom = 'banana';
|
||||
assert.equal(readFxSettings().bloom, true);
|
||||
});
|
||||
|
||||
test('keys3dSetFx: persists, coerces, and ignores unknown keys', () => {
|
||||
const store = {};
|
||||
const events = [];
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: (ev) => { events.push(ev); return true; },
|
||||
CustomEvent: class CustomEvent {
|
||||
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
|
||||
},
|
||||
});
|
||||
win.keys3dSetFx('bloom', false);
|
||||
assert.equal(store.keys3d_bg_bloom, '0');
|
||||
// String forms round-trip like the reader's accepted representations.
|
||||
win.keys3dSetFx('bloom', 'false');
|
||||
assert.equal(store.keys3d_bg_bloom, '0');
|
||||
win.keys3dSetFx('bloom', 'true');
|
||||
assert.equal(store.keys3d_bg_bloom, '1');
|
||||
win.keys3dSetFx('bloom', false);
|
||||
assert.equal(events.length, 4);
|
||||
assert.equal(events[0].type, 'keys3d:settings');
|
||||
// Field-wise (the detail object was built inside the vm realm, so a
|
||||
// deep-strict compare would trip on its foreign Object.prototype).
|
||||
assert.equal(events[0].detail.fx.bloom, false);
|
||||
assert.deepEqual(Object.keys(events[0].detail.fx), ['bloom']);
|
||||
// Unknown key: no write, no event.
|
||||
win.keys3dSetFx('nonsense', 1);
|
||||
assert.equal(events.length, 4);
|
||||
assert.ok(!('keys3d_bg_nonsense' in store));
|
||||
});
|
||||
Reference in New Issue
Block a user