// 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*(u−0.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, // 0–1 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, // 0–1 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.25–1) —
// 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 =
`
${_streak}× combo
` +
`${_hits}/${total} (${pct}%)
` +
(_bestStreak ? `best ${_bestStreak}
` : '');
}
/* ── 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_`.
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;
},
};
})();