feedBack/plugins/drum_highway_3d/screen.js
Kris Anderson 14d116d827 fix(highways): validate panel index before indexing the camera map
_resolveFreeCam() (keys/drum) and _freeCamFor() (highway_3d) guarded the panel
map lookup with only `i != null`, so a non-integer / negative / string index
from panelIndexFor() could resolve an unintended or inherited property (e.g.
map['toString']) instead of cleanly falling back to the global camera. Gate the
index on `Number.isInteger(i) && i >= 0` before `map[i]`, matching the hardening
already applied in _bgPanelKey(). Extend the resolver tests with float/string
(prototype-key) cases. Behavior change only for malformed indices.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
2026-07-09 16:33:57 -04:00

3680 lines
177 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 3D Drum Highway — pure visual mockup.
//
// Exploratory sibling of highway_3d. Renders an 8-lane drum highway (7 lanes
// for hand pieces + a full-width kick bar) populated from a hardcoded demo
// pattern that loops indefinitely. No song-data wiring: bundle.notes /
// bundle.chords / bundle.currentTime are ignored. The viz still plugs into
// slopsmith's setRenderer contract so it appears in the player viz picker
// alongside the guitar highway.
//
// Reuses verbatim from highway_3d: Three.js loader, palette arrays, world
// scale (K), fog, lights — so the two highways feel like the same family
// even though they render different geometry.
(function () {
'use strict';
/* ======================================================================
* Verbatim from highway_3d — keep these in sync if upstream tweaks them
* ====================================================================== */
const THREE_URL = '/static/vendor/three/three.module.min.js';
const THREE_CDN = 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js';
const PALETTES = {
default: [
0xff2828, 0xffd400, 0x2080ff, 0xff8020,
0x30d040, 0xa040ff, 0xff6bd5, 0x6bffe6,
],
neon: [
0xff0030, 0xffe800, 0x0080ff, 0xff8030,
0x40ff50, 0xb050ff, 0xff40d0, 0x40ffd0,
],
pastel: [
0xe89aa0, 0xefdf90, 0x9adfee, 0xefb898,
0xa6e0a8, 0xc4a6e0, 0xe0a6c8, 0xa6e0d8,
],
};
const PALETTE_IDS = Object.keys(PALETTES);
const SCALE = 2.25;
const K = SCALE / 300;
const FOG_COLOR = 0x1a1a2e;
const FOG_START = 200 * K;
const FOG_END = 670 * K;
let T = null;
let threeLoadPromise = null;
function loadThree() {
if (!threeLoadPromise) {
threeLoadPromise = import(THREE_URL)
.then(mod => { T = mod; return mod; })
.catch(() => import(THREE_CDN)
.then(mod => { T = mod; return mod; })
.catch(e => {
console.error('[Drum-Hwy] Three.js load failed:', e);
threeLoadPromise = null;
throw e;
}));
}
return threeLoadPromise;
}
/* ======================================================================
* Drum-specific constants
* ====================================================================== */
// World scroll speed (units / second). Matches highway_3d so the two
// viz feel like they belong in the same scene.
const TS = 130 * K;
// Lane geometry. 7 hand lanes + 1 full-width kick bar.
// lane 0: hi-hat (cymbal)
// lane 1: snare (drum)
// lane 2: high tom (drum)
// lane 3: mid tom (drum)
// lane 4: floor tom(drum)
// lane 5: crash (cymbal)
// lane 6: ride (cymbal)
// lane 7: kick (full-width bar)
const LANE_GAP = 12 * K; // X spacing between lane centers
const KICK_COLOR = 0xffa030; // amber regardless of palette
// Note dimensions (world units). All keyed to K so scale changes
// propagate.
// Bumped ~20% (3.6/3.0 → 4.3/3.6) after hands-on feedback: the gems
// were hard to read at the default camera. Accent scale (1.25×) still
// fits the 12*K lane gap: 4.3*2*1.25 = 10.75. Every dependent shape
// (halo/ghost/open rings, snare stripe, bell dot, flam grace) keys off
// these radii, so the whole family scales together.
const DISC_R_BASE = 4.3 * K; // drum disc radius
const DISC_H = 1.4 * K; // drum disc thickness
const CYMBAL_R = 3.6 * K; // cymbal gem radius
const CYMBAL_H = 1.9 * K; // cymbal gem height
// ─── Piece vocabulary (mirrors lib/drums.py PIECES) ──────────────
// Used for kit configuration: user picks which of these pieces they
// own and in what lane order.
const ALL_PIECES = [
'kick',
'snare', 'snare_xstick',
'hh_closed', 'hh_open', 'hh_pedal',
'tom_hi', 'tom_mid', 'tom_low', 'tom_floor',
'crash_l', 'crash_r', 'splash', 'china',
'ride', 'ride_bell',
];
const PIECE_LABELS = {
kick: 'KICK',
snare: 'SNR',
snare_xstick: 'XSTK',
hh_closed: 'HHc',
hh_open: 'HHo',
hh_pedal: 'HHp',
tom_hi: 'TM1',
tom_mid: 'TM2',
tom_low: 'TM3',
tom_floor: 'FT',
crash_l: 'CRl',
crash_r: 'CRr',
splash: 'SPL',
china: 'CHN',
ride: 'RD',
ride_bell: 'BLL',
};
const PIECE_CATEGORY = {
kick: 'kick',
snare: 'drum', snare_xstick: 'drum',
hh_closed: 'cymbal', hh_open: 'cymbal', hh_pedal: 'cymbal',
tom_hi: 'drum', tom_mid: 'drum', tom_low: 'drum', tom_floor: 'drum',
crash_l: 'cymbal', crash_r: 'cymbal', splash: 'cymbal', china: 'cymbal',
ride: 'cymbal', ride_bell: 'cymbal',
};
const PIECE_SUBKIND = {
kick: 'kick',
snare: 'snare', snare_xstick: 'snare',
hh_closed: 'hihat', hh_open: 'hihat', hh_pedal: 'hihat',
tom_hi: 'tom', tom_mid: 'tom', tom_low: 'tom', tom_floor: 'tom',
crash_l: 'crash', crash_r: 'crash', splash: 'crash', china: 'crash',
ride: 'ride', ride_bell: 'ride',
};
// Palette index per piece — chosen so the default 7-lane kit reads
// the same colours as the original hardcoded LANES layout.
const PIECE_PALETTE_IDX = {
kick: 6,
snare: 0, snare_xstick: 0,
hh_closed: 7, hh_open: 7, hh_pedal: 7,
tom_hi: 4, tom_mid: 2, tom_low: 5, tom_floor: 5,
crash_l: 1, crash_r: 1, splash: 1, china: 1,
ride: 3, ride_bell: 3,
};
// Default kit — replicates the prior hardcoded 7-piece + kick layout.
// Users without a saved kit see exactly what the mockup originally
// showed.
const DEFAULT_KIT = {
version: 1,
name: 'Default 7-piece',
// Lanes in order (left-to-right on the highway). The kick is a
// special full-width bar at the bottom — its position in this
// list is irrelevant (always rendered as the bar), but it must
// be present for the bar to show.
lanes: [
{ piece: 'hh_closed' },
{ piece: 'snare' },
{ piece: 'tom_hi' },
{ piece: 'tom_mid' },
{ piece: 'tom_floor' },
{ piece: 'crash_l' },
{ piece: 'ride' },
{ piece: 'kick' },
],
// Chart piece-id → user's piece-id when the chart has a piece
// the user didn't map. These explicit fallbacks cover the most
// common kit layouts; any unmapped piece is dropped at render time.
fallbacks: {
snare_xstick: 'snare',
hh_open: 'hh_closed', hh_pedal: 'hh_closed',
tom_low: 'tom_mid',
crash_r: 'crash_l', splash: 'crash_l', china: 'crash_l',
ride_bell: 'ride',
},
};
const LS_KIT_CONFIG = 'drum_h3d_kit_v1';
// Module-scope mutable lane layout derived from the active kit. Read
// by createFactory's scene-builders. _rebuildLanesFromKit recomputes
// when the kit changes; instances rebuild their scene by calling
// teardown() + initScene() on the same renderer.
let LANES = []; // ordered hand-lane entries + (optionally) kick last
let LANE_COUNT = 0; // count of HAND lanes (excludes the kick bar)
let LANE_X0 = 0;
let KICK_W = 0;
function _validateKit(raw) {
// Light validation — accept anything that has lanes[] with
// pieces in ALL_PIECES. Reject malformed input rather than
// letting a bad config crash initScene.
if (!raw || typeof raw !== 'object') return null;
const lanes = Array.isArray(raw.lanes) ? raw.lanes : null;
if (!lanes) return null;
const cleanLanes = [];
const seenPieces = new Set();
for (const ln of lanes) {
if (!ln || typeof ln.piece !== 'string') continue;
if (!ALL_PIECES.includes(ln.piece)) continue;
if (seenPieces.has(ln.piece)) continue;
seenPieces.add(ln.piece);
cleanLanes.push({ piece: ln.piece });
}
if (cleanLanes.length === 0) return null;
const fb = (raw.fallbacks && typeof raw.fallbacks === 'object') ? raw.fallbacks : {};
const cleanFb = {};
for (const [from, to] of Object.entries(fb)) {
if (!ALL_PIECES.includes(from) || !ALL_PIECES.includes(to)) continue;
if (!seenPieces.has(to)) continue;
cleanFb[from] = to;
}
return {
version: 1,
name: typeof raw.name === 'string' ? raw.name.slice(0, 80) : 'Custom kit',
lanes: cleanLanes,
fallbacks: cleanFb,
};
}
function _readKitConfig() {
try {
const raw = localStorage.getItem(LS_KIT_CONFIG);
if (!raw) return null;
return _validateKit(JSON.parse(raw));
} catch (_) { return null; }
}
function _writeKitConfig(kit) {
try { localStorage.setItem(LS_KIT_CONFIG, JSON.stringify(kit)); } catch (_) {}
}
// The active kit object — replaceable at runtime via setKit().
let _activeKit = _readKitConfig() || DEFAULT_KIT;
function _rebuildLanesFromKit(kit) {
// Split: hand lanes first (in the user's chosen order), kick
// last (if present). This matches the original 8-entry LANES
// shape so all downstream geometry code keeps working.
const handLanes = [];
let kickLane = null;
for (const ln of kit.lanes) {
const piece = ln.piece;
const entry = {
kind: PIECE_CATEGORY[piece] || 'drum',
subKind: PIECE_SUBKIND[piece] || 'tom',
label: PIECE_LABELS[piece] || piece.toUpperCase(),
paletteIdx: PIECE_PALETTE_IDX[piece] || 0,
piece,
};
if (entry.kind === 'kick') kickLane = entry;
else handLanes.push(entry);
}
LANES = kickLane ? handLanes.concat([kickLane]) : handLanes.slice();
LANE_COUNT = handLanes.length;
LANE_X0 = -((LANE_COUNT - 1) / 2) * LANE_GAP;
KICK_W = Math.max(1, LANE_COUNT) * LANE_GAP + 2 * K;
}
_rebuildLanesFromKit(_activeKit);
const KICK_H = 1.4 * K; // kick bar thickness
const KICK_D = 4.0 * K; // kick bar depth (along scroll)
// Variant size multipliers.
const GHOST_SCALE = 0.65;
const ACCENT_SCALE = 1.25;
const FLAM_GRACE_OFFSET = 0.08; // seconds before main note
const FLAM_GRACE_SCALE = 0.55;
// How many seconds of upcoming notes are visible at once.
const AHEAD = 3.2;
const BEHIND = 0.4;
// Map drum_tab piece-ids (lib/drums.py PIECES) → lane index in the
// active user kit's LANES. Rebuilt by _rebuildPieceToLaneMap whenever
// the kit changes. Pieces the user doesn't have route through
// _activeKit.fallbacks; pieces with no direct or fallback mapping get
// `undefined` and the hit is dropped at render time.
let PIECE_TO_LANE = {};
function _rebuildPieceToLaneMap(kit) {
PIECE_TO_LANE = {};
for (let i = 0; i < LANES.length; i++) {
const piece = LANES[i].piece;
if (piece) PIECE_TO_LANE[piece] = i;
}
// Apply user-defined fallbacks to cover chart pieces the user
// doesn't have on their kit (e.g. chart has tom_low + tom_floor,
// user has only one floor tom → fallback maps low onto floor).
const fb = (kit && kit.fallbacks) || {};
for (const piece of ALL_PIECES) {
if (PIECE_TO_LANE[piece] !== undefined) continue;
const target = fb[piece];
if (target && PIECE_TO_LANE[target] !== undefined) {
PIECE_TO_LANE[piece] = PIECE_TO_LANE[target];
}
}
}
_rebuildPieceToLaneMap(_activeKit);
// Build a horizontal gaussian DataTexture for additive flash quads.
// PORTED FROM highway_3d/screen.js _makeGaussTex — keep in sync.
// Returns a W×1 RGBA texture where alpha follows exp(-0.5*(u0.5)²/σ²),
// peaking at 1.0 in the centre; σ=0.28 keeps ~0.20 alpha at the edges so
// the additive falloff fades gradually instead of cutting off sharply.
function _makeGaussTex(ThreeLib, w = 128, sigma = 0.28) {
const data = new Uint8Array(w * 4);
for (let i = 0; i < w; i++) {
const u = i / (w - 1);
const d = (u - 0.5) / sigma;
const v = Math.exp(-0.5 * d * d);
const a = Math.round(v * 255);
data[i * 4] = 255;
data[i * 4 + 1] = 255;
data[i * 4 + 2] = 255;
data[i * 4 + 3] = a;
}
const tex = new ThreeLib.DataTexture(data, w, 1, ThreeLib.RGBAFormat);
// LinearFilter on both axes so the quad interpolates smoothly when
// scaled — the default NearestFilter causes visible banding.
tex.magFilter = ThreeLib.LinearFilter;
tex.minFilter = ThreeLib.LinearFilter;
tex.needsUpdate = true;
return tex;
}
// 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). Within 40% of the hit window
// either way reads as on-time — same proportions as highway_3d's
// timing verdicts.
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';
}
// Resolve a drum_tab hit's visual variant. Order matters: ghost wins over
// accent (ghost notes are intentionally quiet so their flag dominates),
// flam adds the leading grace disc, ride_bell adds the bright dot. A
// loud non-ghost hit (v >= 100) is an accent.
function _variantForHit(hit) {
if (hit.g) return 'ghost';
if (hit.f) return 'flam';
if (hit.p === 'ride_bell') return 'bell';
const v = typeof hit.v === 'number' ? hit.v : 100;
if (v >= 100) return 'accent';
return 'normal';
}
/* ======================================================================
* MIDI input + hit detection (module-scope singletons)
* ====================================================================== */
// Reverse of lib/drums.py PIECES default midis. The 2D drums plugin keeps
// a user-editable mapping in localStorage; the 3D viz starts with this
// baseline and the settings UI can override later. Multiple MIDI notes
// can map to the same piece-id (e.g. 35 & 36 both → kick).
const MIDI_TO_PIECE = {
35: 'kick', 36: 'kick',
37: 'snare_xstick',
38: 'snare', 40: 'snare',
41: 'tom_floor',
42: 'hh_closed',
43: 'tom_low', 58: 'tom_low',
44: 'hh_pedal',
45: 'tom_mid', 47: 'tom_mid',
46: 'hh_open',
48: 'tom_hi', 50: 'tom_hi',
49: 'crash_l',
51: 'ride', 59: 'ride',
52: 'china',
53: 'ride_bell',
55: 'splash',
57: 'crash_r',
};
// ±50 ms hit window — same as the 2D drums plugin so users get
// identical timing across both visualisations.
const HIT_TOLERANCE_S = 0.05;
// Legacy id-only storage key. Read for migration; new writes go to
// LS_MIDI_PICK (id + name).
const LS_MIDI_INPUT = 'drum_h3d_midi_input';
const LS_MIDI_PICK = 'drum_h3d_midi_pick_v2';
// Inputs whose name matches this regex are skipped from the auto-pick
// fallback. They're either passthrough loopbacks (Midi Through) or
// audio interfaces that happen to expose a MIDI port but don't have
// a controller behind them.
const _MIDI_BLOCKLIST_RE = /midi through|^thru\b|^iac\b/i;
// MIDI is sourced from the core `midi-input` capability domain
// (window.slopsmith.midiInput) rather than a private requestMIDIAccess() —
// one device-access boundary shared with piano/drums/keys/onboarding.
const _MIDI_REQUESTER = 'drum_highway_3d';
let _midiReady = false; // discover() has run
let _midiHandle = null; // live domain session handle (addListener/removeListener)
let _midiListener = null; // addListener callback wrapping _midiOnMessage
let _midiStateSub = false; // subscribed to midi-input:sources-changed
let _midiInput = null; // selected source descriptor { id, name }
// Set to true by _midiConnect when a new device is selected. The render
// loop reads and clears this to skip retroactive miss-counting for notes
// that elapsed while no device was connected.
let _midiJustConnected = false;
// Gates the live listener wiring. init() flips true via _midiResume() and
// the last destroy() flips false via _midiReleaseSession(). Necessary because _midiConnect is
// async — an open() can resolve after the renderer was destroyed, and
// without this gate addListener would wire hits into a dead instance.
let _midiActive = false;
// Routes incoming MIDI events to the focused renderer (null when no
// instance is active). Splitscreen support is deferred; for now this
// tracks the singleton instance.
let _activeInstance = null;
const _instances = new Set();
function _readStore(k) {
try { return localStorage.getItem(k); } catch (_) { return null; }
}
function _writeStore(k, v) {
try { localStorage.setItem(k, v); } catch (_) {}
}
// The core midi-input domain, if present (it ships with core).
function _mi() {
const m = window.slopsmith && window.slopsmith.midiInput;
return (m && m.version === 1) ? m : null;
}
// Domain sources shaped like the old MIDIInput list: { id, name }.
// sourceId == the old MIDIInput.id, so saved picks stay compatible.
function _midiSources() {
const mi = _mi();
if (!mi) return [];
return mi.listSources().map(s => ({ id: s.sourceId, name: s.label, key: s.logicalSourceKey }));
}
// Detach the live listener + release the domain session.
function _midiDetach() {
// Invalidate any in-flight _midiConnect open: a detach driven by device
// removal (sources-changed) or an opt-out must supersede a pending open
// so it can't resume and install a handle for a now-gone source.
_midiConnectSeq += 1;
if (_midiHandle && _midiListener) { try { _midiHandle.removeListener(_midiListener); } catch (_) { /* best-effort */ } }
const mi = _mi();
if (mi && _midiInput) { try { mi.close({ requester: _MIDI_REQUESTER, logicalSourceKey: _midiInput.key || ('web-midi::' + _midiInput.id) }); } catch (_) { /* best-effort */ } }
_midiHandle = null;
_midiListener = null;
_midiInput = null;
}
let _midiInitInFlight = null;
let _midiConnectSeq = 0; // generation guard for async _midiConnect races
async function _midiInit() {
if (_midiReady) {
// Only (re)connect when there's no live session. A repeated init
// (settings panel open, extra splitscreen instance) must NOT re-enter
// _midiConnect on an active handle — that tears down the live session
// and releases held pads for no reason. After a full release the
// handle is null, so reconnect happens then.
if (!_midiHandle) _midiAutoConnect();
return;
}
if (_midiInitInFlight) return _midiInitInFlight;
const mi = _mi();
if (!mi) return;
_midiInitInFlight = (async () => {
try {
const r = await mi.discover(); // permission boundary (requestMIDIAccess, in core)
// Only latch ready on a successful discovery — a denied/unavailable
// outcome must NOT latch, or reopening never retries the prompt.
if (!r || r.outcome !== 'handled') return;
_midiReady = true;
// Replug/unplug refresh (replaces MIDIAccess.onstatechange).
if (!_midiStateSub && window.slopsmith && typeof window.slopsmith.on === 'function') {
_midiStateSub = true;
window.slopsmith.on('midi-input:sources-changed', () => {
_midiNotifyDeviceListChanged();
// Reconnect the saved device when it (re)appears after an
// unplug; recovery only (no fallback) so a transient unplug
// doesn't switch to / persist another input.
if (!_midiInput) _midiAutoConnect(false);
});
}
_midiAutoConnect();
_midiNotifyDeviceListChanged();
} catch (e) {
console.warn('[Drum-Hwy3D] MIDI access denied:', e);
} finally {
_midiInitInFlight = null;
}
})();
return _midiInitInFlight;
}
function _readSavedPick() {
// New v2 storage: {id, name} JSON. Falls back to legacy v1 id-only.
// Coerce id/name to strings so _midiAutoConnect can safely call
// .toLowerCase() even if the stored JSON was manually edited.
try {
const v2 = _readStore(LS_MIDI_PICK);
if (v2) {
const obj = JSON.parse(v2);
if (obj && typeof obj === 'object') {
return { id: String(obj.id || ''), name: String(obj.name || ''), key: String(obj.key || '') };
}
}
} catch (_) {}
const v1 = _readStore(LS_MIDI_INPUT);
if (typeof v1 === 'string') return { id: v1, name: '' };
return null;
}
function _writeSavedPick(id, name, key) {
try {
localStorage.setItem(LS_MIDI_PICK, JSON.stringify({ id: id || '', name: name || '', key: key || '' }));
// Keep the legacy key in sync so a downgrade is non-destructive.
localStorage.setItem(LS_MIDI_INPUT, id || '');
} catch (_) {}
}
function _midiAutoConnect(allowFallback) {
// Recovery (sources-changed after unplug) passes false: never switch to a
// fallback input, because _midiConnect persists the pick and that would
// overwrite the user's saved device on a transient multi-device unplug
// (the original returns on replug and reconnects then).
if (allowFallback === undefined) allowFallback = true;
const inputs = _midiSources();
if (!inputs.length) return;
const saved = _readSavedPick();
// Explicit None — user opted out, don't auto-connect.
if (saved && saved.id === '' && saved.name === '') return;
// Prefer exact id match (browser kept the id stable). Fall back
// to case-insensitive name match — Chrome+Linux regenerates the
// hashed id on every page load, so id-only matching strands the
// saved pick after the first reload.
let target = null;
// Prefer the globally-unique logicalSourceKey, then the legacy sourceId.
if (saved && saved.key) {
target = inputs.find(i => i.key === saved.key) || null;
}
if (!target && saved && saved.id) {
target = inputs.find(i => i.id === saved.id) || null;
}
if (!target && saved && saved.name) {
const n = saved.name.toLowerCase();
target = inputs.find(i => (i.name || '').toLowerCase() === n) || null;
}
if (!target) {
// Skip the substitute ONLY when a saved pick exists but is currently
// absent (recovery: preserve it, don't clobber on a transient unplug).
// With no saved pick at all, a fallback is the intended first-hotplug
// auto-connect — allow it even in recovery.
const hasSavedPick = !!(saved && (saved.key || saved.id || saved.name));
if (!allowFallback && hasSavedPick) return;
// Pick the first input that isn't a loopback/passthrough. Falls back to
// inputs[0] only if every input is blocklisted.
target = inputs.find(i => !_MIDI_BLOCKLIST_RE.test(i.name || '')) || inputs[0];
}
_midiConnect(target.id, target.name, target.key);
}
async function _midiConnect(id, name, key) {
// Capture our generation AFTER _midiDetach()'s own bump, so a later
// detach (device removal / new connect / opt-out) reliably supersedes us.
_midiDetach();
const myGen = ++_midiConnectSeq;
// Pass through `name` so a future id-drift can still find this
// device. Empty id is the explicit-None opt-out.
_writeSavedPick(id || '', name || '', key || '');
const mi = _mi();
if ((id || key) && mi) {
// Prefer the globally-unique logicalSourceKey so two providers that
// expose the same provider-local sourceId stay distinguishable; fall
// back to the legacy sourceId match.
const src = (key && _midiSources().find(s => s.key === key))
|| (id && _midiSources().find(s => s.id === id))
|| null;
if (src) {
const lkey = src.key || ('web-midi::' + src.id);
_midiInput = { id: src.id, name: src.name, key: lkey };
_midiJustConnected = true;
// No live renderer to consume OR release a session — don't hold one
// open (settings-only ensure-init, or the last instance was torn
// down during async discovery). The pick is saved; a later renderer
// mount re-runs auto-connect and opens for real, releasing on destroy.
if (_instances.size === 0) { _midiNotifyDeviceListChanged(); return; }
try {
await mi.select(lkey);
const res = await mi.open({ requester: _MIDI_REQUESTER, logicalSourceKey: lkey });
// A newer _midiConnect (device switch / None / replug) ran while
// we awaited open — discard this stale session so we don't wire a
// listener for a device the user already moved off of.
if (myGen !== _midiConnectSeq) {
if (!_midiInput || _midiInput.key !== lkey) { try { mi.close({ requester: _MIDI_REQUESTER, logicalSourceKey: lkey }); } catch (_) { /* best-effort */ } }
return;
}
if (res && res.handle) {
_midiHandle = res.handle;
// The domain handle delivers raw MIDI data; adapt to the
// old MIDIMessageEvent shape so _midiOnMessage is unchanged.
_midiListener = (data) => _midiOnMessage({ data });
if (_midiActive) _midiHandle.addListener(_midiListener);
} else {
// Open yielded no live handle (device vanished post-discovery,
// or denied/unavailable). Clear the selection so the render
// loop's connected-device gate doesn't sweep phantom misses.
_midiInput = null;
}
} catch (e) {
console.warn('[Drum-Hwy3D] MIDI open failed:', e);
// Only clear if we're still the current connect — a stale older
// open's rejection must not wipe a newer connect's installed
// _midiInput/_midiHandle (which would also leak the live handle,
// since closes are gated on _midiInput).
if (myGen === _midiConnectSeq) _midiInput = null;
}
}
}
// Notify on BOTH connect and disconnect/opt-out so any open
// settings panel re-renders its device dropdown consistently.
_midiNotifyDeviceListChanged();
}
function _midiResume() {
// Idempotent: a second live renderer instance (splitscreen/overlapping
// lifetimes) calls this while already active. The domain handle's
// addListener is Set-backed, but don't rely on the provider de-duping —
// re-adding here could double-deliver one MIDI hit to the focused
// instance and score a hit plus duplicate misses.
if (_midiActive) return;
_midiActive = true;
if (_midiHandle && _midiListener) { try { _midiHandle.addListener(_midiListener); } catch (_) { /* best-effort */ } }
}
// Called when the LAST live instance is torn down: fully release the shared
// midi-input domain session (via _midiDetach: close + null + generation bump),
// not just the listener, so the device/provider session isn't held open after
// the visualization is gone. Re-mount's _midiInit auto-connects from the saved
// pick, so _midiReady is intentionally left latched.
function _midiReleaseSession() {
_midiActive = false;
_midiDetach();
}
function _midiOnMessage(e) {
if (!_activeInstance) return;
const data = e.data;
if (!data || data.length < 3) return;
const type = data[0] & 0xf0;
const note = data[1];
const vel = data[2];
// 0x90 = note-on. note-on with velocity 0 is a note-off (running
// status); skip those too.
if (type !== 0x90 || vel === 0) return;
_activeInstance._handleDrumHit(note, vel);
}
// Fired when the MIDI access state changes (device plugged/unplugged
// or a connect/disconnect from _midiConnect). Settings panels listen
// via the global event so they can re-render their device dropdown.
function _midiNotifyDeviceListChanged() {
// If the currently-selected input was disconnected, clear the reference
// so _updateMissed() stops counting and the UI shows "no device".
if (_midiInput && !_midiSources().some(s => s.id === _midiInput.id)) {
_midiDetach();
}
try {
window.dispatchEvent(new CustomEvent('drum_h3d:midi_devices'));
} catch (_) {}
}
// List the currently-known input devices for the settings UI.
function _midiListInputs() {
return _midiSources().map(s => ({ id: s.id, name: s.name || s.id }));
}
function _midiCurrentInputId() {
return _midiInput ? _midiInput.id : '';
}
/* ── Kit configuration API for the settings panel ──────────────── */
window.drumH3dGetKit = function () {
// Returns a deep clone so the settings UI can mutate without
// disturbing the live config.
return JSON.parse(JSON.stringify(_activeKit));
};
window.drumH3dGetAllPieces = function () {
return ALL_PIECES.slice();
};
window.drumH3dGetPieceLabels = function () {
return Object.assign({}, PIECE_LABELS);
};
window.drumH3dGetPieceCategory = function () {
return Object.assign({}, PIECE_CATEGORY);
};
// Exposed so settings.html palette swatches stay in sync with the
// renderer without duplicating the mapping. kick maps to -1 here
// (settings.html convention = render amber, matching the renderer's
// special-case amber for kick bars).
window.drumH3dGetPiecePaletteIdx = function () {
return Object.assign({}, PIECE_PALETTE_IDX, { kick: -1 });
};
window.drumH3dSetKit = function (raw) {
const kit = _validateKit(raw);
if (!kit) return false;
_activeKit = kit;
_writeKitConfig(kit);
_rebuildLanesFromKit(kit);
_rebuildPieceToLaneMap(kit);
// Notify renderers + settings panels — each instance rebuilds its
// scene; settings panels re-render to reflect the new kit.
try {
window.dispatchEvent(new CustomEvent('drum_h3d:kit', { detail: { kit } }));
} catch (_) {}
return true;
};
window.drumH3dResetKit = function () {
window.drumH3dSetKit(DEFAULT_KIT);
};
// Name-only update — does NOT dispatch 'drum_h3d:kit' (which triggers a
// full WebGL scene teardown/reinit). Typing in the kit-name field therefore
// causes zero GPU overhead; the new name is persisted immediately so it
// survives export/clipboard share on the same keystroke.
window.drumH3dSetKitName = function (name) {
const trimmed = String(name || '').slice(0, 80);
if (!_activeKit) return;
_activeKit.name = trimmed;
_writeKitConfig(_activeKit);
};
// base64(JSON) round-trip for sharing kits between users.
// Use TextEncoder/TextDecoder for UTF-8-safe base64 (escape/unescape are
// deprecated and can mis-handle non-ASCII characters in kit names).
window.drumH3dExportKit = function () {
const bytes = new TextEncoder().encode(JSON.stringify(_activeKit));
return btoa(String.fromCharCode(...bytes));
};
window.drumH3dImportKit = function (b64) {
try {
const binStr = atob((b64 || '').trim());
const bytes = Uint8Array.from(binStr, c => c.charCodeAt(0));
const obj = JSON.parse(new TextDecoder().decode(bytes));
return window.drumH3dSetKit(obj);
} catch (_) { return false; }
};
/* ── MIDI device control API (consumed by settings.html) ───── */
window.drumH3dEnsureMidiInit = function () {
// Settings panel calls this on mount so the user can see + pick
// MIDI devices without having to load a song first. Fires the
// browser permission prompt the first time; idempotent after.
// Returns a promise that resolves when _midiInit settles.
return _midiInit();
};
window.drumH3dListMidiInputs = function () {
// Returns [{id, name}, ...] of all currently-known inputs, in
// whatever order the browser enumerates them. Settings panel
// re-renders on the 'drum_h3d:midi_devices' event.
return _midiListInputs();
};
window.drumH3dGetMidiInputId = function () {
return _midiCurrentInputId();
};
window.drumH3dSetMidiInput = function (id) {
// Empty string = explicit "None" opt-out. Persists by name +
// id via _midiConnect so a future page reload still finds the
// device after Chrome regenerates ids.
// `id` may be a logicalSourceKey (new host calls) or a legacy sourceId.
const src = id
? (_midiSources().find(s => s.key === id) || _midiSources().find(s => s.id === id))
: null;
_midiConnect(src ? src.id : (id || ''), src ? src.name : '', src ? src.key : '');
return true;
};
window.drumH3dGetSynthVolume = function () {
// When the synth has not initialised yet (viz never ran, settings
// opened first), read from localStorage so the settings slider shows
// the persisted value rather than the default 0.70.
if (!_synthPlayer) {
const raw = _readStore(LS_SYNTH_VOL);
const parsed = raw === null ? NaN : parseFloat(raw);
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 1) return parsed;
}
return _synthVolume;
};
window.drumH3dSetSynthVolume = function (v) {
_synthSetVolume(v);
};
/* ======================================================================
* WebAudioFont drum-kit synth (module-scope, one AudioContext per tab)
*
* Ported verbatim from slopsmith-plugin-drums so the 3D viz makes the
* same kit sounds when the user strikes a pad. Preset URLs and the
* JCLive soundfont match the 2D plugin so the kit feels identical.
* Volume defaults to 0.7 and persists via `drum_h3d_synth_vol`.
* ====================================================================== */
const WAF_PLAYER_URL = 'https://surikov.github.io/webaudiofont/npm/dist/WebAudioFontPlayer.js';
const WAF_BASE = 'https://surikov.github.io/webaudiofontdata/sound/';
const WAF_SF = 'JCLive_sf2_file';
// GM percussion notes the JCLive soundfont actually maps. Matches the
// 2D plugin's DRUM_MIDI_NOTES; adding entries here would just download
// 404s if the soundfont doesn't ship them.
const DRUM_MIDI_NOTES = [35, 36, 38, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 57, 59];
const LS_SYNTH_VOL = 'drum_h3d_synth_vol';
let _audioCtx = null;
let _synthPlayer = null;
let _synthGain = null;
let _synthVolume = 0.7;
let _playerScriptLoaded = false;
let _synthInitInFlight = null; // shared promise while _synthInit is running
const _drumPresets = {}; // midiNote -> WebAudioFont preset
function _loadScript(url) {
return new Promise((resolve, reject) => {
// Reuse an existing tag only if it succeeded (no data-error).
// A previously-failed tag must be removed and retried so we don't
// resolve immediately with a script that never actually executed.
// An in-flight tag (no data-loaded / data-error yet) gets
// additional listeners so we wait for its outcome rather than
// resolving immediately before it has actually run.
const existing = document.querySelector(`script[src="${url}"]`);
if (existing) {
if (existing.dataset.error) {
// Previous load failed — remove and retry.
existing.remove();
} else if (existing.dataset.loaded) {
// Already fully executed.
resolve();
return;
} else {
// Still in flight — piggyback on the same tag.
existing.addEventListener('load', () => { existing.dataset.loaded = '1'; resolve(); }, { once: true });
existing.addEventListener('error', () => { existing.dataset.error = '1'; reject(new Error('Failed to load ' + url)); }, { once: true });
return;
}
}
const s = document.createElement('script');
s.src = url;
s.onload = () => { s.dataset.loaded = '1'; resolve(); };
s.onerror = () => { s.dataset.error = '1'; reject(new Error('Failed to load ' + url)); };
document.head.appendChild(s);
});
}
function _wafVar(note) { return '_drum_' + note + '_0_' + WAF_SF; }
function _wafUrl(note) { return WAF_BASE + '128' + note + '_0_' + WAF_SF + '.js'; }
async function _synthInit() {
if (_synthPlayer) return;
// Guard against concurrent calls: if a prior call is still awaiting
// script/preset loads, share its promise so only one AudioContext and
// one WebAudioFontPlayer are ever created per tab.
if (_synthInitInFlight) return _synthInitInFlight;
_synthInitInFlight = (async () => {
try {
const raw = _readStore(LS_SYNTH_VOL);
const parsed = raw === null ? NaN : parseFloat(raw);
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 1) _synthVolume = parsed;
if (!_playerScriptLoaded) {
await _loadScript(WAF_PLAYER_URL);
_playerScriptLoaded = true;
}
if (typeof WebAudioFontPlayer === 'undefined') return;
_audioCtx = new (window.AudioContext || window.webkitAudioContext)();
_synthGain = _audioCtx.createGain();
_synthGain.gain.value = _synthVolume;
_synthGain.connect(_audioCtx.destination);
_synthPlayer = new WebAudioFontPlayer();
// Chrome creates AudioContext in suspended state and refuses to
// play scheduled audio until a user gesture resumes it. MIDI
// note-on events count as gestures in recent Chrome — but only
// if the gesture-to-resume gap is small. Arm a one-shot
// pointerdown / keydown listener so the very first user
// interaction anywhere on the page unblocks playback. The
// {once:true} flag detaches the listener after it fires.
const _resumeOnGesture = () => {
if (_audioCtx && _audioCtx.state === 'suspended') _audioCtx.resume();
};
window.addEventListener('pointerdown', _resumeOnGesture, { once: true, capture: true });
window.addEventListener('keydown', _resumeOnGesture, { once: true, capture: true });
await _synthLoadKit();
} catch (e) {
console.warn('[Drum-Hwy3D] Synth init failed:', e);
} finally {
_synthInitInFlight = null;
}
})();
return _synthInitInFlight;
}
async function _synthLoadKit() {
if (!_synthPlayer || !_audioCtx) return;
await Promise.all(DRUM_MIDI_NOTES.map(async (note) => {
const varName = _wafVar(note);
try {
if (!window[varName]) await _loadScript(_wafUrl(note));
const preset = window[varName];
if (preset) {
_synthPlayer.adjustPreset(_audioCtx, preset);
_drumPresets[note] = preset;
}
} catch (e) {
console.warn('[Drum-Hwy3D] preset load failed for MIDI ' + note + ':', e);
}
}));
}
function _synthEnsureCtx() {
// Most browsers block AudioContext until a user gesture. MIDI input
// counts as a gesture in Chrome — _synthDrumHit kicks the context
// out of suspended on first hit.
if (_audioCtx && _audioCtx.state === 'suspended') _audioCtx.resume();
}
function _synthDrumHit(midiNote, velocity) {
if (!_synthPlayer || !_audioCtx || !_synthGain) return;
const preset = _drumPresets[midiNote];
if (!preset) return;
_synthEnsureCtx();
// Velocity-to-volume: normalise to 0-1. The overall slider level is
// already applied by _synthGain (its gain.value == _synthVolume), so
// don't multiply here — that would square the volume.
const vol = (velocity || 100) / 127;
// 0.5 s queue duration — drum samples are short, no sustain needed.
_synthPlayer.queueWaveTable(_audioCtx, _synthGain, preset, 0, midiNote, 0.5, vol);
}
function _synthSetVolume(v) {
const c = Math.max(0, Math.min(1, Number(v) || 0));
_synthVolume = c;
if (_synthGain) _synthGain.gain.value = c;
_writeStore(LS_SYNTH_VOL, String(c));
}
/* ======================================================================
* Demo patterns — hardcoded, loop indefinitely
* ====================================================================== */
// Each pattern: { length: seconds, notes: [{ t, lane, variant? }] }
// lane is the LANES index. variant is optional and recognised values are
// 'accent' | 'ghost' | 'flam' | 'bell' (bell only meaningful on ride lane).
//
// Times are in seconds within the loop. The renderer schedules each note
// at every loop cycle (t, t+length, t+2*length, …) within the active
// window, so notes recycle naturally without bookkeeping.
const DEMO_PATTERNS = {
// Classic rock backbeat: kick on 1+3, snare on 2+4, hi-hat 8ths,
// crash on the downbeat of bar 1. 4-bar loop at ~120 BPM (2s/bar).
rock_backbeat: {
length: 8.0,
notes: (() => {
const out = [];
for (let bar = 0; bar < 4; bar++) {
const b = bar * 2.0;
// hi-hat 8ths
for (let i = 0; i < 8; i++) out.push({ t: b + i * 0.25, lane: 0 });
// kick on 1 and 3
out.push({ t: b + 0.0, lane: 7 });
out.push({ t: b + 1.0, lane: 7 });
// snare on 2 and 4
out.push({ t: b + 0.5, lane: 1 });
out.push({ t: b + 1.5, lane: 1 });
// crash on bar-1 downbeat
if (bar === 0) out.push({ t: b + 0.0, lane: 5, variant: 'accent' });
}
return out;
})(),
},
// Jazz swing — ride pattern, ghost-note snare comping, soft kick.
jazz_swing: {
length: 8.0,
notes: (() => {
const out = [];
for (let bar = 0; bar < 4; bar++) {
const b = bar * 2.0;
// ride: ding ding-a ding ding-a (swing 8ths)
for (let beat = 0; beat < 4; beat++) {
const onBeat = b + beat * 0.5;
out.push({ t: onBeat, lane: 6, variant: beat === 0 ? 'bell' : undefined });
// swing "and" — closer to the next beat
if (beat === 1 || beat === 3) {
out.push({ t: onBeat + 0.33, lane: 6 });
}
}
// hi-hat foot on 2 and 4
out.push({ t: b + 0.5, lane: 0 });
out.push({ t: b + 1.5, lane: 0 });
// snare ghost comping
out.push({ t: b + 0.75, lane: 1, variant: 'ghost' });
out.push({ t: b + 1.25, lane: 1, variant: 'ghost' });
// soft kick on 1
out.push({ t: b + 0.0, lane: 7 });
}
return out;
})(),
},
// Showcase every variant: ghost, accent, flam, bell — plus every
// lane fires at least once. Designed to read each visual element
// clearly without flipping settings.
fill_showcase: {
length: 8.0,
notes: [
// Bar 1 — basic groove to set the stage
{ t: 0.00, lane: 7 }, // kick
{ t: 0.00, lane: 0 }, // hh
{ t: 0.00, lane: 5, variant: 'accent' }, // crash accent (lane 5)
{ t: 0.25, lane: 0 },
{ t: 0.50, lane: 1 }, // snare
{ t: 0.50, lane: 0 },
{ t: 0.75, lane: 0 },
{ t: 1.00, lane: 7 },
{ t: 1.00, lane: 0 },
{ t: 1.25, lane: 0 },
{ t: 1.50, lane: 1 },
{ t: 1.50, lane: 0 },
{ t: 1.75, lane: 0 },
// Bar 2 — ghost note showcase on snare
{ t: 2.00, lane: 7 },
{ t: 2.00, lane: 0 },
{ t: 2.125, lane: 1, variant: 'ghost' },
{ t: 2.25, lane: 0 },
{ t: 2.375, lane: 1, variant: 'ghost' },
{ t: 2.50, lane: 1 },
{ t: 2.50, lane: 0 },
{ t: 2.625, lane: 1, variant: 'ghost' },
{ t: 2.75, lane: 0 },
{ t: 3.00, lane: 7 },
{ t: 3.50, lane: 1, variant: 'accent' }, // snare accent
// Bar 3 — flam showcase + ride
{ t: 4.00, lane: 7 },
{ t: 4.00, lane: 6 }, // ride
{ t: 4.50, lane: 1, variant: 'flam' }, // snare flam
{ t: 5.00, lane: 6, variant: 'bell' }, // ride bell
{ t: 5.50, lane: 1, variant: 'flam' }, // snare flam
// Bar 4 — tom roll down the kit
{ t: 6.00, lane: 7 },
{ t: 6.00, lane: 2 }, // hi tom
{ t: 6.25, lane: 2 },
{ t: 6.50, lane: 3 }, // mid tom
{ t: 6.75, lane: 3 },
{ t: 7.00, lane: 4 }, // floor tom
{ t: 7.25, lane: 4 },
{ t: 7.50, lane: 5, variant: 'accent' }, // crash accent
{ t: 7.50, lane: 7 }, // kick under crash
],
},
};
/* ======================================================================
* Settings hydration — palette/pattern/camera-angle live in localStorage.
* ====================================================================== */
const LS_KEYS = {
palette: 'drum_h3d_palette',
pattern: 'drum_h3d_pattern',
cameraAngle: 'drum_h3d_camera_angle',
};
function readSettings() {
let palette = 'default';
let pattern = 'rock_backbeat';
let cameraAngle = 0.35; // 0 = looking down the lanes, 1 = top-down
try {
const p = localStorage.getItem(LS_KEYS.palette);
if (p && PALETTES[p]) palette = p;
const pat = localStorage.getItem(LS_KEYS.pattern);
if (pat && DEMO_PATTERNS[pat]) pattern = pat;
const ca = parseFloat(localStorage.getItem(LS_KEYS.cameraAngle));
if (Number.isFinite(ca)) cameraAngle = Math.min(1, Math.max(0, ca));
} catch (_) { /* localStorage unavailable — use defaults */ }
return { palette, pattern, cameraAngle };
}
// Expose setters so settings.html can poke a live preview without a
// reload. Setters update localStorage and broadcast a CustomEvent that
// the renderer subscribes to.
window.drumH3dSetPalette = function (id) {
if (!PALETTES[id]) return;
try { localStorage.setItem(LS_KEYS.palette, id); } catch (_) {}
window.dispatchEvent(new CustomEvent('drum_h3d:settings', { detail: { palette: id } }));
};
window.drumH3dSetPattern = function (id) {
if (!DEMO_PATTERNS[id]) return;
try { localStorage.setItem(LS_KEYS.pattern, id); } catch (_) {}
window.dispatchEvent(new CustomEvent('drum_h3d:settings', { detail: { pattern: id } }));
};
window.drumH3dSetCameraAngle = function (v) {
const c = Math.min(1, Math.max(0, Number(v) || 0));
try { localStorage.setItem(LS_KEYS.cameraAngle, String(c)); } catch (_) {}
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,
sparks: true, // pooled hit-spark bursts (Points cloud)
hitFx: 0.7, // 01 master intensity for lane flashes / kick pulse
timingFx: true, // early/late/on-time coloring of hit feedback
streakFx: true, // consecutive-hit escalation (bigger spark bursts)
cinematic: true, // rebalanced lighting (dimmer ambient, stronger key)
glow: 0.5, // 01 emissive multiplier (0.5 = the stock look)
vibrancy: 0.85, // lane stripe / halo / ghost opacity strength
scoreFx: true, // 2D overlay: +N pops, combo rings, streak-break wash
bgIntensity: 0.5, // background-ambience density/strength
bgReactive: true, // background reacts to the audio analyser
};
const FX_LS_PREFIX = 'drum_h3d_bg_';
// Theme id lives OUTSIDE FX_DEFAULTS (string, not bool/number) — its own
// localStorage key + validation against BG_THEMES.
const FX_LS_THEME = 'drum_h3d_bg_theme';
// Background-ambience style — string-valued like the theme, so it gets
// its own validated key + setter rather than an FX_DEFAULTS slot.
// butterchurn/image/video from the guitar are deliberately out of scope
// (per-plugin vendored megabytes / custom-asset upload plumbing).
const BG_STYLE_IDS = ['off', 'particles', 'lights', 'geometric'];
const FX_LS_STYLE = 'drum_h3d_bg_style';
function readBgStyleSetting() {
try {
const id = localStorage.getItem(FX_LS_STYLE);
if (id && BG_STYLE_IDS.indexOf(id) !== -1) return id;
} catch (_) {}
return 'particles';
}
window.drumH3dSetBgStyle = function (id) {
if (BG_STYLE_IDS.indexOf(id) === -1) return;
try { localStorage.setItem(FX_LS_STYLE, id); } catch (_) {}
try {
window.dispatchEvent(new CustomEvent('drum_h3d:settings', { detail: { bgStyle: id } }));
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
};
/* ======================================================================
* Audio analyser bridge — module singletons, shared across instances.
* PORTED FROM highway_3d _bgGetAnalyser/_bgReadBands (keep in sync;
* delta: the guitar's diagnostics recordBridgeHit plumbing is dropped —
* this plugin has no diagnostics contribution).
* ====================================================================== */
const BG_FREQ_BINS = 128;
const BG_ZERO_BANDS = { bass: 0, mid: 0, treble: 0 };
let _bgAudio = null; // {ctx, analyser, freq, source} | {failed, permanent}
let _bgAudioCore = null; // remembered core tap (one-shot per element)
let _bgAudioFailedAt = 0;
const _BG_AUDIO_RETRY_MS = 1000;
function _bgGetAnalyser() {
// Prefer the stems plugin's side-chain analyser: on sloppaks the
// #audio element is a silent virtual transport, so tapping it sees
// only silence. The stems analyser is per-song — key the cache on
// its identity so a song switch re-adopts automatically.
const stemsApi = window.feedBack && window.feedBack.stems;
const stemsAnalyser = (stemsApi && typeof stemsApi.getAnalyser === 'function')
? stemsApi.getAnalyser() : null;
if (stemsAnalyser) {
if (!_bgAudio || _bgAudio.source !== 'stems' || _bgAudio.analyser !== stemsAnalyser) {
_bgAudio = {
ctx: stemsAnalyser.context,
analyser: stemsAnalyser,
freq: new Uint8Array(Math.max(BG_FREQ_BINS, stemsAnalyser.frequencyBinCount)),
source: 'stems',
};
}
return _bgAudio;
}
// No sloppak stems — drop a stale stems cache (restoring the core
// tap if one exists; re-tapping #audio would throw InvalidStateError).
if (_bgAudio && _bgAudio.source === 'stems') _bgAudio = _bgAudioCore;
if (_bgAudio && !_bgAudio.failed) return _bgAudio;
if (_bgAudio && _bgAudio.failed) {
if (_bgAudio.permanent) return null;
if (performance.now() - _bgAudioFailedAt < _BG_AUDIO_RETRY_MS) return null;
}
const audio = document.getElementById('audio');
if (!audio) return null;
// Never tap #audio before the page has user activation: a fresh
// AudioContext would start suspended and route LIVE playback into
// silence until the next play gesture (createMediaElementSource is
// one-shot — not undoable). Called per frame, so this just retries
// once activation exists. (Improvement over the guitar's copy, which
// only resumes after the fact — candidate to port back.)
const ua = navigator.userActivation;
if (ua && ua.hasBeenActive === false) return null;
// Shared tap: createMediaElementSource is one-shot per element, so
// the FIRST visualizer to tap #audio publishes it at
// window.__feedBackAudioTap and every later one (this plugin, the
// keys highway — the guitar is a port-back candidate) adopts it
// instead of throwing InvalidStateError in a mixed splitscreen.
const shared = window.__feedBackAudioTap;
if (shared && shared.analyser && shared.mediaEl === audio) {
_bgAudio = {
ctx: shared.ctx,
analyser: shared.analyser,
freq: new Uint8Array(Math.max(BG_FREQ_BINS, shared.analyser.frequencyBinCount)),
source: 'core',
};
_bgAudioCore = _bgAudio;
return _bgAudio;
}
let ctx = null;
try {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) throw new Error('Web Audio API not available');
ctx = new Ctx();
const source = ctx.createMediaElementSource(audio);
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
analyser.connect(ctx.destination);
_bgAudio = { ctx, analyser, freq: new Uint8Array(Math.max(BG_FREQ_BINS, analyser.frequencyBinCount)), source: 'core' };
_bgAudioCore = _bgAudio;
try { window.__feedBackAudioTap = { ctx, analyser, mediaEl: audio }; } catch (_) {}
const resume = () => {
if (ctx.state === 'suspended' && typeof ctx.resume === 'function') {
ctx.resume().catch(() => { /* no gesture yet, retry on next play */ });
}
};
resume();
audio.addEventListener('play', resume);
return _bgAudio;
} catch (e) {
if (ctx && typeof ctx.close === 'function') {
try { ctx.close(); } catch (_) {}
}
console.warn('[Drum-Hwy3D] failed to set up audio analyser:', e);
// InvalidStateError = the element is already tapped by another
// consumer (e.g. the guitar highway in a mixed split) — no
// recovery without a reload; styles keep animating on time only.
_bgAudio = { failed: true, permanent: !!(e && e.name === 'InvalidStateError') };
_bgAudioFailedAt = performance.now();
return null;
}
}
const _BG_BANDS_CACHE_MS = 5;
let _bgBandsLastT = -Infinity;
const _bgBandsCache = { bass: 0, mid: 0, treble: 0 };
function _bgReadBands() {
const a = _bgGetAnalyser();
if (!a) return BG_ZERO_BANDS;
const t = performance.now();
if (t - _bgBandsLastT < _BG_BANDS_CACHE_MS) return _bgBandsCache;
_bgBandsLastT = t;
a.analyser.getByteFrequencyData(a.freq);
let bass = 0, mid = 0, treble = 0;
for (let i = 0; i < 8; i++) bass += a.freq[i];
for (let i = 8; i < 40; i++) mid += a.freq[i];
for (let i = 40; i < 128; i++) treble += a.freq[i];
_bgBandsCache.bass = bass / (8 * 255);
_bgBandsCache.mid = mid / (32 * 255);
_bgBandsCache.treble = treble / (88 * 255);
return _bgBandsCache;
}
// Scene color themes — PORTED FROM highway_3d BG_THEMES (keep the ids,
// names and values in sync so a user's look carries across instruments).
// The guitar splits background/highway into two axes; here one pick
// drives both (clear/fog + board/lane) — the drum scene is simpler and
// a single dropdown reads better in its settings panel.
// clear/fog — WebGL clear color + distance fog (kept equal so the
// horizon dissolves cleanly)
// board — the floor plane under the lanes
// lane/laneDim — the alternating lane stripes; themes without them
// fall back to the stock teal/blue pair.
const BG_THEMES = {
// 'default' is THIS plugin's original palette (fog 0x1a1a2e, floor
// 0x0a0e1a, stock teal/blue stripes) — byte-identical for anyone who
// never touches the setting. The guitar's default differs; every
// OTHER id below matches the guitar's table exactly.
default: { clear: 0x1a1a2e, fog: 0x1a1a2e, board: 0x0a0e1a },
midnight: { clear: 0x0a0e1a, fog: 0x0a0e1a, board: 0x080d1c, lane: 0x244fae, laneDim: 0x122a5e },
charcoal: { clear: 0x16181c, fog: 0x16181c, board: 0x141417, lane: 0x525a66, laneDim: 0x282d34 },
deeppurple: { clear: 0x140a1e, fog: 0x140a1e, board: 0x0b0610, lane: 0x3a1f6e, laneDim: 0x1f1040 },
forest: { clear: 0x0a1614, fog: 0x0a1614, board: 0x06100c, lane: 0x15602a, laneDim: 0x0a3318 },
warmslate: { clear: 0x1c130b, fog: 0x1c130b, board: 0x0e0805, lane: 0x5e3a12, laneDim: 0x341f0a },
deepfocus: { clear: 0x0c0c0d, fog: 0x0c0c0d, board: 0x060606, lane: 0x2f7fa0, laneDim: 0x163c4e },
deepsea: { clear: 0x06222b, fog: 0x06222b, board: 0x03141a, lane: 0x0e5a63, laneDim: 0x063338 },
cathode: { clear: 0x140b03, fog: 0x140b03, board: 0x0c0702, lane: 0x6e4a0e, laneDim: 0x3a2806 },
cathodegreen: { clear: 0x07301a, fog: 0x07301a, board: 0x031a0c, lane: 0x0e6e2a, laneDim: 0x073a18 },
hearth: { clear: 0x280806, fog: 0x280806, board: 0x1a0606, lane: 0x7a2410, laneDim: 0x3f1409 },
};
function _bgThemeColors(id) { return BG_THEMES[id] || BG_THEMES.default; }
function readThemeSetting() {
try {
const id = localStorage.getItem(FX_LS_THEME);
if (id && BG_THEMES[id]) return id;
} catch (_) {}
return 'default';
}
window.drumH3dSetTheme = function (id) {
if (!BG_THEMES[id]) return;
try { localStorage.setItem(FX_LS_THEME, id); } catch (_) {}
try {
window.dispatchEvent(new CustomEvent('drum_h3d:settings', { detail: { theme: id } }));
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
};
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);
// 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 */ }
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;
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));
} catch (_) {}
try {
window.dispatchEvent(new CustomEvent('drum_h3d:settings', { detail: { fx: { [key]: v } } }));
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
};
/* ======================================================================
* Camera Director bridge resolver (pure — exported via createFactory.__test)
* ====================================================================== */
/**
* The active splitscreen API, defensive on the global-name rename in flight
* (feedBackSplitscreen is canonical; slopsmithSplitscreen is the legacy alias).
* @returns {object|null} the splitscreen API, or null when not present
*/
function _ssApi() { return window.feedBackSplitscreen || window.slopsmithSplitscreen || null; }
/**
* Resolve the Camera Director camera for a canvas: this panel's camera under
* splitscreen, else the global, else null (Camera Director absent → stock
* framing). Throw-safe on panelIndexFor so a misbehaving splitscreen build
* can't break framing.
* @param {HTMLCanvasElement} canvas this renderer's highway canvas
* @param {object|null} ss the splitscreen API (see _ssApi)
* @param {object|null} panelsMap window.__h3dCamCtlPanels (per-panel cameras by index)
* @param {object|null} globalCam window.__h3dCamCtl (single global camera)
* @returns {object|null} the resolved free-camera bridge, or null
*/
function _resolveFreeCam(canvas, ss, panelsMap, globalCam) {
if (panelsMap && ss && typeof ss.panelIndexFor === 'function') {
try {
const i = ss.panelIndexFor(canvas);
// Only a non-negative integer indexes the panel map — a non-int /
// negative / string index (or a prototype key) must not resolve an
// unintended/inherited property; fall through to the global then.
if (Number.isInteger(i) && i >= 0 && panelsMap[i]) return panelsMap[i];
} catch (e) { /* ignore */ }
}
return globalCam || null;
}
/* ======================================================================
* Renderer factory
* ====================================================================== */
function createFactory() {
// Per-instance state (per-panel under splitscreen).
let highwayCanvas = null;
let scene = null;
let cam = null;
let ren = null;
let lights = null;
// Settings snapshot — mutated by 'drum_h3d:settings' event.
let settings = readSettings();
let activePalette = PALETTES[settings.palette];
let fx = readFxSettings();
let _theme = readThemeSetting();
// Theme/material handles (initScene builds them; _applyTheme /
// _applyCinematic / the glow slider retune them live).
let ambLight = null, dirLight = null;
let mFloor = null, _gFloor = null;
let _hitBarMat = null, _gHitBar = null;
let _envRT = null; // PMREM render target backing scene.environment
// Host adaptive-quality scale (bundle.renderScale, 0.251) —
// multiplied into the device pixel ratio like highway_3d does.
let _renderScale = 1;
// Auto-resize fallback state (PORTED FROM highway_3d — keep in sync).
// The splitscreen host resizes the panel canvas but overrides
// hw.resize and never calls our resize(w,h), so draw() self-detects
// size drift. _lastHwW/H track the backing store last seen; _appliedW/H
// track the logical size last handed to applySize(); the countdown
// throttles the per-frame clientWidth/Height read.
let _lastHwW = 0, _lastHwH = 0;
let _appliedW = 0, _appliedH = 0;
let _boxCheckCountdown = 0;
// 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
// Hit-FX state. Sparks are PORTED FROM highway_3d (keep in sync);
// _SPARK_N is 160 here vs the guitar's 256 — a drum chart produces
// fewer simultaneous verified hits than a strummed chord chart.
const _SPARK_N = 160;
let _sparkPts = null, _sparkPos = null, _sparkCol = null, _sparkVel = null, _sparkLife = null;
let _fxLastWall = 0; // wall clock for FX integration (sparks, pulse decay)
let _kickPulse = 0; // kick-hit camera-dip + floor-wash envelope
let _camBaseH = null, _camBaseD = null; // positionCamera's unpulsed pose (null until it first runs; applyCamera's guard depends on this)
let _gaussTex = null; // shared soft-falloff texture for flash quads
let _laneFlashQuads = []; // pooled additive quad per hand lane (z=0)
let _kickFlashQuad = null; // full-width flash quad for the kick bar
let _floorFlash = null; // wide amber wash pulsed on kick hits
let laneStripeMats = []; // buildLanes stripe materials (approach highlight)
let _laneProx = []; // per-frame max approach proximity per lane
// Background ambience (PORTED FROM highway_3d BG_STYLES subset).
let _bgStyle = readBgStyleSetting();
let bgGroup = null; // style meshes live here, renderOrder -1
let _bgState = null; // active style's build() state
// Score-FX overlay (2D canvas sibling — the guitar's drawScoreFx
// adapted to this plugin's internal scoring; no notedetect events).
let _fxCanvas = null, _fxCtx = null;
let _fxDpr = 1; // backing-store scale (CSS→device px)
let _fxParentOrigPosition = null; // parent position to restore on teardown
let _scorePops = Array.from({ length: 12 }, () => ({ active: false, x: 0, z: 0, bornMs: 0, text: '' }));
const _SCORE_BURST_N = 36;
let _scoreBursts = Array.from({ length: 2 }, () => ({
active: false, bornMs: 0,
px: new Float32Array(_SCORE_BURST_N), py: new Float32Array(_SCORE_BURST_N),
vx: new Float32Array(_SCORE_BURST_N), vy: new Float32Array(_SCORE_BURST_N),
}));
let _scoreRingMs = -1e9; // combo tier-up ring anchor
let _scoreBreakMs = -1e9; // streak-break wash anchor
let _probe = null; // lazy THREE.Vector3 for projections
// Scene groups / pooled meshes.
let laneGroup = null; // lane stripes + dividers
let kitMapGroup = null; // top-of-highway kit silhouette
let notesGroup = null; // all currently-visible notes (recreated each frame)
// Cached materials — palette-driven, rebuilt on palette swap.
let mDrumByLane = null; // Mesh material per lane (drum lanes)
let mCymbalByLane = null; // Mesh material per lane (cymbal lanes)
// Pre-cloned hit/miss tint materials — avoids per-frame per-note clones.
let mDrumHitByLane = null;
let mDrumMissByLane = null;
let mCymbalHitByLane = null;
let mCymbalMissByLane = null;
let mKick = null; // Kick bar material
let mKickHit = null; // Pre-cloned hit tint for kick
let mKickMiss = null; // Pre-cloned miss tint for kick
let mAccentRing = null; // Halo material for accents
let mGhostRing = null; // Hollow ring material for ghost notes
let mSnareStripe = null; // White snare wire material
let mBellDot = null; // Bright dot for ride bell hits
// Geometry — shared across notes.
let gDrumDisc = null;
let gCymbalGem = null;
let gKickBar = null;
let gAccentRing = null;
let gGhostRing = null;
let gSnareStripe = null;
let gBellDot = null;
let gFlamGrace = null;
let gOpenRing = null; // open hi-hat ring geometry
let mOpenRing = null; // open hi-hat ring material
// Demo loop clock — anchored to performance.now() so animation
// proceeds regardless of audio playback state.
const t0 = performance.now() / 1000;
let _isReady = false;
let _settingsHandler = null;
let _kitHandler = null;
/* ── Hit detection + scoring (per-instance) ──────────────── */
// _hitKeys / _missedKeys: note keys (t|lane) of drum_tab hits the
// user already scored or scrolled past. Keying on LANE (not piece)
// lets a hit on either crash MIDI register against either crash
// note on the shared CR lane — same semantics as the 2D plugin.
const _hitKeys = new Set();
const _missedKeys = new Set();
let _hits = 0, _misses = 0;
let _streak = 0, _bestStreak = 0;
// [{lane, wall, kind}] — wall is performance.now(); kind drives
// the colour of the lane flash (green=hit, red=miss/wrong).
const _laneFlashes = [];
// Latest snapshot of (sorted real-data notes, currentTime) so
// MIDI events (which fire async w.r.t. draw) score against the
// chart the user is actually looking at.
let _latestNotes = null;
let _latestTime = 0;
// Miss-sweep floor: notes at or before this song-time are exempt
// from _updateMissed. Set to the connect-frame time when a MIDI
// device wires up mid-song — notes that passed while no events
// could arrive are unplayable, not misses. Lowered on seek-back
// (those notes become playable again) and cleared by _resetScoring.
let _missSweepFloor = -Infinity;
/* ── HUD overlay (combo / accuracy / streak) ─────────────── */
let _hudEl = null;
let _hudParentOrigPosition = null; // saved so _removeHud can restore it
function _injectHud() {
if (_hudEl || !highwayCanvas) return;
const parent = highwayCanvas.parentElement;
if (!parent) return;
// Position the parent relative so 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 = 'drum-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;
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>` : '');
}
/* ── Lane flash — pulse a lane's emissive briefly on hit ── */
// Wallclock decay window — matches the 2D plugin's 300 ms flash.
const FLASH_MS = 300;
// Timing → color (PORTED FROM highway_3d _timingHex — keep in sync):
// on-time green, early cyan, late amber; green when timing is
// unknown or the user turned timing colors off.
function _timingHex(ts) {
if (!fx.timingFx || !ts || ts === 'OK') return 0x22ff88;
if (ts === 'EARLY') return 0x35d6ff;
if (ts === 'LATE') return 0xffb84d;
return 0x22ff88;
}
// Hit sparks (PORTED FROM highway_3d _sparkBurst/_sparkUpdate — keep
// in sync): a pooled additive Points cloud; a small radial burst with
// an upward kick fires at the struck lane, integrated with gravity +
// color fade in draw().
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;
}
// Fire the 3D feedback for a struck lane: spark burst (timing-colored
// for hand lanes, always-amber for the kick, whose identity color IS
// amber) + the kick camera/floor pulse. Wrong hits get no sparks —
// the red lane flash from _applyLaneFlashes carries that verdict.
function _spawnHitFx(lane, ts, wrong) {
const laneCfg = LANES[lane];
if (!laneCfg || wrong) return;
let count = 10;
if (fx.streakFx) count += Math.round(8 * Math.min(1, _streak / 16));
if (laneCfg.kind === 'kick') {
_kickPulse = 1;
if (fx.sparks) {
for (const sx of [-KICK_W * 0.3, 0, KICK_W * 0.3]) {
_sparkBurst(sx, KICK_H + 1 * K, 0, KICK_COLOR, Math.max(6, count - 4));
}
}
} else if (fx.sparks) {
const x = LANE_X0 + lane * LANE_GAP;
const y = (laneCfg.kind === 'cymbal' ? CYMBAL_H : DISC_H) + 1 * K;
_sparkBurst(x, y, 0, _timingHex(ts), count);
}
}
function _applyLaneFlashes() {
// Drop expired entries first so the buffer can'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();
}
if (!_laneFlashQuads.length && !_kickFlashQuad) return;
// Reset, then drive each lane's quad from its strongest live
// flash. Additive quads with the shared gauss falloff read as a
// brief light-up of the struck lane at the hit line.
for (const q of _laneFlashQuads) if (q) q.material.opacity = 0;
if (_kickFlashQuad) _kickFlashQuad.material.opacity = 0;
if (!(fx.hitFx > 0)) return;
for (const f of _laneFlashes) {
if (f.lane === -1) continue; // unknown piece — no lane to flash
const laneCfg = LANES[f.lane];
if (!laneCfg) continue;
const quad = laneCfg.kind === 'kick' ? _kickFlashQuad : _laneFlashQuads[f.lane];
if (!quad) continue;
const age = (now - f.wall) / FLASH_MS;
const strength = Math.max(0, 1 - age) * 0.85 * fx.hitFx;
if (strength > quad.material.opacity) {
quad.material.opacity = strength;
quad.material.color.setHex(f.kind === 'wrong' ? 0xff4444 : _timingHex(f.ts));
}
}
}
// Dispose every hit-FX GPU resource. Called from teardown() AND
// _disposeScene() — like the bloom composer, all of these are bound
// to the renderer being dropped; initScene rebuilds them.
function _disposeFxObjects() {
if (_sparkPts) {
try { _sparkPts.geometry.dispose(); _sparkPts.material.dispose(); } catch (_) {}
_sparkPts = null;
_sparkPos = _sparkCol = _sparkVel = _sparkLife = null;
}
for (const q of _laneFlashQuads) {
if (q) { try { q.geometry.dispose(); q.material.dispose(); } catch (_) {} }
}
_laneFlashQuads = [];
if (_kickFlashQuad) {
try { _kickFlashQuad.geometry.dispose(); _kickFlashQuad.material.dispose(); } catch (_) {}
_kickFlashQuad = null;
}
if (_floorFlash) {
try { _floorFlash.geometry.dispose(); _floorFlash.material.dispose(); } catch (_) {}
_floorFlash = null;
}
if (_gaussTex) { try { _gaussTex.dispose(); } catch (_) {} _gaussTex = null; }
laneStripeMats = []; // owned + disposed with laneGroup
_kickPulse = 0;
}
/* ── 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();
_hits = 0; _misses = 0;
_streak = 0; _bestStreak = 0;
_laneFlashes.length = 0;
_missSweepFloor = -Infinity;
}
function _hitKey(t, lane) {
return t.toFixed(3) + '|' + lane;
}
// MIDI note-on dispatcher. Resolves the played MIDI to a lane
// index, scans the visible chart window for an un-hit matching
// note within ±HIT_TOLERANCE_S, and updates scoring + visual
// feedback. Wrong-piece or missed-window hits flash red.
function _handleDrumHit(midiNote, velocity) {
_synthDrumHit(midiNote, velocity);
_synthEnsureCtx();
const piece = MIDI_TO_PIECE[midiNote];
const lane = piece !== undefined ? PIECE_TO_LANE[piece] : undefined;
if (lane === undefined) {
_laneFlashes.push({ lane: -1, wall: performance.now(), kind: 'wrong' });
return;
}
if (!_latestNotes || _latestNotes.length === 0) {
// Free-play (no chart loaded): no scoring, but still give
// the pad strike its lane flash + sparks so drumming along
// to the demo pattern feels alive.
_laneFlashes.push({ lane, wall: performance.now(), kind: 'hit' });
_spawnHitFx(lane, null, false);
return;
}
const t = _latestTime;
let foundHit = false;
let matchedDelta = 0; // note.t - now of the matched note (timing verdict)
for (const n of _latestNotes) {
if (n.t > t + HIT_TOLERANCE_S) break;
if (n.t < t - HIT_TOLERANCE_S) continue;
if (n.lane !== lane) continue;
const key = _hitKey(n.t, n.lane);
if (_hitKeys.has(key)) continue;
_hitKeys.add(key);
foundHit = true;
matchedDelta = n.t - t;
break;
}
if (foundHit) {
_hits++;
_streak++;
if (_streak > _bestStreak) _bestStreak = _streak;
const ts = _classifyTiming(matchedDelta, HIT_TOLERANCE_S);
_laneFlashes.push({ lane, wall: performance.now(), kind: 'hit', ts });
_spawnHitFx(lane, ts, false);
_scoreOnHit(lane);
} else {
const prevStreak = _streak;
_misses++;
_streak = 0;
_laneFlashes.push({ lane, wall: performance.now(), kind: 'wrong' });
_scoreOnBreak(prevStreak);
}
}
// Walk recently-passed notes once per frame and mark any unhit
// ones as missed. Called from rebuildNotes after the bundle's
// currentTime is known.
function _updateMissed(t) {
if (!_latestNotes) return;
const cutoff = t - HIT_TOLERANCE_S - 0.02;
for (const n of _latestNotes) {
if (n.t > cutoff) break;
if (n.t < cutoff - 2) continue; // older than 2 s — already counted
if (n.t <= _missSweepFloor) continue; // passed before MIDI connected
const key = _hitKey(n.t, n.lane);
if (_hitKeys.has(key) || _missedKeys.has(key)) continue;
_missedKeys.add(key);
const prevStreak = _streak;
_misses++;
_streak = 0;
_scoreOnBreak(prevStreak);
}
}
function applySettings(detail) {
if (!detail) return;
if (detail.palette && PALETTES[detail.palette]) {
settings.palette = detail.palette;
activePalette = PALETTES[detail.palette];
rebuildPaletteMaterials();
rebuildKitMap();
if (_bgStyle === 'lights') _bgMountStyle(); // lights use the palette
}
if (detail.pattern && DEMO_PATTERNS[detail.pattern]) {
settings.pattern = detail.pattern;
}
if (typeof detail.cameraAngle === 'number') {
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];
}
if ('cinematic' in detail.fx) _applyCinematic();
if ('glow' in detail.fx) {
// Static emissive bases (hit bar, snare stripe, hit/miss
// tints) are baked at build time — rebuild them; the
// per-frame placeNote pulse picks the new value up on
// its own.
rebuildPaletteMaterials();
if (_hitBarMat) _hitBarMat.emissiveIntensity = 0.9 * _glowMul();
if (mSnareStripe) mSnareStripe.emissiveIntensity = 0.5 * _glowMul();
}
if ('vibrancy' in detail.fx) _applyVibrancy();
if ('bgIntensity' in detail.fx) _bgMountStyle();
}
if (detail.theme && BG_THEMES[detail.theme]) {
_theme = detail.theme;
_applyTheme();
}
if (detail.bgStyle && BG_STYLE_IDS.indexOf(detail.bgStyle) !== -1) {
_bgStyle = detail.bgStyle;
_bgMountStyle();
}
}
// Vibrancy slider: stripes get their new base next frame (draw()
// recomputes them); the halo/ghost ring statics update here.
function _applyVibrancy() {
const v = Math.min(1, Math.max(0, fx.vibrancy));
if (mAccentRing) mAccentRing.opacity = 0.6 + 0.3 * v;
// Ghost rings get their vibrancy at per-note material creation in
// buildNoteMesh (they never render with the shared mGhostRing).
}
function rebuildPaletteMaterials() {
disposeMaterialArray(mDrumByLane);
disposeMaterialArray(mCymbalByLane);
disposeMaterialArray(mDrumHitByLane);
disposeMaterialArray(mDrumMissByLane);
disposeMaterialArray(mCymbalHitByLane);
disposeMaterialArray(mCymbalMissByLane);
mDrumByLane = new Array(LANES.length).fill(null);
mCymbalByLane = new Array(LANES.length).fill(null);
mDrumHitByLane = new Array(LANES.length).fill(null);
mDrumMissByLane = new Array(LANES.length).fill(null);
mCymbalHitByLane = new Array(LANES.length).fill(null);
mCymbalMissByLane = new Array(LANES.length).fill(null);
for (let i = 0; i < LANES.length; i++) {
const lane = LANES[i];
const color = lane.kind === 'kick' ? KICK_COLOR : activePalette[lane.paletteIdx];
if (lane.kind === 'drum') {
mDrumByLane[i] = new T.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: 0.45 * _glowMul(),
// Drumhead: satin, a little env pickup so the studio
// strips give the discs some dimension.
roughness: 0.45,
metalness: 0.1,
envMapIntensity: 0.5,
});
// Pre-cloned tint materials for hit (green) and miss (red),
// keyed per lane so proximity emissive adjustments on the base
// material don't affect tinted notes.
mDrumHitByLane[i] = mDrumByLane[i].clone();
mDrumHitByLane[i].color.setHex(0x22c55e);
mDrumHitByLane[i].emissive.setHex(0x22c55e);
mDrumMissByLane[i] = mDrumByLane[i].clone();
mDrumMissByLane[i].color.setHex(0xef4444);
mDrumMissByLane[i].emissive.setHex(0xef4444);
} else if (lane.kind === 'cymbal') {
mCymbalByLane[i] = new T.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: 0.55 * _glowMul(),
// With scene.environment set (studio env map) the
// metalness finally reads as metal — the old 0.7
// metalness had no envMap and looked matte.
roughness: 0.2,
metalness: 0.85,
envMapIntensity: 1.2,
transparent: true,
opacity: 0.92,
});
mCymbalHitByLane[i] = mCymbalByLane[i].clone();
mCymbalHitByLane[i].color.setHex(0x22c55e);
mCymbalHitByLane[i].emissive.setHex(0x22c55e);
mCymbalMissByLane[i] = mCymbalByLane[i].clone();
mCymbalMissByLane[i].color.setHex(0xef4444);
mCymbalMissByLane[i].emissive.setHex(0xef4444);
}
}
if (mKick) mKick.dispose();
if (mKickHit) mKickHit.dispose();
if (mKickMiss) mKickMiss.dispose();
mKick = new T.MeshStandardMaterial({
color: KICK_COLOR,
emissive: KICK_COLOR,
emissiveIntensity: 0.6 * _glowMul(),
roughness: 0.4,
metalness: 0.2,
envMapIntensity: 0.6,
});
mKickHit = mKick.clone();
mKickHit.color.setHex(0x22c55e);
mKickHit.emissive.setHex(0x22c55e);
mKickMiss = mKick.clone();
mKickMiss.color.setHex(0xef4444);
mKickMiss.emissive.setHex(0xef4444);
}
function disposeMaterialArray(arr) {
if (!arr) return;
for (const m of arr) if (m) m.dispose();
}
/* -- background ambience (PORTED FROM highway_3d BG_STYLES:
* off/particles/lights/geometric — keep formulas in sync; the
* guitar's silhouettes/butterchurn/image/video are out of scope) -- */
const BG_STYLES = {
off: {
build() { return null; },
update() {},
teardown() {},
},
particles: {
build(group, settings) {
const N = Math.max(20, Math.floor(80 + 200 * settings.intensity));
const positions = new Float32Array(N * 3);
for (let i = 0; i < N; i++) {
positions[i * 3] = (Math.random() - 0.5) * 800 * K;
positions[i * 3 + 1] = (Math.random() - 0.4) * 80 * K;
// Inside the visible fog band; renderOrder -1 keeps
// them behind notes regardless of z.
positions[i * 3 + 2] = -FOG_START - Math.random() * (FOG_END - FOG_START) * 0.85;
}
const geo = new T.BufferGeometry();
geo.setAttribute('position', new T.BufferAttribute(positions, 3));
const mat = new T.PointsMaterial({
color: 0xa0c0ff, size: 5 * K, transparent: true,
blending: T.AdditiveBlending, depthWrite: false, sizeAttenuation: true,
});
const points = new T.Points(geo, mat);
points.renderOrder = -1;
points.frustumCulled = false;
group.add(points);
return { points, geo, mat, N };
},
update(s, bands, dt) {
const positions = s.geo.attributes.position.array;
const dx = dt * (3 + bands.mid * 12) * K;
for (let i = 0; i < s.N; i++) {
positions[i * 3] += dx;
if (positions[i * 3] > 400 * K) positions[i * 3] -= 800 * K;
}
s.geo.attributes.position.needsUpdate = true;
s.mat.opacity = 0.55 + bands.treble * 0.45;
},
teardown(s) {
if (!s) return;
if (s.points.parent) s.points.parent.remove(s.points);
s.geo.dispose();
s.mat.dispose();
},
},
lights: {
build(group, settings) {
const N = Math.floor(6 + 8 * settings.intensity);
const lights = [];
const palette = settings.palette || PALETTES.default;
for (let i = 0; i < N; i++) {
const color = palette[i % palette.length];
const geo = new T.PlaneGeometry(30 * K, 30 * K);
const mat = new T.MeshBasicMaterial({
color, transparent: true,
blending: T.AdditiveBlending, depthWrite: false,
});
const mesh = new T.Mesh(geo, mat);
mesh.renderOrder = -1;
mesh.position.set(
(Math.random() - 0.5) * 600 * K,
(Math.random() - 0.3) * 80 * K,
-FOG_START - Math.random() * (FOG_END - FOG_START) * 0.85
);
group.add(mesh);
lights.push({ mesh, geo, mat, baseScale: 1 + Math.random() * 0.5, phase: Math.random() * Math.PI * 2 });
}
return { lights };
},
update(s, bands, dt, t) {
for (const L of s.lights) {
const pulse = 1 + bands.bass * 1.5 + Math.sin(t * 1.5 + L.phase) * 0.2;
L.mesh.scale.set(L.baseScale * pulse, L.baseScale * pulse, 1);
L.mat.opacity = 0.55 + bands.treble * 0.4;
}
},
teardown(s) {
if (!s) return;
for (const L of s.lights) {
if (L.mesh.parent) L.mesh.parent.remove(L.mesh);
L.geo.dispose();
L.mat.dispose();
}
},
},
geometric: {
build(group, settings) {
const meshes = [];
const op = 0.45 + 0.25 * settings.intensity;
const ico = new T.Mesh(
new T.IcosahedronGeometry(30 * K, 1),
new T.MeshBasicMaterial({ color: 0x6080c0, wireframe: true, transparent: true, opacity: op, depthWrite: false }),
);
ico.position.set(-100 * K, 30 * K, -FOG_END * 0.65);
ico.renderOrder = -1;
group.add(ico);
meshes.push(ico);
const torus = new T.Mesh(
new T.TorusGeometry(22 * K, 4 * K, 6, 12),
new T.MeshBasicMaterial({ color: 0xc06080, wireframe: true, transparent: true, opacity: op * 0.9, depthWrite: false }),
);
torus.position.set(120 * K, 20 * K, -FOG_END * 0.75);
torus.renderOrder = -1;
group.add(torus);
meshes.push(torus);
return { meshes };
},
update(s, bands, dt) {
const speed = 0.2 + bands.mid * 0.4;
const pulse = 1 + bands.bass * 0.25;
for (const m of s.meshes) {
m.rotation.x += dt * speed * 0.3;
m.rotation.y += dt * speed * 0.4;
m.scale.setScalar(pulse);
}
},
teardown(s) {
if (!s) return;
for (const m of s.meshes) {
if (m.parent) m.parent.remove(m);
m.geometry.dispose();
m.material.dispose();
}
},
},
};
// (Re)mount the active bg style into bgGroup. Idempotent — tears the
// old style down first. Called from initScene, on style/intensity
// settings changes, and after kit-change scene rebuilds.
function _bgMountStyle() {
if (!scene || !T) return;
if (!bgGroup) {
bgGroup = new T.Group();
scene.add(bgGroup);
}
const style = BG_STYLES[_bgStyle] || BG_STYLES.off;
if (_bgState && _bgState._style && BG_STYLES[_bgState._style]) {
try { BG_STYLES[_bgState._style].teardown(_bgState.s); } catch (_) {}
}
let s = null;
try {
s = style.build(bgGroup, { intensity: Math.min(1, Math.max(0, fx.bgIntensity)), palette: activePalette });
} catch (e) {
console.warn('[Drum-Hwy3D] bg style build failed', e);
s = null;
}
_bgState = { _style: _bgStyle, s };
}
function _bgTeardownStyle() {
if (_bgState && _bgState._style && BG_STYLES[_bgState._style]) {
try { BG_STYLES[_bgState._style].teardown(_bgState.s); } catch (_) {}
}
_bgState = null;
bgGroup = null; // its children were removed by teardown; group dies with the scene
}
/* -- score-FX overlay (guitar drawScoreFx adapted to internal scoring) -- */
function _ensureFxCanvas() {
if (_fxCanvas || !highwayCanvas) return;
const parent = highwayCanvas.parentElement;
if (!parent) return;
const cur = parent.style.position || getComputedStyle(parent).position;
if (cur === 'static' || !cur) {
// Record the original inline value (usually '') so teardown
// can undo this layout mutation — same contract as the HUD.
_fxParentOrigPosition = parent.style.position;
parent.style.position = 'relative';
}
_fxCanvas = document.createElement('canvas');
_fxCanvas.className = 'drum-h3d-fx';
_fxCanvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:5';
parent.appendChild(_fxCanvas);
_fxCtx = _fxCanvas.getContext('2d');
_sizeFxCanvas();
}
function _sizeFxCanvas() {
if (!_fxCanvas || !highwayCanvas) return;
const w = highwayCanvas.clientWidth | 0, h = highwayCanvas.clientHeight | 0;
if (!w || !h) return;
const r = Math.min(window.devicePixelRatio || 1, 2);
if (_fxCanvas.width !== (w * r | 0) || _fxCanvas.height !== (h * r | 0)) {
_fxCanvas.width = w * r | 0;
_fxCanvas.height = h * r | 0;
}
// The draw path works in CSS pixels and scales the context by
// this — fixed font/line sizes stay physically consistent on
// HiDPI instead of shrinking with the backing store.
_fxDpr = r;
}
function _removeFxCanvas() {
if (_fxCanvas && _fxCanvas.parentNode) {
const parent = _fxCanvas.parentNode;
parent.removeChild(_fxCanvas);
// Restore position only if _ensureFxCanvas changed it (and
// nothing else — e.g. the HUD — still needs it; the HUD sets
// and restores its own copy of the same guard first).
if (_fxParentOrigPosition !== null) {
parent.style.position = _fxParentOrigPosition;
_fxParentOrigPosition = null;
}
}
_fxCanvas = null;
_fxCtx = null;
}
function _scoreSpawnPop(lane) {
const laneCfg = LANES[lane];
if (!laneCfg) return;
const nowMs = performance.now();
for (const pop of _scorePops) {
if (pop.active) continue;
pop.active = true;
pop.x = laneCfg.kind === 'kick' ? 0 : (LANE_X0 + lane * LANE_GAP);
pop.z = 0;
pop.bornMs = nowMs;
pop.text = '+1';
return;
}
}
function _scoreSpawnBurst(nowMs) {
for (const b of _scoreBursts) {
if (b.active) continue;
b.active = true;
b.bornMs = nowMs;
for (let j = 0; j < _SCORE_BURST_N; j++) {
const a = (j / _SCORE_BURST_N) * Math.PI * 2;
const sp = 2 + (j % 5) * 0.8;
b.px[j] = 0; b.py[j] = 0;
b.vx[j] = Math.cos(a) * sp;
b.vy[j] = Math.sin(a) * sp - 1.2;
}
return;
}
}
// Called from the scoring paths: hit → pop (+ ring on 10-combo
// tier-ups, burst on 25/50/100 milestones); a broken streak of 3+
// → brief red wash.
function _scoreOnHit(lane) {
if (!fx.scoreFx) return;
const nowMs = performance.now();
_scoreSpawnPop(lane);
if (_streak > 0 && _streak % 10 === 0) _scoreRingMs = nowMs;
if (_streak === 25 || _streak === 50 || _streak === 100) _scoreSpawnBurst(nowMs);
}
function _scoreOnBreak(prevStreak) {
if (!fx.scoreFx) return;
if (prevStreak >= 3) _scoreBreakMs = performance.now();
}
function _drawScoreFx() {
if (!_fxCtx || !_fxCanvas || !cam) return;
// CSS-pixel coordinate space; the context transform applies the
// DPR so strokes/fonts render at their intended physical size.
const r = _fxDpr || 1;
const W = _fxCanvas.width / r, H = _fxCanvas.height / r;
_fxCtx.setTransform(r, 0, 0, r, 0, 0);
const nowMs = performance.now();
let anyPop = false;
for (const pop of _scorePops) if (pop.active) { anyPop = true; break; }
let anyBurst = false;
for (const b of _scoreBursts) if (b.active) { anyBurst = true; break; }
const ringAge = nowMs - _scoreRingMs;
const breakAge = nowMs - _scoreBreakMs;
// Cheap early-out: clear only while something was recently live.
if (!anyPop && !anyBurst && ringAge >= 600 && breakAge >= 350) {
if (_fxCanvas._dirty) { _fxCtx.clearRect(0, 0, W, H); _fxCanvas._dirty = false; }
return;
}
_fxCanvas._dirty = true;
if (!_probe) _probe = new T.Vector3();
const ctx = _fxCtx;
ctx.clearRect(0, 0, W, H);
ctx.save();
// Streak-break flicker: brief red wash over the panel.
if (breakAge < 350) {
ctx.fillStyle = '#ef4444';
ctx.globalAlpha = 0.10 * (1 - breakAge / 350);
ctx.fillRect(0, 0, W, H);
ctx.globalAlpha = 1;
}
// Hit-line center in screen px — anchor for rings + bursts.
let cx = W / 2, cy = H * 0.72, centerOk = false;
_probe.set(0, 0.5 * K, 0);
_probe.project(cam);
if (_probe.z >= -1 && _probe.z <= 1) {
cx = (_probe.x * 0.5 + 0.5) * W;
cy = (-_probe.y * 0.5 + 0.5) * H;
centerOk = true;
}
// Combo tier-up ring (every 10-streak).
if (centerOk && ringAge < 600) {
const t = ringAge / 600;
const ease = 1 - Math.pow(1 - t, 2);
ctx.beginPath();
ctx.arc(cx, cy, 20 + ease * Math.min(W, H) * 0.28, 0, Math.PI * 2);
ctx.strokeStyle = _streak >= 30 ? '#fde047' : '#86efac';
ctx.globalAlpha = 0.6 * (1 - t);
ctx.lineWidth = 3;
ctx.stroke();
ctx.globalAlpha = 1;
}
// Milestone bursts (25/50/100 streak).
if (anyBurst && centerOk) {
for (const b of _scoreBursts) {
if (!b.active) continue;
const age = nowMs - b.bornMs;
if (age >= 900) { b.active = false; continue; }
const t = age / 900;
ctx.globalAlpha = 1 - t;
for (let j = 0; j < _SCORE_BURST_N; j++) {
b.px[j] += b.vx[j];
b.py[j] += b.vy[j];
b.vy[j] += 0.08;
ctx.fillStyle = (j & 1) ? '#fde047' : '#86efac';
ctx.fillRect(cx + b.px[j] - 2, cy + b.py[j] - 2, 4, 4);
}
ctx.globalAlpha = 1;
}
}
// "+1" pops: rise off the struck lane and fade over the back half.
if (anyPop) {
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const pop of _scorePops) {
if (!pop.active) continue;
const age = nowMs - pop.bornMs;
if (age >= 800) { pop.active = false; continue; }
_probe.set(pop.x, 2 * K, pop.z);
_probe.project(cam);
if (_probe.z < -1 || _probe.z > 1) continue;
const t = age / 800;
const sx = (_probe.x * 0.5 + 0.5) * W;
const sy = (-_probe.y * 0.5 + 0.5) * H - t * 30;
ctx.globalAlpha = t < 0.4 ? 1 : 1 - (t - 0.4) / 0.6;
ctx.font = 'bold 15px system-ui, sans-serif';
ctx.lineWidth = 4;
ctx.strokeStyle = 'rgba(0,0,0,0.8)';
ctx.strokeText(pop.text, sx, sy);
ctx.fillStyle = '#86efac';
ctx.fillText(pop.text, sx, sy);
}
ctx.globalAlpha = 1;
}
ctx.restore();
}
/* -- one-time scene setup --------------------------------------- */
// Emissive multiplier from the Glow slider — 0.5 is neutral (the
// stock look), so `base * _glowMul()` leaves defaults byte-identical.
function _glowMul() {
return Math.min(1, Math.max(0, fx.glow)) * 2;
}
// Cinematic lighting: dimmer ambient + stronger key light, same
// rebalance idea as highway_3d's _applyCinematic. Off = the plugin's
// pre-parity values.
function _applyCinematic() {
if (!ambLight || !dirLight) return;
ambLight.intensity = fx.cinematic ? 0.3 : 0.4;
dirLight.intensity = fx.cinematic ? 1.2 : 1.0;
}
// Procedural "studio" environment for image-based lighting: a dark
// room shell with three emissive light strips (cool overhead key,
// warm left fill, cool right accent), baked through PMREMGenerator.
// No addon needed (PMREMGenerator ships in the core three build) —
// RoomEnvironment is NOT vendored, hence this hand-rolled stand-in.
// Returns the PMREM render target ({texture}) or null; caller owns
// disposing the RT.
function _makeStudioEnv(ThreeLib, renderer) {
if (!renderer) return null;
try {
const envScene = new ThreeLib.Scene();
const own = []; // geometries/materials to free after baking
const shellGeo = new ThreeLib.BoxGeometry(100, 100, 100);
const shellMat = new ThreeLib.MeshBasicMaterial({ color: 0x11131c, side: ThreeLib.BackSide });
envScene.add(new ThreeLib.Mesh(shellGeo, shellMat));
own.push(shellGeo, shellMat);
const strip = (w, h, hex, intensity, x, y, z, rx, ry) => {
const g = new ThreeLib.PlaneGeometry(w, h);
// DoubleSide: orientation-proof — the bake only runs once,
// so the extra faces are free insurance.
const m = new ThreeLib.MeshBasicMaterial({ color: hex, side: ThreeLib.DoubleSide });
m.color.multiplyScalar(intensity);
const mesh = new ThreeLib.Mesh(g, m);
mesh.position.set(x, y, z);
mesh.rotation.set(rx, ry, 0);
envScene.add(mesh);
own.push(g, m);
};
strip(60, 8, 0xdfe8ff, 18, 0, 45, 0, Math.PI / 2, 0); // overhead key
strip(30, 50, 0xffd9a8, 6, -48, 5, 0, 0, Math.PI / 2); // warm left wall
strip(30, 50, 0x9fd8ff, 6, 48, 5, 0, 0, -Math.PI / 2); // cool right wall
const pmrem = new ThreeLib.PMREMGenerator(renderer);
const rt = pmrem.fromScene(envScene, 0.04);
pmrem.dispose();
for (const r of own) r.dispose();
return rt;
} catch (e) {
console.warn('[Drum-Hwy3D] studio env failed (continuing without IBL)', e);
return null;
}
}
function _disposeEnv() {
if (_envRT) {
try { _envRT.dispose(); } catch (_) {}
_envRT = null;
}
}
// Apply the active theme to the live scene (background/fog/clear +
// floor + lane stripes). Notes keep their piece palette — themes own
// the SCENE, palettes own the KIT.
function _applyTheme() {
const c = _bgThemeColors(_theme);
if (scene) {
if (scene.background && scene.background.isColor) scene.background.setHex(c.clear);
if (scene.fog) scene.fog.color.setHex(c.fog);
}
if (ren) ren.setClearColor(c.clear, 1);
if (mFloor) mFloor.color.setHex(c.board);
_applyStripeColors();
}
// Lane stripe colors + base opacity (theme lane pair or the stock
// teal/blue; vibrancy drives how strongly they read). draw()'s
// approach highlight adds on top of the vibrancy base each frame.
function _stripeBaseOpacity() {
return 0.12 + 0.24 * Math.min(1, Math.max(0, fx.vibrancy));
}
function _applyStripeColors() {
const c = _bgThemeColors(_theme);
const pair = (c.lane != null && c.laneDim != null)
? [c.laneDim, c.lane]
: [0x2d5476, 0x42759d];
for (let i = 0; i < laneStripeMats.length; i++) {
laneStripeMats[i].color.setHex(pair[i % 2]);
}
}
function initScene() {
scene = new T.Scene();
const themeCols = _bgThemeColors(_theme);
scene.background = new T.Color(themeCols.clear);
scene.fog = new T.Fog(themeCols.fog, FOG_START, FOG_END);
if (ren) ren.setClearColor(themeCols.clear, 1);
// Studio environment map: emissive-strip room baked through
// PMREM so MeshStandardMaterial metalness/roughness finally get
// specular payoff (the cymbals' 0.85 metalness reads as metal).
// Rebuilt here so the kit-change renderer recreation gets a
// fresh one; the old RT is disposed by _disposeScene/teardown.
_envRT = _makeStudioEnv(T, ren);
if (_envRT) scene.environment = _envRT.texture;
cam = new T.PerspectiveCamera(60, 16 / 9, 0.1, 1000 * K);
positionCamera();
lights = new T.Group();
ambLight = new T.AmbientLight(0xffffff, 0.4);
dirLight = new T.DirectionalLight(0xffffff, 1.0);
dirLight.position.set(-50 * K, 200 * K, 200 * K);
lights.add(ambLight);
lights.add(dirLight);
scene.add(lights);
_applyCinematic();
// Floor plane — wide darkened quad under the lanes.
// Use Math.max(1, LANE_COUNT) so a kick-only kit (LANE_COUNT=0)
// still produces a floor at least as wide as the kick bar (KICK_W).
const floorW = Math.max(1, LANE_COUNT) * LANE_GAP + 8 * K;
const floorD = (AHEAD + BEHIND + 0.5) * TS + 60 * K;
_gFloor = new T.PlaneGeometry(floorW, floorD);
const gFloor = _gFloor;
mFloor = new T.MeshStandardMaterial({
color: themeCols.board,
// 0.7 (was 0.95): just enough sheen to catch the env strips
// without turning the floor into a mirror.
roughness: 0.7,
metalness: 0.0,
envMapIntensity: 0.35,
});
const floor = new T.Mesh(gFloor, mFloor);
floor.rotation.x = -Math.PI / 2;
floor.position.set(0, -0.3 * K, -floorD / 2 + BEHIND * TS);
scene.add(floor);
// Build lane stripes / dividers (static).
buildLanes(floorW, floorD);
// Build the kit silhouette backboard.
kitMapGroup = new T.Group();
scene.add(kitMapGroup);
rebuildKitMap();
// Hit-line bar in front of the camera, perpendicular to scroll.
_gHitBar = new T.BoxGeometry(floorW, 0.6 * K, 0.6 * K);
const gHit = _gHitBar;
const mHit = new T.MeshStandardMaterial({
color: 0xffffff,
emissive: 0xffffff,
emissiveIntensity: 0.9 * _glowMul(),
roughness: 0.3,
metalness: 0.6,
envMapIntensity: 1.0,
});
_hitBarMat = mHit;
const hitBar = new T.Mesh(gHit, mHit);
hitBar.position.set(0, 0.3 * K, 0);
scene.add(hitBar);
// (Lane-flash boxes removed — hit/miss feedback now lives on
// the chart note itself via a per-note material clone in
// placeNote. Simpler visual: green note = you struck it,
// red note = it scrolled past unstruck.)
// Notes group is rebuilt every frame — pooling could come later
// but at <100 visible notes per frame the GC cost is negligible.
notesGroup = new T.Group();
scene.add(notesGroup);
// Shared geometry.
gDrumDisc = new T.CylinderGeometry(DISC_R_BASE, DISC_R_BASE, DISC_H, 32);
gCymbalGem = new T.CylinderGeometry(CYMBAL_R * 0.15, CYMBAL_R, CYMBAL_H, 8, 1, false);
gKickBar = new T.BoxGeometry(KICK_W, KICK_H, KICK_D);
gAccentRing = new T.RingGeometry(DISC_R_BASE * 1.15, DISC_R_BASE * 1.4, 32);
gGhostRing = new T.RingGeometry(DISC_R_BASE * 0.55, DISC_R_BASE * 0.75, 24);
gSnareStripe = new T.BoxGeometry(DISC_R_BASE * 1.9, 0.25 * K, 0.4 * K);
gBellDot = new T.CircleGeometry(CYMBAL_R * 0.3, 16);
gFlamGrace = new T.CylinderGeometry(
DISC_R_BASE * FLAM_GRACE_SCALE,
DISC_R_BASE * FLAM_GRACE_SCALE,
DISC_H,
24,
);
// Open hi-hat: thin warm ring around the gem (closed = bare gem).
gOpenRing = new T.RingGeometry(CYMBAL_R * 1.05, CYMBAL_R * 1.25, 24);
mOpenRing = new T.MeshBasicMaterial({
color: 0xffe9a8,
transparent: true,
opacity: 0.85,
side: T.DoubleSide,
});
// Hit sparks (PORTED FROM highway_3d): pooled additive Points
// cloud, burst on verified hits, integrated in draw().
_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);
}
// Lane-flash quads: one pooled additive quad per hand lane plus a
// full-width one for the kick, parked at the hit line with the
// shared gauss falloff. _applyLaneFlashes drives color/opacity —
// this resurrects the lane-flash feedback that was removed when
// note recoloring landed, now as real light instead of boxes.
_gaussTex = _makeGaussTex(T, 128, 0.28);
_laneFlashQuads = [];
for (let i = 0; i < LANE_COUNT; i++) {
const g = new T.PlaneGeometry(LANE_GAP * 0.9, 12 * K);
const m = new T.MeshBasicMaterial({
color: 0xffffff, map: _gaussTex, transparent: true, opacity: 0,
blending: T.AdditiveBlending, depthWrite: false,
});
const quad = new T.Mesh(g, m);
quad.rotation.x = -Math.PI / 2;
quad.position.set(LANE_X0 + i * LANE_GAP, 0.06 * K, 0);
quad.renderOrder = 7;
scene.add(quad);
_laneFlashQuads.push(quad);
}
{
const g = new T.PlaneGeometry(floorW * 0.92, 8 * K);
const m = new T.MeshBasicMaterial({
color: KICK_COLOR, map: _gaussTex, transparent: true, opacity: 0,
blending: T.AdditiveBlending, depthWrite: false,
});
_kickFlashQuad = new T.Mesh(g, m);
_kickFlashQuad.rotation.x = -Math.PI / 2;
_kickFlashQuad.position.set(0, 0.05 * K, 0);
_kickFlashQuad.renderOrder = 7;
scene.add(_kickFlashQuad);
}
// Kick pulse floor wash — wide, faint, pulsed in draw().
{
const g = new T.PlaneGeometry(floorW, 40 * K);
const m = new T.MeshBasicMaterial({
color: KICK_COLOR, map: _gaussTex, transparent: true, opacity: 0,
blending: T.AdditiveBlending, depthWrite: false,
});
_floorFlash = new T.Mesh(g, m);
_floorFlash.rotation.x = -Math.PI / 2;
_floorFlash.position.set(0, 0.04 * K, -6 * K);
_floorFlash.renderOrder = 6;
scene.add(_floorFlash);
}
// Halo and ghost ring materials — additive emissive, palette-agnostic.
mAccentRing = new T.MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.85,
side: T.DoubleSide,
});
mGhostRing = new T.MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.55,
side: T.DoubleSide,
});
mSnareStripe = new T.MeshStandardMaterial({
color: 0xffffff,
emissive: 0xffffff,
emissiveIntensity: 0.5 * _glowMul(),
roughness: 0.3,
});
mBellDot = new T.MeshBasicMaterial({
color: 0xffeecc,
transparent: true,
opacity: 0.95,
side: T.DoubleSide,
});
rebuildPaletteMaterials();
_applyVibrancy(); // halo/ghost ring opacities from the slider
// Background ambience behind everything (renderOrder -1).
_bgMountStyle();
}
function positionCamera() {
// cameraAngle: 0 = down the lanes (low + forward), 1 = top-down.
const a = settings.cameraAngle;
// Camera height ramps from low-ish to high; depth pulls back as we
// tilt down so the lanes still fit the frame.
const h = (45 + 180 * a) * K;
const d = (60 + 60 * (1 - a)) * K;
// Remember the unpulsed pose — the kick pulse in draw() dips
// cam.position.y relative to this and must not accumulate.
_camBaseH = h;
_camBaseD = d;
cam.position.set(0, h, d);
cam.lookAt(0, 0, -AHEAD * TS * 0.45);
}
/**
* Camera Director bridge for THIS panel — delegates to the pure, unit-
* tested _resolveFreeCam / _ssApi (resolver block above the factory).
* Reads the live globals: per-panel map __h3dCamCtlPanels → this panel's
* camera, else the global __h3dCamCtl, else null (stock framing).
* @param {HTMLCanvasElement} canvas this panel's highway canvas
* @returns {object|null} the resolved free-camera bridge, or null
*/
function _freeCamFor(canvas) {
return _resolveFreeCam(canvas, _ssApi(), window.__h3dCamCtlPanels, window.__h3dCamCtl);
}
// Per-frame camera write: static base pose (positionCamera) + kick-pulse Y
// dip, then layer Camera Director free-cam offsets (dolly/height/orbit on
// the camera-from-target vector; pan/pitch on the look target). Runs every
// frame so a live free-cam drag is smooth; allocation-free; NaN-safe; a
// null/disabled bridge reproduces the stock static+pulse pose exactly.
function applyCamera() {
if (_camBaseH == null) return; // before first positionCamera()
const _dip = (_kickPulse > 0.001) ? (0.8 * K * _kickPulse * fx.hitFx) : 0;
let _cx = 0, _cy = _camBaseH - _dip, _cz = _camBaseD;
let _lx = 0, _ly = 0, _lz = -AHEAD * TS * 0.45;
const _fc = _freeCamFor(highwayCanvas);
if (_fc && _fc.enabled) {
const _dm = Number.isFinite(_fc.distMul) ? _fc.distMul : 1;
const _hm = Number.isFinite(_fc.heightMul) ? _fc.heightMul : 1;
const _yaw = Number.isFinite(_fc.yaw) ? _fc.yaw : 0;
let _vx = _cx - _lx, _vy = _cy - _ly, _vz = _cz - _lz;
_vx *= _dm; _vy *= _dm; _vz *= _dm; // dolly (zoom)
_vy *= _hm; // height
const _cyw = Math.cos(_yaw), _syw = Math.sin(_yaw);
const _rx = _vx * _cyw - _vz * _syw, _rz = _vx * _syw + _vz * _cyw; // orbit around Y
_cx = _lx + _rx; _cy = _ly + _vy; _cz = _lz + _rz;
const _px = Number.isFinite(_fc.panX) ? _fc.panX : 0;
const _py = Number.isFinite(_fc.panY) ? _fc.panY : 0;
const _pt = Number.isFinite(_fc.pitch) ? _fc.pitch : 0;
_lx += _px * K; _ly += (_pt + _py) * K;
}
cam.position.set(_cx, _cy, _cz);
cam.lookAt(_lx, _ly, _lz);
}
function buildLanes(_floorW, floorD) {
laneGroup = new T.Group();
laneStripeMats = [];
// Alternating lane stripes for the 7 hand lanes — theme lane
// pair when the active theme defines one, else the stock
// teal/blue (a notch darker than highway_3d so discs pop).
// _applyStripeColors() retints these on a live theme switch.
const tc = _bgThemeColors(_theme);
const colors = (tc.lane != null && tc.laneDim != null)
? [tc.laneDim, tc.lane]
: [0x2d5476, 0x42759d];
for (let i = 0; i < LANE_COUNT; i++) {
const x = LANE_X0 + i * LANE_GAP;
const g = new T.PlaneGeometry(LANE_GAP * 0.96, floorD);
const m = new T.MeshBasicMaterial({
color: colors[i % 2],
transparent: true,
opacity: _stripeBaseOpacity(),
});
const stripe = new T.Mesh(g, m);
stripe.rotation.x = -Math.PI / 2;
stripe.position.set(x, -0.25 * K, -floorD / 2 + BEHIND * TS);
laneGroup.add(stripe);
// Kept for the per-frame approach highlight (rebuildNotes
// raises a lane's stripe opacity as its next note nears the
// hit line).
laneStripeMats.push(m);
}
scene.add(laneGroup);
}
function rebuildKitMap() {
// Clear existing children.
while (kitMapGroup.children.length) {
const c = kitMapGroup.children.pop();
if (c.geometry) c.geometry.dispose();
if (c.material) {
if (Array.isArray(c.material)) c.material.forEach(m => m.dispose());
else c.material.dispose();
}
}
// Backboard plane far down the highway where guitar's fretboard
// would terminate. Each lane's piece silhouette sits over its
// lane's X position so the kit reads as a top-down map.
const farZ = -AHEAD * TS - 10 * K;
const boardW = LANE_COUNT * LANE_GAP + 6 * K;
const boardH = 28 * K;
const gBoard = new T.PlaneGeometry(boardW, boardH);
const mBoard = new T.MeshBasicMaterial({
color: 0x0a1422,
transparent: true,
opacity: 0.85,
});
const board = new T.Mesh(gBoard, mBoard);
board.position.set(0, boardH / 2 + 2 * K, farZ);
kitMapGroup.add(board);
// Per-piece silhouettes — circle outlines (drums/cymbals) for
// each HAND lane. LANE_COUNT excludes the kick (which is
// rendered as a wide rectangle below instead).
for (let i = 0; i < LANE_COUNT; i++) {
const lane = LANES[i];
if (!lane) continue;
const color = activePalette[lane.paletteIdx];
const x = LANE_X0 + i * LANE_GAP;
const r = lane.kind === 'cymbal' ? 3.2 * K : 2.6 * K;
const segs = lane.kind === 'cymbal' ? 8 : 32;
const gOutline = new T.RingGeometry(r * 0.92, r, segs);
const mOutline = new T.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.85,
side: T.DoubleSide,
});
const ring = new T.Mesh(gOutline, mOutline);
ring.position.set(x, boardH * 0.55 + 2 * K, farZ + 0.1 * K);
kitMapGroup.add(ring);
}
// Kick rectangle — only rendered when the active kit includes
// a kick lane (LANES.length > LANE_COUNT means a kick is appended
// at the end). Otherwise no bar appears, kit silhouette omits it.
const hasKick = LANES.length > LANE_COUNT;
if (hasKick) {
const kickG = new T.PlaneGeometry(boardW * 0.86, 2.0 * K);
const kickM = new T.MeshBasicMaterial({
color: KICK_COLOR,
transparent: true,
opacity: 0.85,
});
const kick = new T.Mesh(kickG, kickM);
kick.position.set(0, boardH * 0.18 + 2 * K, farZ + 0.1 * K);
kitMapGroup.add(kick);
}
}
/* -- per-frame note rendering ----------------------------------- */
function buildNoteMesh(lane, variant, open) {
const laneCfg = LANES[lane];
const group = new T.Group();
if (laneCfg.kind === 'kick') {
const bar = new T.Mesh(gKickBar, mKick);
if (variant === 'accent') bar.scale.setScalar(ACCENT_SCALE);
group.add(bar);
if (variant === 'accent') {
// Bright leading edge bar (a thin white strip on the
// camera-facing edge of the kick). Geometry + material
// are per-note — flag both as transient so disposeMeshTree
// releases them when the note recycles.
const edgeGeo = new T.BoxGeometry(KICK_W * 0.96, KICK_H * 1.05, 0.6 * K);
const edgeMat = new T.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.9 });
edgeGeo.userData.transient = true;
edgeMat.userData.transient = true;
const edge = new T.Mesh(edgeGeo, edgeMat);
edge.position.set(0, 0, KICK_D * 0.5);
group.add(edge);
}
return group;
}
if (laneCfg.kind === 'drum') {
let scale = 1.0;
if (variant === 'ghost') scale = GHOST_SCALE;
else if (variant === 'accent') scale = ACCENT_SCALE;
if (variant === 'ghost') {
// Hollow ring instead of disc — easier to read as "quiet".
// Material is per-note (palette-tinted but transparent) —
// flag it transient so disposeMeshTree releases it.
const ghostMat = new T.MeshBasicMaterial({
color: activePalette[laneCfg.paletteIdx],
transparent: true,
// Vibrancy-driven, evaluated per build — notesGroup is
// rebuilt every frame, so slider changes apply live.
// (The shared mGhostRing is only the hit/miss-tint
// template, not what pending ghosts render with.)
opacity: 0.5 + 0.29 * Math.min(1, Math.max(0, fx.vibrancy)),
side: T.DoubleSide,
});
ghostMat.userData.transient = true;
ghostMat.userData.isGhostRing = true;
const ring = new T.Mesh(gGhostRing, ghostMat);
ring.rotation.x = -Math.PI / 2;
ring.scale.setScalar(scale);
group.add(ring);
} else {
const disc = new T.Mesh(gDrumDisc, mDrumByLane[lane]);
disc.scale.set(scale, 1, scale);
group.add(disc);
}
if (laneCfg.subKind === 'snare' && variant !== 'ghost') {
const stripe = new T.Mesh(gSnareStripe, mSnareStripe);
stripe.position.set(0, DISC_H * 0.55, 0);
stripe.scale.set(scale, 1, scale);
group.add(stripe);
}
if (variant === 'accent') {
const halo = new T.Mesh(gAccentRing, mAccentRing);
halo.rotation.x = -Math.PI / 2;
halo.position.y = DISC_H * 0.6;
halo.scale.setScalar(scale);
group.add(halo);
}
return group;
}
if (laneCfg.kind === 'cymbal') {
// Cymbal "gem" — flattened bipyramid (truncated cone). The
// top-radius/bottom-radius ratio gives a faceted gem look.
let scale = 1.0;
if (variant === 'ghost') scale = GHOST_SCALE;
else if (variant === 'accent') scale = ACCENT_SCALE;
const gem = new T.Mesh(gCymbalGem, mCymbalByLane[lane]);
gem.scale.setScalar(scale);
group.add(gem);
if (laneCfg.subKind === 'hihat' && open) {
// Open-hat: a thin warm ring around the gem (closed-hat
// stays the bare gem) — mirrors standard drum notation's
// "o" marking. Shared geometry/material: zero per-frame
// GC despite the every-frame notesGroup rebuild.
const ring = new T.Mesh(gOpenRing, mOpenRing);
ring.rotation.x = -Math.PI / 2;
ring.position.y = CYMBAL_H * 0.55;
ring.scale.setScalar(scale);
group.add(ring);
}
if (laneCfg.subKind === 'ride' && variant === 'bell') {
const dot = new T.Mesh(gBellDot, mBellDot);
dot.rotation.x = -Math.PI / 2;
dot.position.y = CYMBAL_H * 0.55;
group.add(dot);
}
if (variant === 'accent') {
const halo = new T.Mesh(gAccentRing, mAccentRing);
halo.rotation.x = -Math.PI / 2;
halo.position.y = CYMBAL_H * 0.55;
halo.scale.setScalar(scale * 1.05);
group.add(halo);
}
return group;
}
return group;
}
// Cache: converted drum_tab.hits → [{lane, t, variant}] sorted by t.
// Re-derive only when the bundle's drumTab object identity changes
// (one allocation per song-load, not per frame).
let _cachedDrumTabKey = null;
let _cachedRealNotes = null;
function _realNotesFromDrumTab(drumTab) {
const hits = (drumTab && Array.isArray(drumTab.hits)) ? drumTab.hits : [];
const out = [];
for (const h of hits) {
const lane = PIECE_TO_LANE[h.p];
if (lane === undefined) continue; // unknown piece — skip silently
// `open` is orthogonal to variant so an accented or flammed
// open hat keeps both cues (ring + halo / grace disc).
out.push({ lane, t: +h.t || 0, variant: _variantForHit(h), open: h.p === 'hh_open' });
}
out.sort((a, b) => a.t - b.t);
return out;
}
function rebuildNotes(bundle) {
// Clear the existing notes group — for <100 simultaneously visible
// notes the GC churn is fine and the code stays simple.
while (notesGroup.children.length) {
const c = notesGroup.children.pop();
disposeMeshTree(c);
}
// Approach highlight: the visible-window walks below record each
// hand lane's strongest proximity; _applyApproach() then raises
// that lane's stripe so the eye is drawn to where the next hit
// lands.
if (_laneProx.length !== LANE_COUNT) _laneProx = new Array(LANE_COUNT).fill(0);
else _laneProx.fill(0);
// Real-data path: when the active sloppak ships a drum_tab,
// bundle.drumTab is populated by static/highway.js (slopsmith
// drums-from-scratch PR). Otherwise fall back to the hardcoded
// demo loop so the viz still has something to show on songs
// without drums or in standalone playback.
const drumTab = bundle && bundle.drumTab;
if (drumTab && Array.isArray(drumTab.hits)) {
if (_cachedDrumTabKey !== drumTab) {
_cachedDrumTabKey = drumTab;
_cachedRealNotes = _realNotesFromDrumTab(drumTab);
// New chart — reset scoring so combo/accuracy don't
// carry over from the previous song. Mirrors the v3
// 2D plugin's bundle.isReady edge-detect behaviour.
_resetScoring();
}
const t = (bundle && typeof bundle.currentTime === 'number') ? bundle.currentTime : 0;
// Snapshot for the MIDI handler — has to run BEFORE the
// visible-window walk so an in-flight pad hit fired
// between frames scores against the same view the user
// sees. _updateMissed also reads these to retire passed
// notes that the user didn't strike.
_latestNotes = _cachedRealNotes;
_latestTime = t;
// Only accumulate misses when a MIDI device is connected.
// With no device selected the user cannot hit notes, so
// counting passes as misses would corrupt the HUD accuracy.
// When a device was just connected, reset scoring first so
// notes that elapsed while no device was connected are not
// retroactively counted as misses.
// Gate on _midiHandle (the live wired session), NOT _midiInput: the
// latter is set as soon as a device is picked, but the async
// mi.open() may still be pending (slow / permission prompt), during
// which no events can arrive — counting passes then would bank false
// misses. _midiHandle is truthy only after a handle is opened+wired.
if (_midiHandle) {
if (_midiJustConnected) {
_midiJustConnected = false;
_resetScoring();
// Floor the sweep at the connect frame: without this
// the _updateMissed call right below would bank the
// last ~2 s of notes as misses even though no input
// could have arrived for them yet.
_missSweepFloor = t;
}
// Seek-back past the floor makes those notes playable
// again — let them count.
if (t < _missSweepFloor) _missSweepFloor = t;
_updateMissed(t);
}
// Linear walk with early break — notes are sorted by time so
// the first note beyond AHEAD ends the visible window.
// Hit / missed notes get a tag passed through to placeNote
// so it can render them differently (hit → green tint, miss →
// red tint). The match is keyed on (t, lane), same as hit
// detection — multiple piece-ids on a lane (crash_l +
// crash_r) share the visual feedback.
for (const note of _cachedRealNotes) {
const dt = note.t - t;
if (dt > AHEAD) break;
if (dt < -BEHIND) continue;
const key = _hitKey(note.t, note.lane);
const status = _hitKeys.has(key) ? 'hit' :
_missedKeys.has(key) ? 'missed' :
'pending';
placeNote(note, dt, status);
}
return;
}
// Demo-loop fallback: drumTab is absent or empty. If we just
// transitioned away from a real chart, clear stale scoring state
// so MIDI hits (and the HUD) don't score against the old notes.
if (_cachedDrumTabKey !== null) {
_cachedDrumTabKey = null;
_cachedRealNotes = [];
_latestNotes = [];
_latestTime = 0;
_resetScoring();
}
const pat = DEMO_PATTERNS[settings.pattern];
if (!pat) return;
const now = performance.now() / 1000 - t0;
const phase = now % pat.length;
for (let cycle = -1; cycle <= 1; cycle++) {
const cycleBase = cycle * pat.length;
for (const note of pat.notes) {
const dt = note.t + cycleBase - phase;
if (dt < -BEHIND || dt > AHEAD) continue;
placeNote(note, dt);
}
}
}
function placeNote(note, dt, status) {
const laneCfg = LANES[note.lane];
if (!laneCfg) return;
const z = -dt * TS; // dt > 0 → upstream (negative Z)
const x = laneCfg.kind === 'kick' ? 0 : (LANE_X0 + note.lane * LANE_GAP);
const y = laneCfg.kind === 'kick' ? 0 : DISC_H * 0.5;
const mesh = buildNoteMesh(note.lane, note.variant, note.open);
mesh.position.set(x, y, z);
// Brighten emissive as the note approaches the hit line.
// 0 at AHEAD, peak at 0, then linger briefly past it.
const proximity = Math.max(0, 1 - Math.abs(dt) / 0.6);
// Feed the lane approach highlight (strongest note wins per frame).
if (note.lane < LANE_COUNT && proximity > (_laneProx[note.lane] || 0)) {
_laneProx[note.lane] = proximity;
}
const g = _glowMul(); // Glow slider (0.5 default = 1.0x, stock)
if (laneCfg.kind === 'drum' && note.variant !== 'ghost' && mDrumByLane[note.lane]) {
// Subtle pulse via emissiveIntensity — palette-driven base + pulse.
mDrumByLane[note.lane].emissiveIntensity = (0.45 + proximity * 0.35) * g;
} else if (laneCfg.kind === 'cymbal' && mCymbalByLane[note.lane]) {
mCymbalByLane[note.lane].emissiveIntensity = (0.55 + proximity * 0.35) * g;
} else if (laneCfg.kind === 'kick' && mKick) {
mKick.emissiveIntensity = (0.6 + proximity * 0.5) * g;
}
// Slight scale-up as notes near the hit line gives the eye a
// "this is the moment" cue. Capped so it doesn't overshoot.
const approach = 1.0 + Math.max(0, 1 - Math.abs(dt) / 0.3) * 0.12;
mesh.scale.multiplyScalar(approach);
// Status overrides — clone the shared lane material per-note so
// we can recolor JUST this note (green = hit, red = miss). The
// Swap in pre-cloned hit/miss tint materials — avoids per-frame
// per-note material.clone() calls while notesGroup is rebuilt.
if (status === 'hit' || status === 'missed') {
const isHit = status === 'hit';
if (laneCfg.kind === 'kick') {
// Kick uses a single shared bar — swap to the pre-cloned
// tint material for consistent hit/miss feedback.
const tintKick = isHit ? mKickHit : mKickMiss;
mesh.traverse((child) => {
if (!child.isMesh || !child.material) return;
if (child.material === mKick && tintKick) child.material = tintKick;
});
} else {
const tintDrum = isHit ? mDrumHitByLane[note.lane] : mDrumMissByLane[note.lane];
const tintCymbal = isHit ? mCymbalHitByLane[note.lane] : mCymbalMissByLane[note.lane];
const ghostColor = isHit ? 0x00ff88 : 0xff4444;
mesh.traverse((child) => {
if (!child.isMesh || !child.material) return;
const isBase = child.material === mDrumByLane[note.lane];
const isCym = child.material === mCymbalByLane[note.lane];
// Ghost ring uses a dedicated per-note MeshBasicMaterial
// with userData.isGhostRing — tint it in-place (already
// unique per note, no clone needed). Not the same as the
// kick accent edge which only sets userData.transient.
const isGhostRing = child.material.userData && child.material.userData.isGhostRing;
if (isBase && tintDrum) child.material = tintDrum;
else if (isCym && tintCymbal) child.material = tintCymbal;
else if (isGhostRing) child.material.color.setHex(ghostColor);
});
}
}
notesGroup.add(mesh);
// Flam grace note — small auxiliary disc slightly before the main.
// Hit status applies to the whole flam (grace + main vanish
// together); missed status leaves the grace at normal size
// since shrinking just the main reads as enough miss-feedback.
if (note.variant === 'flam' && laneCfg.kind === 'drum') {
const graceDt = dt + FLAM_GRACE_OFFSET;
if (graceDt >= -BEHIND && graceDt <= AHEAD) {
const grace = new T.Mesh(gFlamGrace, mDrumByLane[note.lane]);
grace.position.set(x - DISC_R_BASE * 0.9, y, -graceDt * TS);
notesGroup.add(grace);
}
}
}
function disposeMeshTree(node) {
// Shared geometries/materials (gDrumDisc, mDrumByLane[i], etc.)
// are owned by the renderer and disposed in teardown(). Per-note
// ephemeral geometries/materials are flagged with
// .userData.transient = true at construction time — dispose those.
node.traverse((child) => {
if (!child.isMesh) return;
if (child.geometry && child.geometry.userData && child.geometry.userData.transient) {
child.geometry.dispose();
}
if (child.material && child.material.userData && child.material.userData.transient) {
child.material.dispose();
}
});
}
/* -- size handling ---------------------------------------------- */
function applySize(w, h) {
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));
// 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();
// Record the logical size actually framed for, so the draw()
// drift check can tell when the live canvas box has moved away
// from it (PORTED FROM highway_3d).
_appliedW = W; _appliedH = H;
_sizeFxCanvas();
}
/* -- teardown --------------------------------------------------- */
// Drop scene geometry / materials / renderer without unsubscribing
// lifecycle handlers (settings, kit, MIDI). Used for kit-change
// rebuild where the renderer comes right back up against the same
// canvas. Returns true when the renderer was re-created cleanly,
// 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
_disposeFxObjects();
_disposeEnv(); // PMREM RT is renderer-bound too; initScene rebuilds it
_bgTeardownStyle();
if (notesGroup) {
while (notesGroup.children.length) {
disposeMeshTree(notesGroup.children.pop());
}
}
if (kitMapGroup) {
while (kitMapGroup.children.length) {
const c = kitMapGroup.children.pop();
if (c.geometry) c.geometry.dispose();
if (c.material) c.material.dispose();
}
}
// Dispose laneGroup stripe meshes (PlaneGeometry + MeshBasicMaterial per
// lane) so kit-change rebuilds don't leak GPU resources.
if (laneGroup) {
while (laneGroup.children.length) {
const c = laneGroup.children.pop();
if (c.geometry) c.geometry.dispose();
if (c.material) c.material.dispose();
}
}
disposeMaterialArray(mDrumByLane);
disposeMaterialArray(mCymbalByLane);
disposeMaterialArray(mDrumHitByLane);
disposeMaterialArray(mDrumMissByLane);
disposeMaterialArray(mCymbalHitByLane);
disposeMaterialArray(mCymbalMissByLane);
if (mKick) mKick.dispose();
if (mKickHit) mKickHit.dispose();
if (mKickMiss) mKickMiss.dispose();
if (mAccentRing) mAccentRing.dispose();
if (mGhostRing) mGhostRing.dispose();
if (mSnareStripe) mSnareStripe.dispose();
if (mBellDot) mBellDot.dispose();
if (gDrumDisc) gDrumDisc.dispose();
if (gCymbalGem) gCymbalGem.dispose();
if (gKickBar) gKickBar.dispose();
if (gAccentRing) gAccentRing.dispose();
if (gGhostRing) gGhostRing.dispose();
if (gSnareStripe) gSnareStripe.dispose();
if (gBellDot) gBellDot.dispose();
if (gFlamGrace) gFlamGrace.dispose();
if (gOpenRing) gOpenRing.dispose();
if (mOpenRing) mOpenRing.dispose();
if (ren) ren.dispose();
// Floor + hit-bar resources (tracked since the themes PR — they
// previously leaked across kit-change scene rebuilds).
if (mFloor) mFloor.dispose();
if (_gFloor) _gFloor.dispose();
if (_hitBarMat) _hitBarMat.dispose();
if (_gHitBar) _gHitBar.dispose();
scene = cam = ren = lights = laneGroup = kitMapGroup = notesGroup = null;
ambLight = dirLight = mFloor = _gFloor = _hitBarMat = _gHitBar = null;
mDrumByLane = mCymbalByLane = mKick = mKickHit = mKickMiss = null;
mDrumHitByLane = mDrumMissByLane = mCymbalHitByLane = mCymbalMissByLane = null;
mAccentRing = mGhostRing = mSnareStripe = mBellDot = null;
gDrumDisc = gCymbalGem = gKickBar = null;
gAccentRing = gGhostRing = gSnareStripe = gBellDot = gFlamGrace = null;
gOpenRing = mOpenRing = null;
// Re-create the renderer against the existing canvas. initScene
// populates everything we just disposed.
try {
ren = new T.WebGLRenderer({
canvas: highwayCanvas,
antialias: true,
alpha: false,
});
ren.setClearColor(FOG_COLOR, 1);
return true;
} catch (e) {
console.error('[Drum-Hwy3D] WebGL2 re-init failed:', e);
ren = null;
return false;
}
}
function teardown() {
_bloomDispose();
_disposeFxObjects();
_disposeEnv();
_bgTeardownStyle();
_removeFxCanvas();
// 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;
}
if (_kitHandler) {
window.removeEventListener('drum_h3d:kit', _kitHandler);
_kitHandler = null;
}
if (notesGroup) {
while (notesGroup.children.length) {
disposeMeshTree(notesGroup.children.pop());
}
}
if (kitMapGroup) {
while (kitMapGroup.children.length) {
const c = kitMapGroup.children.pop();
if (c.geometry) c.geometry.dispose();
if (c.material) c.material.dispose();
}
}
disposeMaterialArray(mDrumByLane);
disposeMaterialArray(mCymbalByLane);
disposeMaterialArray(mDrumHitByLane);
disposeMaterialArray(mDrumMissByLane);
disposeMaterialArray(mCymbalHitByLane);
disposeMaterialArray(mCymbalMissByLane);
if (mKick) mKick.dispose();
if (mKickHit) mKickHit.dispose();
if (mKickMiss) mKickMiss.dispose();
if (mAccentRing) mAccentRing.dispose();
if (mGhostRing) mGhostRing.dispose();
if (mSnareStripe) mSnareStripe.dispose();
if (mBellDot) mBellDot.dispose();
if (gDrumDisc) gDrumDisc.dispose();
if (gCymbalGem) gCymbalGem.dispose();
if (gKickBar) gKickBar.dispose();
if (gAccentRing) gAccentRing.dispose();
if (gGhostRing) gGhostRing.dispose();
if (gSnareStripe) gSnareStripe.dispose();
if (gBellDot) gBellDot.dispose();
if (gFlamGrace) gFlamGrace.dispose();
if (gOpenRing) gOpenRing.dispose();
if (mOpenRing) mOpenRing.dispose();
if (ren) ren.dispose();
// Floor + hit-bar resources (tracked since the themes PR — they
// previously leaked across kit-change scene rebuilds).
if (mFloor) mFloor.dispose();
if (_gFloor) _gFloor.dispose();
if (_hitBarMat) _hitBarMat.dispose();
if (_gHitBar) _gHitBar.dispose();
scene = cam = ren = lights = laneGroup = kitMapGroup = notesGroup = null;
ambLight = dirLight = mFloor = _gFloor = _hitBarMat = _gHitBar = null;
mDrumByLane = mCymbalByLane = mKick = mKickHit = mKickMiss = null;
mDrumHitByLane = mDrumMissByLane = mCymbalHitByLane = mCymbalMissByLane = null;
mAccentRing = mGhostRing = mSnareStripe = mBellDot = null;
gDrumDisc = gCymbalGem = gKickBar = null;
gAccentRing = gGhostRing = gSnareStripe = gBellDot = gFlamGrace = null;
gOpenRing = mOpenRing = null;
_isReady = false;
}
/* -- setRenderer contract --------------------------------------- */
const instance = {
contextType: 'webgl2',
init(canvas, _bundle) {
if (_isReady) teardown();
highwayCanvas = canvas;
settings = readSettings();
activePalette = PALETTES[settings.palette];
fx = readFxSettings();
// Persisted string settings refresh here too — a theme saved
// while no instance was listening must not come up stale on a
// later init().
_theme = readThemeSetting();
_bgStyle = readBgStyleSetting();
loadThree().then(() => {
if (!highwayCanvas) return; // destroyed before load resolved
try {
ren = new T.WebGLRenderer({
canvas: highwayCanvas,
antialias: true,
alpha: false,
});
ren.setClearColor(FOG_COLOR, 1);
} catch (e) {
console.error('[Drum-Hwy] WebGL2 init failed:', e);
return;
}
initScene();
applySize(highwayCanvas.clientWidth, highwayCanvas.clientHeight);
_settingsHandler = (ev) => applySettings(ev && ev.detail);
window.addEventListener('drum_h3d:settings', _settingsHandler);
// Kit-change: full scene rebuild because lane count,
// positions, kit silhouette and all materials depend
// on the active kit. Cheaper than dirty-tracking each
// affected piece.
_kitHandler = () => {
if (!_isReady || !ren) return;
_cachedDrumTabKey = null; // force note re-resolution
_cachedRealNotes = null;
// Drop the scene + rebuild against the new LANES.
// initScene reads the module-level LANES which was
// recomputed by drumH3dSetKit before this event.
const w = highwayCanvas ? highwayCanvas.clientWidth : 0;
const h = highwayCanvas ? highwayCanvas.clientHeight : 0;
if (!_disposeScene()) {
// WebGL renderer re-init failed — don't build a
// scene that would draw into nothing. Mark not
// ready and fully tear down so the plugin fails
// loudly rather than freezing half-initialised.
_isReady = false;
teardown();
return;
}
initScene();
applySize(w, h);
};
window.addEventListener('drum_h3d:kit', _kitHandler);
// MIDI lifecycle. _midiInit is idempotent across init
// calls (re-running setRenderer for the same plugin
// doesn't re-request browser permission); _midiResume
// wires the message handler whenever this instance
// claims focus, which for the non-splitscreen case is
// 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();
_injectHud();
_isReady = true;
});
},
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);
}
// Keep the render matched to the highway canvas's real box
// (PORTED FROM highway_3d — keep in sync). Two drifts to catch:
// 1. Backing store (canvas.width/height) changed out from under
// us — the splitscreen hw.resize override resizes the element
// but never calls renderer.resize().
// 2. The CSS box (clientWidth/Height) drifted while the backing
// store held — e.g. the flex #highway box settling after a
// fullscreen transition, with no backing-store change and no
// resize() call, so branch 1 never fires. Without this the
// drum/keys panels stay framed for the pre-fullscreen size.
if (highwayCanvas) {
const _bsChanged = highwayCanvas.width !== _lastHwW
|| highwayCanvas.height !== _lastHwH;
_boxCheckCountdown = (_boxCheckCountdown + 1) % 10;
if (_bsChanged || _boxCheckCountdown === 0) {
const _bw = highwayCanvas.clientWidth | 0;
const _bh = highwayCanvas.clientHeight | 0;
if (_bsChanged) {
_lastHwW = highwayCanvas.width;
_lastHwH = highwayCanvas.height;
if (_bw > 0 && _bh > 0) applySize(_bw, _bh);
} else if (_bw > 0 && _bh > 0 &&
(Math.abs(_bw - _appliedW) > 1 || Math.abs(_bh - _appliedH) > 1)) {
applySize(_bw, _bh);
}
}
}
rebuildNotes(bundle);
// Wall-clock FX step (sparks, kick pulse) — decoupled from
// song time so effects keep settling while paused/seeking.
{
const nowMs = performance.now();
const fdt = _fxLastWall === 0 ? 1 / 60 : Math.min(0.05, (nowMs - _fxLastWall) / 1000);
_fxLastWall = nowMs;
_sparkUpdate(fdt);
// Background ambience: advance the active style with the
// audio bands (zeros when reactivity is off). Uses this
// block's fdt — recomputing from _fxLastWall after the
// line above would read ~0 and freeze the animation.
if (_bgState && _bgState.s && BG_STYLES[_bgState._style]) {
const bands = fx.bgReactive ? _bgReadBands() : BG_ZERO_BANDS;
try {
BG_STYLES[_bgState._style].update(_bgState.s, bands, fdt, nowMs / 1000);
} catch (_) { /* visual-only */ }
}
// Kick pulse decays each frame; it drives the floor flash and,
// via applyCamera(), the camera Y dip.
if (_kickPulse > 0.001) {
_kickPulse *= Math.exp(-fdt * 7);
if (_floorFlash) _floorFlash.material.opacity = 0.25 * _kickPulse * fx.hitFx;
} else if (_kickPulse !== 0) {
_kickPulse = 0;
if (_floorFlash) _floorFlash.material.opacity = 0;
}
// Write the camera every frame: static base pose + kick dip +
// Camera Director free-cam offsets (per-panel-aware).
applyCamera();
}
// Approach highlight: raise each lane stripe toward its next
// note (accumulated by the rebuildNotes walk above).
for (let i = 0; i < laneStripeMats.length; i++) {
laneStripeMats[i].opacity = _stripeBaseOpacity() + 0.28 * (_laneProx[i] || 0) * fx.hitFx;
}
_applyLaneFlashes();
_refreshHud();
if (fx.scoreFx) { _ensureFxCanvas(); _drawScoreFx(); }
else if (_fxCtx && _fxCanvas && _fxCanvas._dirty) {
_fxCtx.clearRect(0, 0, _fxCanvas.width, _fxCanvas.height);
_fxCanvas._dirty = false;
}
// 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) {
if (!_isReady) return;
applySize(w, h);
},
destroy() {
_instances.delete(instance);
// If the focused instance is going away but others remain
// (splitscreen teardown of one panel), promote a survivor so
// _midiOnMessage keeps routing instead of dropping every
// event on its `if (!_activeInstance) return` guard — same
// behavior as keys_highway_3d.
if (_activeInstance === instance) {
_activeInstance = null;
for (const inst of _instances) { _activeInstance = inst; break; }
}
if (_instances.size === 0) _midiReleaseSession();
teardown(); // includes _removeHud()
// Instances are reused across songs (destroy() → init()); stale
// applied/backing dims would suppress the first reframe of the
// next song (PORTED FROM highway_3d).
_lastHwW = 0; _lastHwH = 0;
_appliedW = 0; _appliedH = 0;
highwayCanvas = null;
},
// Exposed for module-level MIDI router. The receiver runs on
// every note-on dispatched to _activeInstance.
_handleDrumHit,
// Headless FX-state probe for window.__drumHwTest — reports
// whether sparks are live, the strongest lane-flash opacity, and
// the kick-pulse envelope so tests can assert the FX pipeline
// without pixel-diffing a scrolling scene.
_fxProbe() {
let maxFlash = 0;
for (const q of _laneFlashQuads) {
if (q && q.material.opacity > maxFlash) maxFlash = q.material.opacity;
}
if (_kickFlashQuad && _kickFlashQuad.material.opacity > maxFlash) {
maxFlash = _kickFlashQuad.material.opacity;
}
let popsLive = 0;
for (const pop of _scorePops) if (pop.active) popsLive++;
return {
sparksLive: !!(_sparkPts && _sparkPts.visible),
maxFlash,
kickPulse: _kickPulse,
flashCount: _laneFlashes.length,
bgStyle: _bgState ? _bgState._style : null,
bgLive: !!(_bgState && _bgState.s),
popsLive,
};
},
};
return instance;
}
/* ======================================================================
* Register
* ====================================================================== */
window.slopsmithViz_drum_highway_3d = createFactory;
// slopsmith→feedBack rename: host viz picker looks up `window.feedBackViz_<id>`.
window.feedBackViz_drum_highway_3d = window.slopsmithViz_drum_highway_3d;
// Static contextType so core can read it for canvas-swap decisions
// before constructing a throwaway renderer instance.
window.slopsmithViz_drum_highway_3d.contextType = 'webgl2';
// Auto-select on sloppaks that carry a drum_tab.json (sloppak-spec
// §5.3). Sorts before `drums` alphabetically so Auto mode picks the
// 3D viz over the 2D fallback. The 2D drums plugin keeps its own
// matchesArrangement as a defence-in-depth fallback for WebGL2-less
// browsers — _autoMatchViz gates webgl2 contextType on _canRun3D
// before installing the renderer, so non-WebGL2 environments will
// skip past this and pick the 2D one.
//
// Steal-guard (bundling): `has_drum_tab` is a PACK-level flag, and Auto
// mode is first-match-wins in plugin order — `drum_highway_3d` sorts
// before `highway_3d`, so matching on the flag alone would take every
// full-band pack away from the guitar highway even when the active
// arrangement is Lead/Bass. Only auto-claim when the ACTIVE arrangement
// is a drum part, or when nothing more specific can render the song
// (no keys notation, no guitar-family arrangement). A drummer on a
// full-band pack picks this viz from the picker as before; instrument-
// selector routing (server.py highway_ws) will auto-route once a drums
// selector entry lands.
window.slopsmithViz_drum_highway_3d.matchesArrangement = function (songInfo) {
if (!songInfo || !songInfo.has_drum_tab) return false;
const arr = songInfo.arrangement || '';
if (/\b(?:drums?|percussion)\b/i.test(arr)) return true;
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 = {
_resolveFreeCam,
_ssApi,
_variantForHit,
_classifyTiming,
readFxSettings,
readThemeSetting,
readBgStyleSetting,
_bgThemeColors,
BG_THEMES,
BG_STYLE_IDS,
FX_DEFAULTS,
MIDI_TO_PIECE,
HIT_TOLERANCE_S,
};
// Headless verification hook (keys __keysHwTest pattern): lets
// Playwright fire synthetic pad hits through the full hit-detection +
// FX path without a physical MIDI kit, and read the live FX state back
// (the demo pattern scrolls every frame, so pixel-diff assertions can't
// distinguish FX from scroll — this probe can).
window.__drumHwTest = {
injectHit(midiNote, velocity) {
if (_activeInstance && typeof _activeInstance._handleDrumHit === 'function') {
_activeInstance._handleDrumHit(midiNote, velocity == null ? 100 : velocity);
}
},
probe() {
return (_activeInstance && typeof _activeInstance._fxProbe === 'function')
? _activeInstance._fxProbe() : null;
},
};
})();