mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
feat(drum_highway_3d): foundation for guitar-highway visual parity (D1) (#695)
- Consume host adaptive-quality bundle.renderScale; fold into DPR like highway_3d (splitscreen proxy: >1 instance caps baseDPR at 1.25) - Bloom: port _bloomEnsure/_bloomDispose (UnrealBloomPass 0.65/0.5/0.82, MSAA HalfFloat target, ACES<->None tone-mapping switch); rebuilt across the kit-change renderer recreation; direct render is the degrade path - FX settings scaffold: FX_DEFAULTS + readFxSettings + drumH3dSetFx (drum_h3d_bg_* keys), Graphics section in settings.html (bloom toggle, default ON, live-applies) - _applyLaneFlashes dead-code comment updated (visual consumer lands in the hit-FX PR) - __test export + tests/data_layer.test.js (vm harness, 8 tests) — picked up by the CI glob from the bundling PR; README refreshed Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1e2d5a6162
commit
a7ea719652
@@ -49,15 +49,28 @@ a few bars, plus a tom roll down the kit. Best read of what's possible.
|
||||
|
||||
All settings persist in `localStorage` under the `drum_h3d_*` prefix.
|
||||
|
||||
## What this mockup is *not* doing yet
|
||||
## What this plugin is *not* doing yet
|
||||
|
||||
(Historical note: this started as a pure-visual mockup; it now reads
|
||||
`bundle.drumTab` + `bundle.currentTime` and scores MIDI hits against the
|
||||
chart, so the old "no song-data wiring" caveats are gone.)
|
||||
|
||||
- Reading `bundle.notes` / `bundle.chords` / `bundle.currentTime`
|
||||
- Hit detection / scoring / note_detect integration
|
||||
- Real drum chart parsing (the chart doesn't ship drum charts; this
|
||||
would need a new arrangement format or a Guitar Pro/MIDI source)
|
||||
- Sustain trails (drums don't sustain meaningfully)
|
||||
- Sticking labels, double kick, foot hi-hat, cross-stick, rolls — see
|
||||
the TODOs in `screen.js` for the variant backlog
|
||||
- Sticking labels, double kick, rolls — see the TODOs in `screen.js`
|
||||
for the variant backlog
|
||||
|
||||
## 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 `keys_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)
|
||||
|
||||
## Why a separate plugin (vs. drum mode inside highway_3d)?
|
||||
|
||||
|
||||
@@ -1054,6 +1054,72 @@
|
||||
window.dispatchEvent(new CustomEvent('drum_h3d:settings', { detail: { cameraAngle: c } }));
|
||||
};
|
||||
|
||||
// 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
|
||||
// `drum_h3d_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 = 'drum_h3d_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.drumH3dSetFx('bloom', checked). Coerces to the default's type
|
||||
// so a slider string can't poison a boolean toggle.
|
||||
window.drumH3dSetFx = 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('drum_h3d:settings', { detail: { fx: { [key]: v } } }));
|
||||
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
||||
};
|
||||
|
||||
/* ======================================================================
|
||||
* Renderer factory
|
||||
* ====================================================================== */
|
||||
@@ -1069,6 +1135,18 @@
|
||||
// Settings snapshot — mutated by 'drum_h3d:settings' event.
|
||||
let settings = readSettings();
|
||||
let activePalette = PALETTES[settings.palette];
|
||||
let fx = readFxSettings();
|
||||
|
||||
// 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
|
||||
|
||||
// Scene groups / pooled meshes.
|
||||
let laneGroup = null; // lane stripes + dividers
|
||||
@@ -1152,7 +1230,9 @@
|
||||
_hudEl = document.createElement('div');
|
||||
_hudEl.className = 'drum-h3d-hud';
|
||||
_hudEl.style.cssText = [
|
||||
'position:absolute', 'top:10px', 'left:14px',
|
||||
// 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)',
|
||||
@@ -1194,16 +1274,90 @@
|
||||
const FLASH_MS = 300;
|
||||
|
||||
function _applyLaneFlashes() {
|
||||
// No longer used for visual feedback (chart notes now turn
|
||||
// green/red on hit/miss via placeNote). Still drop expired
|
||||
// entries from the buffer so the array doesn't grow forever
|
||||
// if MIDI hits arrive while no chart is loaded.
|
||||
// The visual consumer (pooled additive lane-flash quads) lands in
|
||||
// the hit-FX parity PR; chart notes already turn green/red on
|
||||
// hit/miss via placeNote. Until then just drop expired entries so
|
||||
// the buffer doesn't grow forever if MIDI hits arrive while no
|
||||
// chart is loaded.
|
||||
const now = performance.now();
|
||||
while (_laneFlashes.length && now - _laneFlashes[0].wall > FLASH_MS) {
|
||||
_laneFlashes.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 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('[Drum-Hwy3D] bloom init failed', e); _composer = null; }
|
||||
}).catch((e) => console.warn('[Drum-Hwy3D] bloom modules failed', e));
|
||||
return null;
|
||||
}
|
||||
|
||||
// Drop the composer + its render targets. Called from teardown() AND
|
||||
// _disposeScene() — the kit-change path recreates the renderer, and a
|
||||
// composer bound to the dead renderer would draw into nothing.
|
||||
// Nulling _bloomLoad lets _bloomEnsure rebuild lazily against the new
|
||||
// renderer.
|
||||
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
|
||||
}
|
||||
|
||||
function _resetScoring() {
|
||||
_hitKeys.clear();
|
||||
_missedKeys.clear();
|
||||
@@ -1296,6 +1450,13 @@
|
||||
settings.cameraAngle = Math.min(1, Math.max(0, detail.cameraAngle));
|
||||
positionCamera();
|
||||
}
|
||||
if (detail.fx) {
|
||||
// FX toggles are consumed per-frame in draw() — no rebuild
|
||||
// needed; the bloom composer stays cached while toggled off.
|
||||
for (const k of Object.keys(detail.fx)) {
|
||||
if (k in FX_DEFAULTS) fx[k] = detail.fx[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rebuildPaletteMaterials() {
|
||||
@@ -1902,8 +2063,25 @@
|
||||
if (!ren || !cam || !highwayCanvas) return;
|
||||
const W = Math.max(1, Math.round(w || highwayCanvas.clientWidth || highwayCanvas.width || 1));
|
||||
const H = Math.max(1, Math.round(h || highwayCanvas.clientHeight || highwayCanvas.height || 1));
|
||||
ren.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
// 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). _renderScale is the host's adaptive
|
||||
// quality scale from bundle.renderScale.
|
||||
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();
|
||||
}
|
||||
@@ -1917,6 +2095,7 @@
|
||||
// false when WebGL re-init failed — callers must NOT proceed to
|
||||
// initScene() on false (the scene would draw into nothing).
|
||||
function _disposeScene() {
|
||||
_bloomDispose(); // composer is bound to the renderer we're about to replace
|
||||
if (notesGroup) {
|
||||
while (notesGroup.children.length) {
|
||||
disposeMeshTree(notesGroup.children.pop());
|
||||
@@ -1984,6 +2163,12 @@
|
||||
}
|
||||
|
||||
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 (_settingsHandler) {
|
||||
window.removeEventListener('drum_h3d:settings', _settingsHandler);
|
||||
_settingsHandler = null;
|
||||
@@ -2045,6 +2230,7 @@
|
||||
highwayCanvas = canvas;
|
||||
settings = readSettings();
|
||||
activePalette = PALETTES[settings.palette];
|
||||
fx = readFxSettings();
|
||||
|
||||
loadThree().then(() => {
|
||||
if (!highwayCanvas) return; // destroyed before load resolved
|
||||
@@ -2100,6 +2286,17 @@
|
||||
// immediately at init.
|
||||
_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), same as on
|
||||
// split teardown.
|
||||
if (_instances.size > 1 || _ssActive()) {
|
||||
applySize(highwayCanvas.clientWidth, highwayCanvas.clientHeight);
|
||||
}
|
||||
_midiInit();
|
||||
_synthInit();
|
||||
_midiResume();
|
||||
@@ -2112,10 +2309,32 @@
|
||||
|
||||
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);
|
||||
}
|
||||
rebuildNotes(bundle);
|
||||
_applyLaneFlashes();
|
||||
_refreshHud();
|
||||
ren.render(scene, cam);
|
||||
// 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 = (fx.bloom && _instances.size === 1 && !_ssActive()) ? _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) {
|
||||
@@ -2135,8 +2354,7 @@
|
||||
for (const inst of _instances) { _activeInstance = inst; break; }
|
||||
}
|
||||
if (_instances.size === 0) _midiReleaseSession();
|
||||
_removeHud();
|
||||
teardown();
|
||||
teardown(); // includes _removeHud()
|
||||
highwayCanvas = null;
|
||||
},
|
||||
// Exposed for module-level MIDI router. The receiver runs on
|
||||
@@ -2181,4 +2399,14 @@
|
||||
if (songInfo.has_notation) return false;
|
||||
return !/\b(?:lead|rhythm|bass|combo|guitar)\b/i.test(arr);
|
||||
};
|
||||
// Pure helpers exposed for the node:test suite (tests/ — screen.js is
|
||||
// vm-loaded with no DOM/WebGL; everything here must stay side-effect
|
||||
// free to call).
|
||||
window.slopsmithViz_drum_highway_3d.__test = {
|
||||
_variantForHit,
|
||||
readFxSettings,
|
||||
FX_DEFAULTS,
|
||||
MIDI_TO_PIECE,
|
||||
HIT_TOLERANCE_S,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -42,6 +42,20 @@
|
||||
<p class="text-xs text-gray-500 mt-1">0 = down the lanes · 1 = top-down</p>
|
||||
</div>
|
||||
|
||||
<!-- Graphics -->
|
||||
<div class="mt-6 border-t border-gray-800 pt-4">
|
||||
<h4 class="text-xs font-medium text-gray-300 mb-2">Graphics</h4>
|
||||
<label for="drumh3d-fx-bloom" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
|
||||
<input type="checkbox" id="drumh3d-fx-bloom" checked
|
||||
onchange="window.drumH3dSetFx && window.drumH3dSetFx('bloom', this.checked)">
|
||||
Glow (bloom)
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Soft light-bleed around the hit line and bright notes. Applies
|
||||
live; turn off to reclaim GPU headroom on weak machines.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- MIDI input -->
|
||||
<div class="mt-6 border-t border-gray-800 pt-4">
|
||||
<h4 class="text-xs font-medium text-gray-300 mb-2">MIDI input</h4>
|
||||
@@ -422,6 +436,16 @@
|
||||
cam.value = String(c);
|
||||
camVal.textContent = c.toFixed(2);
|
||||
}
|
||||
|
||||
// FX toggles (drum_h3d_bg_* — guitar-parity graphics controls).
|
||||
// Only explicit values override; absent/corrupt keys keep the
|
||||
// default (ON), matching screen.js readFxSettings.
|
||||
const storedBloom = localStorage.getItem('drum_h3d_bg_bloom');
|
||||
if (storedBloom === '1' || storedBloom === 'true') {
|
||||
document.getElementById('drumh3d-fx-bloom').checked = true;
|
||||
} else if (storedBloom === '0' || storedBloom === 'false') {
|
||||
document.getElementById('drumh3d-fx-bloom').checked = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Drum-Hwy3D settings] hydration failed:', e);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Pure data-layer tests: load screen.js in a bare vm window and exercise the
|
||||
// __test exports (no DOM, no WebGL, no network). Doubles as a lint that no
|
||||
// module-scope code touches document/localStorage outside a try/catch —
|
||||
// the vm window deliberately provides neither.
|
||||
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() {
|
||||
const window = {
|
||||
console,
|
||||
location: { protocol: 'http:', host: 'localhost' },
|
||||
slopsmith: {},
|
||||
};
|
||||
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.slopsmithViz_drum_highway_3d;
|
||||
}
|
||||
|
||||
test('module loads in a bare vm (no DOM / localStorage at module scope)', () => {
|
||||
const factory = load();
|
||||
assert.equal(typeof factory, 'function');
|
||||
assert.equal(factory.contextType, 'webgl2');
|
||||
});
|
||||
|
||||
test('_variantForHit: ghost > flam > bell > accent > normal precedence', () => {
|
||||
const { _variantForHit } = load().__test;
|
||||
assert.equal(_variantForHit({ g: true, f: true, v: 120 }), 'ghost');
|
||||
assert.equal(_variantForHit({ f: true, v: 120 }), 'flam');
|
||||
assert.equal(_variantForHit({ p: 'ride_bell' }), 'bell');
|
||||
assert.equal(_variantForHit({ v: 100 }), 'accent');
|
||||
assert.equal(_variantForHit({ v: 127 }), 'accent');
|
||||
assert.equal(_variantForHit({ v: 99 }), 'normal');
|
||||
// Missing velocity defaults to 100 → accent.
|
||||
assert.equal(_variantForHit({}), 'accent');
|
||||
});
|
||||
|
||||
test('matchesArrangement: claims drum arrangements', () => {
|
||||
const matches = load().matchesArrangement;
|
||||
assert.equal(matches({ has_drum_tab: true, arrangement: 'Drums' }), true);
|
||||
assert.equal(matches({ has_drum_tab: true, arrangement: 'Drum Kit' }), true);
|
||||
assert.equal(matches({ has_drum_tab: true, arrangement: 'Percussion' }), true);
|
||||
});
|
||||
|
||||
test('matchesArrangement: never claims without a drum tab', () => {
|
||||
const matches = load().matchesArrangement;
|
||||
assert.equal(matches(null), false);
|
||||
assert.equal(matches({}), false);
|
||||
assert.equal(matches({ arrangement: 'Drums' }), false);
|
||||
});
|
||||
|
||||
test('matchesArrangement: steal-guard — guitar arrangements stay with highway_3d', () => {
|
||||
const matches = load().matchesArrangement;
|
||||
// Full-band pack (drum_tab present) playing a guitar-family part:
|
||||
// first-match-wins Auto order must not hand these to the drum highway.
|
||||
for (const arr of ['Lead', 'Rhythm', 'Bass', 'Combo', 'Guitar 22', 'Alt. Lead']) {
|
||||
assert.equal(matches({ has_drum_tab: true, arrangement: arr }), false, arr);
|
||||
}
|
||||
// Keys notation present → the keys/staff viz take it.
|
||||
assert.equal(matches({ has_drum_tab: true, has_notation: true, arrangement: 'Piano' }), false);
|
||||
});
|
||||
|
||||
test('matchesArrangement: claims packs nothing more specific can render', () => {
|
||||
const matches = load().matchesArrangement;
|
||||
// Drum tab + nondescript arrangement, no notation → drummable, claim it.
|
||||
assert.equal(matches({ has_drum_tab: true, arrangement: '' }), true);
|
||||
assert.equal(matches({ has_drum_tab: true }), true);
|
||||
// Word-boundary check: "BasslineKeys"-style names don't contain a
|
||||
// guitar-family word as a whole word.
|
||||
assert.equal(matches({ has_drum_tab: true, arrangement: 'Bassline' }), true);
|
||||
});
|
||||
|
||||
test('readFxSettings: defaults survive a localStorage-less environment', () => {
|
||||
const { readFxSettings, FX_DEFAULTS } = load().__test;
|
||||
// The vm window has no localStorage — the try/catch must eat the
|
||||
// ReferenceError and hand back pure defaults (everything ON).
|
||||
assert.deepEqual(readFxSettings(), FX_DEFAULTS);
|
||||
assert.equal(FX_DEFAULTS.bloom, true);
|
||||
});
|
||||
|
||||
test('MIDI map: open hi-hat is a first-class piece (46 → hh_open)', () => {
|
||||
const { MIDI_TO_PIECE, HIT_TOLERANCE_S } = load().__test;
|
||||
assert.equal(MIDI_TO_PIECE[46], 'hh_open');
|
||||
assert.equal(MIDI_TO_PIECE[42], 'hh_closed');
|
||||
assert.equal(MIDI_TO_PIECE[35], 'kick');
|
||||
assert.equal(MIDI_TO_PIECE[36], 'kick');
|
||||
// ±50 ms window matches the 2D drums plugin.
|
||||
assert.equal(HIT_TOLERANCE_S, 0.05);
|
||||
});
|
||||
Reference in New Issue
Block a user