mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-23 05:11:34 +00:00
_resolveFreeCam() (keys/drum) and _freeCamFor() (highway_3d) guarded the panel map lookup with only `i != null`, so a non-integer / negative / string index from panelIndexFor() could resolve an unintended or inherited property (e.g. map['toString']) instead of cleanly falling back to the global camera. Gate the index on `Number.isInteger(i) && i >= 0` before `map[i]`, matching the hardening already applied in _bgPanelKey(). Extend the resolver tests with float/string (prototype-key) cases. Behavior change only for malformed indices. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Kris Anderson <topkoa@gmail.com>
3680 lines
177 KiB
JavaScript
3680 lines
177 KiB
JavaScript
// 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 =
|
||
`<div style="color:${comboColor};font-weight:600;font-size:18px">${_streak}× combo</div>` +
|
||
`<div>${_hits}/${total} (${pct}%)</div>` +
|
||
(_bestStreak ? `<div style="color:#94a3b8;font-size:11px">best ${_bestStreak}</div>` : '');
|
||
}
|
||
|
||
/* ── Lane flash — pulse a lane's emissive briefly on hit ── */
|
||
// Wallclock decay window — matches the 2D plugin's 300 ms flash.
|
||
const FLASH_MS = 300;
|
||
|
||
// Timing → color (PORTED FROM highway_3d _timingHex — keep in sync):
|
||
// on-time green, early cyan, late amber; green when timing is
|
||
// unknown or the user turned timing colors off.
|
||
function _timingHex(ts) {
|
||
if (!fx.timingFx || !ts || ts === 'OK') return 0x22ff88;
|
||
if (ts === 'EARLY') return 0x35d6ff;
|
||
if (ts === 'LATE') return 0xffb84d;
|
||
return 0x22ff88;
|
||
}
|
||
|
||
// Hit sparks (PORTED FROM highway_3d _sparkBurst/_sparkUpdate — keep
|
||
// in sync): a pooled additive Points cloud; a small radial burst with
|
||
// an upward kick fires at the struck lane, integrated with gravity +
|
||
// color fade in draw().
|
||
function _sparkBurst(x, y, z, hex, count) {
|
||
if (!_sparkPts || count <= 0) return;
|
||
const r = ((hex >> 16) & 255) / 255, g = ((hex >> 8) & 255) / 255, b = (hex & 255) / 255;
|
||
let made = 0;
|
||
for (let i = 0; i < _SPARK_N && made < count; i++) {
|
||
if (_sparkLife[i] > 0) continue;
|
||
const j = i * 3, ang = Math.random() * Math.PI * 2, sp = (5 + Math.random() * 12) * K;
|
||
_sparkPos[j] = x; _sparkPos[j + 1] = y; _sparkPos[j + 2] = z;
|
||
_sparkVel[j] = Math.cos(ang) * sp; _sparkVel[j + 1] = (12 + Math.random() * 24) * K; _sparkVel[j + 2] = Math.sin(ang) * sp * 0.55;
|
||
_sparkCol[j] = r; _sparkCol[j + 1] = g; _sparkCol[j + 2] = b;
|
||
_sparkLife[i] = 0.30 + Math.random() * 0.16; made++;
|
||
}
|
||
}
|
||
function _sparkUpdate(dt) {
|
||
if (!_sparkPts) return;
|
||
const grav = 55 * K; let any = false;
|
||
for (let i = 0; i < _SPARK_N; i++) {
|
||
if (_sparkLife[i] <= 0) continue;
|
||
const j = i * 3;
|
||
_sparkLife[i] -= dt;
|
||
if (_sparkLife[i] <= 0) { _sparkCol[j] = _sparkCol[j + 1] = _sparkCol[j + 2] = 0; continue; }
|
||
any = true;
|
||
_sparkVel[j + 1] -= grav * dt;
|
||
_sparkPos[j] += _sparkVel[j] * dt; _sparkPos[j + 1] += _sparkVel[j + 1] * dt; _sparkPos[j + 2] += _sparkVel[j + 2] * dt;
|
||
const fade = 1 - Math.min(1, dt * 3.2);
|
||
_sparkCol[j] *= fade; _sparkCol[j + 1] *= fade; _sparkCol[j + 2] *= fade;
|
||
}
|
||
_sparkPts.geometry.attributes.position.needsUpdate = true;
|
||
_sparkPts.geometry.attributes.color.needsUpdate = true;
|
||
_sparkPts.visible = any;
|
||
}
|
||
|
||
// Fire the 3D feedback for a struck lane: spark burst (timing-colored
|
||
// for hand lanes, always-amber for the kick, whose identity color IS
|
||
// amber) + the kick camera/floor pulse. Wrong hits get no sparks —
|
||
// the red lane flash from _applyLaneFlashes carries that verdict.
|
||
function _spawnHitFx(lane, ts, wrong) {
|
||
const laneCfg = LANES[lane];
|
||
if (!laneCfg || wrong) return;
|
||
let count = 10;
|
||
if (fx.streakFx) count += Math.round(8 * Math.min(1, _streak / 16));
|
||
if (laneCfg.kind === 'kick') {
|
||
_kickPulse = 1;
|
||
if (fx.sparks) {
|
||
for (const sx of [-KICK_W * 0.3, 0, KICK_W * 0.3]) {
|
||
_sparkBurst(sx, KICK_H + 1 * K, 0, KICK_COLOR, Math.max(6, count - 4));
|
||
}
|
||
}
|
||
} else if (fx.sparks) {
|
||
const x = LANE_X0 + lane * LANE_GAP;
|
||
const y = (laneCfg.kind === 'cymbal' ? CYMBAL_H : DISC_H) + 1 * K;
|
||
_sparkBurst(x, y, 0, _timingHex(ts), count);
|
||
}
|
||
}
|
||
|
||
function _applyLaneFlashes() {
|
||
// Drop expired entries first so the buffer can't grow forever if
|
||
// MIDI hits arrive while no chart is loaded.
|
||
const now = performance.now();
|
||
while (_laneFlashes.length && now - _laneFlashes[0].wall > FLASH_MS) {
|
||
_laneFlashes.shift();
|
||
}
|
||
if (!_laneFlashQuads.length && !_kickFlashQuad) return;
|
||
// Reset, then drive each lane's quad from its strongest live
|
||
// flash. Additive quads with the shared gauss falloff read as a
|
||
// brief light-up of the struck lane at the hit line.
|
||
for (const q of _laneFlashQuads) if (q) q.material.opacity = 0;
|
||
if (_kickFlashQuad) _kickFlashQuad.material.opacity = 0;
|
||
if (!(fx.hitFx > 0)) return;
|
||
for (const f of _laneFlashes) {
|
||
if (f.lane === -1) continue; // unknown piece — no lane to flash
|
||
const laneCfg = LANES[f.lane];
|
||
if (!laneCfg) continue;
|
||
const quad = laneCfg.kind === 'kick' ? _kickFlashQuad : _laneFlashQuads[f.lane];
|
||
if (!quad) continue;
|
||
const age = (now - f.wall) / FLASH_MS;
|
||
const strength = Math.max(0, 1 - age) * 0.85 * fx.hitFx;
|
||
if (strength > quad.material.opacity) {
|
||
quad.material.opacity = strength;
|
||
quad.material.color.setHex(f.kind === 'wrong' ? 0xff4444 : _timingHex(f.ts));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Dispose every hit-FX GPU resource. Called from teardown() AND
|
||
// _disposeScene() — like the bloom composer, all of these are bound
|
||
// to the renderer being dropped; initScene rebuilds them.
|
||
function _disposeFxObjects() {
|
||
if (_sparkPts) {
|
||
try { _sparkPts.geometry.dispose(); _sparkPts.material.dispose(); } catch (_) {}
|
||
_sparkPts = null;
|
||
_sparkPos = _sparkCol = _sparkVel = _sparkLife = null;
|
||
}
|
||
for (const q of _laneFlashQuads) {
|
||
if (q) { try { q.geometry.dispose(); q.material.dispose(); } catch (_) {} }
|
||
}
|
||
_laneFlashQuads = [];
|
||
if (_kickFlashQuad) {
|
||
try { _kickFlashQuad.geometry.dispose(); _kickFlashQuad.material.dispose(); } catch (_) {}
|
||
_kickFlashQuad = null;
|
||
}
|
||
if (_floorFlash) {
|
||
try { _floorFlash.geometry.dispose(); _floorFlash.material.dispose(); } catch (_) {}
|
||
_floorFlash = null;
|
||
}
|
||
if (_gaussTex) { try { _gaussTex.dispose(); } catch (_) {} _gaussTex = null; }
|
||
laneStripeMats = []; // owned + disposed with laneGroup
|
||
_kickPulse = 0;
|
||
}
|
||
|
||
/* ── Bloom (PORTED FROM highway_3d/screen.js _bloomEnsure — keep in
|
||
* sync; deliberate delta: this copy tracks pixel-ratio changes via
|
||
* composer.setPixelRatio (here and in applySize) because renderScale
|
||
* changes the ratio at runtime — the upstream composer never learns
|
||
* about ratio changes after construction, a candidate fix to port
|
||
* back to highway_3d) ── */
|
||
// Lazy-load the vendored postprocessing addons and build an
|
||
// EffectComposer (RenderPass -> UnrealBloomPass -> OutputPass/ACES).
|
||
// Returns the composer once ready, or null (caller falls back to a
|
||
// direct render — also the permanent path if the addons are missing,
|
||
// e.g. an older self-hosted core without static/vendor/three/addons).
|
||
function _bloomEnsure() {
|
||
if (_composer) return _composer;
|
||
if (_bloomLoad || !ren || !scene || !cam) return null;
|
||
const A = '/static/vendor/three/addons/';
|
||
const myGen = _bloomGen; // superseded by any _bloomDispose()
|
||
_bloomLoad = Promise.all([
|
||
import(A + 'postprocessing/EffectComposer.js'),
|
||
import(A + 'postprocessing/RenderPass.js'),
|
||
import(A + 'postprocessing/UnrealBloomPass.js'),
|
||
import(A + 'postprocessing/OutputPass.js'),
|
||
]).then(([EC, RP, UB, OP]) => {
|
||
try {
|
||
// Torn down or superseded mid-load (a dispose clears
|
||
// _bloomLoad, letting a NEW load start against the new
|
||
// renderer — this stale completion must not also build
|
||
// and orphan a composer).
|
||
if (myGen !== _bloomGen || _composer) return;
|
||
if (!ren || !scene || !cam || !highwayCanvas) return;
|
||
const w = Math.max(2, (highwayCanvas.clientWidth || highwayCanvas.width || 1280) | 0);
|
||
const h = Math.max(2, (highwayCanvas.clientHeight || highwayCanvas.height || 720) | 0);
|
||
// Multisampled (WebGL2 MSAA) HalfFloat target so anti-aliasing
|
||
// survives the bloom path — EffectComposer's default target has
|
||
// no `samples`.
|
||
const rt = new T.WebGLRenderTarget(w, h, { type: T.HalfFloatType, samples: 4 });
|
||
const comp = new EC.EffectComposer(ren, rt);
|
||
comp.setPixelRatio(ren.getPixelRatio());
|
||
comp.addPass(new RP.RenderPass(scene, cam));
|
||
_bloomPass = new UB.UnrealBloomPass(new T.Vector2(w, h), 0.65, 0.5, 0.82); // strength, radius, threshold (high → only emissive blooms)
|
||
comp.addPass(_bloomPass);
|
||
comp.addPass(new OP.OutputPass());
|
||
comp.setSize(w, h);
|
||
_bloomW = w; _bloomH = h; _composer = comp;
|
||
} catch (e) { console.warn('[Drum-Hwy3D] bloom init failed', e); _composer = null; }
|
||
}).catch((e) => console.warn('[Drum-Hwy3D] bloom modules failed', e));
|
||
return null;
|
||
}
|
||
|
||
// Drop the composer + its render targets. Called from teardown() AND
|
||
// _disposeScene() — the kit-change path recreates the renderer, and a
|
||
// composer bound to the dead renderer would draw into nothing.
|
||
// Nulling _bloomLoad lets _bloomEnsure rebuild lazily against the new
|
||
// renderer.
|
||
function _bloomDispose() {
|
||
if (_composer) {
|
||
// EffectComposer.dispose() only frees its own read/write
|
||
// buffers — passes own additional GPU resources (UnrealBloom
|
||
// keeps several render targets + materials, OutputPass a
|
||
// material), so dispose each pass explicitly first.
|
||
try {
|
||
for (const p of _composer.passes || []) {
|
||
if (p && typeof p.dispose === 'function') p.dispose();
|
||
}
|
||
} catch (_) {}
|
||
try { _composer.dispose(); } catch (_) {}
|
||
}
|
||
_composer = null;
|
||
_bloomPass = null;
|
||
_bloomLoad = null;
|
||
_bloomW = 0; _bloomH = 0;
|
||
_bloomGen++; // invalidate any in-flight addon load
|
||
}
|
||
|
||
function _resetScoring() {
|
||
_hitKeys.clear();
|
||
_missedKeys.clear();
|
||
_hits = 0; _misses = 0;
|
||
_streak = 0; _bestStreak = 0;
|
||
_laneFlashes.length = 0;
|
||
_missSweepFloor = -Infinity;
|
||
}
|
||
|
||
function _hitKey(t, lane) {
|
||
return t.toFixed(3) + '|' + lane;
|
||
}
|
||
|
||
// MIDI note-on dispatcher. Resolves the played MIDI to a lane
|
||
// index, scans the visible chart window for an un-hit matching
|
||
// note within ±HIT_TOLERANCE_S, and updates scoring + visual
|
||
// feedback. Wrong-piece or missed-window hits flash red.
|
||
function _handleDrumHit(midiNote, velocity) {
|
||
_synthDrumHit(midiNote, velocity);
|
||
_synthEnsureCtx();
|
||
|
||
const piece = MIDI_TO_PIECE[midiNote];
|
||
const lane = piece !== undefined ? PIECE_TO_LANE[piece] : undefined;
|
||
if (lane === undefined) {
|
||
_laneFlashes.push({ lane: -1, wall: performance.now(), kind: 'wrong' });
|
||
return;
|
||
}
|
||
|
||
if (!_latestNotes || _latestNotes.length === 0) {
|
||
// Free-play (no chart loaded): no scoring, but still give
|
||
// the pad strike its lane flash + sparks so drumming along
|
||
// to the demo pattern feels alive.
|
||
_laneFlashes.push({ lane, wall: performance.now(), kind: 'hit' });
|
||
_spawnHitFx(lane, null, false);
|
||
return;
|
||
}
|
||
|
||
const t = _latestTime;
|
||
|
||
let foundHit = false;
|
||
let matchedDelta = 0; // note.t - now of the matched note (timing verdict)
|
||
for (const n of _latestNotes) {
|
||
if (n.t > t + HIT_TOLERANCE_S) break;
|
||
if (n.t < t - HIT_TOLERANCE_S) continue;
|
||
if (n.lane !== lane) continue;
|
||
const key = _hitKey(n.t, n.lane);
|
||
if (_hitKeys.has(key)) continue;
|
||
_hitKeys.add(key);
|
||
foundHit = true;
|
||
matchedDelta = n.t - t;
|
||
break;
|
||
}
|
||
|
||
if (foundHit) {
|
||
_hits++;
|
||
_streak++;
|
||
if (_streak > _bestStreak) _bestStreak = _streak;
|
||
const ts = _classifyTiming(matchedDelta, HIT_TOLERANCE_S);
|
||
_laneFlashes.push({ lane, wall: performance.now(), kind: 'hit', ts });
|
||
_spawnHitFx(lane, ts, false);
|
||
_scoreOnHit(lane);
|
||
} else {
|
||
const prevStreak = _streak;
|
||
_misses++;
|
||
_streak = 0;
|
||
_laneFlashes.push({ lane, wall: performance.now(), kind: 'wrong' });
|
||
_scoreOnBreak(prevStreak);
|
||
}
|
||
}
|
||
|
||
// Walk recently-passed notes once per frame and mark any unhit
|
||
// ones as missed. Called from rebuildNotes after the bundle's
|
||
// currentTime is known.
|
||
function _updateMissed(t) {
|
||
if (!_latestNotes) return;
|
||
const cutoff = t - HIT_TOLERANCE_S - 0.02;
|
||
for (const n of _latestNotes) {
|
||
if (n.t > cutoff) break;
|
||
if (n.t < cutoff - 2) continue; // older than 2 s — already counted
|
||
if (n.t <= _missSweepFloor) continue; // passed before MIDI connected
|
||
const key = _hitKey(n.t, n.lane);
|
||
if (_hitKeys.has(key) || _missedKeys.has(key)) continue;
|
||
_missedKeys.add(key);
|
||
const prevStreak = _streak;
|
||
_misses++;
|
||
_streak = 0;
|
||
_scoreOnBreak(prevStreak);
|
||
}
|
||
}
|
||
|
||
function applySettings(detail) {
|
||
if (!detail) return;
|
||
if (detail.palette && PALETTES[detail.palette]) {
|
||
settings.palette = detail.palette;
|
||
activePalette = PALETTES[detail.palette];
|
||
rebuildPaletteMaterials();
|
||
rebuildKitMap();
|
||
if (_bgStyle === 'lights') _bgMountStyle(); // lights use the palette
|
||
}
|
||
if (detail.pattern && DEMO_PATTERNS[detail.pattern]) {
|
||
settings.pattern = detail.pattern;
|
||
}
|
||
if (typeof detail.cameraAngle === 'number') {
|
||
settings.cameraAngle = Math.min(1, Math.max(0, detail.cameraAngle));
|
||
positionCamera();
|
||
}
|
||
if (detail.fx) {
|
||
// FX toggles are consumed per-frame in draw() — no rebuild
|
||
// needed; the bloom composer stays cached while toggled off.
|
||
for (const k of Object.keys(detail.fx)) {
|
||
if (k in FX_DEFAULTS) fx[k] = detail.fx[k];
|
||
}
|
||
if ('cinematic' in detail.fx) _applyCinematic();
|
||
if ('glow' in detail.fx) {
|
||
// Static emissive bases (hit bar, snare stripe, hit/miss
|
||
// tints) are baked at build time — rebuild them; the
|
||
// per-frame placeNote pulse picks the new value up on
|
||
// its own.
|
||
rebuildPaletteMaterials();
|
||
if (_hitBarMat) _hitBarMat.emissiveIntensity = 0.9 * _glowMul();
|
||
if (mSnareStripe) mSnareStripe.emissiveIntensity = 0.5 * _glowMul();
|
||
}
|
||
if ('vibrancy' in detail.fx) _applyVibrancy();
|
||
if ('bgIntensity' in detail.fx) _bgMountStyle();
|
||
}
|
||
if (detail.theme && BG_THEMES[detail.theme]) {
|
||
_theme = detail.theme;
|
||
_applyTheme();
|
||
}
|
||
if (detail.bgStyle && BG_STYLE_IDS.indexOf(detail.bgStyle) !== -1) {
|
||
_bgStyle = detail.bgStyle;
|
||
_bgMountStyle();
|
||
}
|
||
}
|
||
|
||
// Vibrancy slider: stripes get their new base next frame (draw()
|
||
// recomputes them); the halo/ghost ring statics update here.
|
||
function _applyVibrancy() {
|
||
const v = Math.min(1, Math.max(0, fx.vibrancy));
|
||
if (mAccentRing) mAccentRing.opacity = 0.6 + 0.3 * v;
|
||
// Ghost rings get their vibrancy at per-note material creation in
|
||
// buildNoteMesh (they never render with the shared mGhostRing).
|
||
}
|
||
|
||
function rebuildPaletteMaterials() {
|
||
disposeMaterialArray(mDrumByLane);
|
||
disposeMaterialArray(mCymbalByLane);
|
||
disposeMaterialArray(mDrumHitByLane);
|
||
disposeMaterialArray(mDrumMissByLane);
|
||
disposeMaterialArray(mCymbalHitByLane);
|
||
disposeMaterialArray(mCymbalMissByLane);
|
||
mDrumByLane = new Array(LANES.length).fill(null);
|
||
mCymbalByLane = new Array(LANES.length).fill(null);
|
||
mDrumHitByLane = new Array(LANES.length).fill(null);
|
||
mDrumMissByLane = new Array(LANES.length).fill(null);
|
||
mCymbalHitByLane = new Array(LANES.length).fill(null);
|
||
mCymbalMissByLane = new Array(LANES.length).fill(null);
|
||
for (let i = 0; i < LANES.length; i++) {
|
||
const lane = LANES[i];
|
||
const color = lane.kind === 'kick' ? KICK_COLOR : activePalette[lane.paletteIdx];
|
||
if (lane.kind === 'drum') {
|
||
mDrumByLane[i] = new T.MeshStandardMaterial({
|
||
color,
|
||
emissive: color,
|
||
emissiveIntensity: 0.45 * _glowMul(),
|
||
// Drumhead: satin, a little env pickup so the studio
|
||
// strips give the discs some dimension.
|
||
roughness: 0.45,
|
||
metalness: 0.1,
|
||
envMapIntensity: 0.5,
|
||
});
|
||
// Pre-cloned tint materials for hit (green) and miss (red),
|
||
// keyed per lane so proximity emissive adjustments on the base
|
||
// material don't affect tinted notes.
|
||
mDrumHitByLane[i] = mDrumByLane[i].clone();
|
||
mDrumHitByLane[i].color.setHex(0x22c55e);
|
||
mDrumHitByLane[i].emissive.setHex(0x22c55e);
|
||
mDrumMissByLane[i] = mDrumByLane[i].clone();
|
||
mDrumMissByLane[i].color.setHex(0xef4444);
|
||
mDrumMissByLane[i].emissive.setHex(0xef4444);
|
||
} else if (lane.kind === 'cymbal') {
|
||
mCymbalByLane[i] = new T.MeshStandardMaterial({
|
||
color,
|
||
emissive: color,
|
||
emissiveIntensity: 0.55 * _glowMul(),
|
||
// With scene.environment set (studio env map) the
|
||
// metalness finally reads as metal — the old 0.7
|
||
// metalness had no envMap and looked matte.
|
||
roughness: 0.2,
|
||
metalness: 0.85,
|
||
envMapIntensity: 1.2,
|
||
transparent: true,
|
||
opacity: 0.92,
|
||
});
|
||
mCymbalHitByLane[i] = mCymbalByLane[i].clone();
|
||
mCymbalHitByLane[i].color.setHex(0x22c55e);
|
||
mCymbalHitByLane[i].emissive.setHex(0x22c55e);
|
||
mCymbalMissByLane[i] = mCymbalByLane[i].clone();
|
||
mCymbalMissByLane[i].color.setHex(0xef4444);
|
||
mCymbalMissByLane[i].emissive.setHex(0xef4444);
|
||
}
|
||
}
|
||
if (mKick) mKick.dispose();
|
||
if (mKickHit) mKickHit.dispose();
|
||
if (mKickMiss) mKickMiss.dispose();
|
||
mKick = new T.MeshStandardMaterial({
|
||
color: KICK_COLOR,
|
||
emissive: KICK_COLOR,
|
||
emissiveIntensity: 0.6 * _glowMul(),
|
||
roughness: 0.4,
|
||
metalness: 0.2,
|
||
envMapIntensity: 0.6,
|
||
});
|
||
mKickHit = mKick.clone();
|
||
mKickHit.color.setHex(0x22c55e);
|
||
mKickHit.emissive.setHex(0x22c55e);
|
||
mKickMiss = mKick.clone();
|
||
mKickMiss.color.setHex(0xef4444);
|
||
mKickMiss.emissive.setHex(0xef4444);
|
||
}
|
||
|
||
function disposeMaterialArray(arr) {
|
||
if (!arr) return;
|
||
for (const m of arr) if (m) m.dispose();
|
||
}
|
||
|
||
/* -- background ambience (PORTED FROM highway_3d BG_STYLES:
|
||
* off/particles/lights/geometric — keep formulas in sync; the
|
||
* guitar's silhouettes/butterchurn/image/video are out of scope) -- */
|
||
|
||
const BG_STYLES = {
|
||
off: {
|
||
build() { return null; },
|
||
update() {},
|
||
teardown() {},
|
||
},
|
||
particles: {
|
||
build(group, settings) {
|
||
const N = Math.max(20, Math.floor(80 + 200 * settings.intensity));
|
||
const positions = new Float32Array(N * 3);
|
||
for (let i = 0; i < N; i++) {
|
||
positions[i * 3] = (Math.random() - 0.5) * 800 * K;
|
||
positions[i * 3 + 1] = (Math.random() - 0.4) * 80 * K;
|
||
// Inside the visible fog band; renderOrder -1 keeps
|
||
// them behind notes regardless of z.
|
||
positions[i * 3 + 2] = -FOG_START - Math.random() * (FOG_END - FOG_START) * 0.85;
|
||
}
|
||
const geo = new T.BufferGeometry();
|
||
geo.setAttribute('position', new T.BufferAttribute(positions, 3));
|
||
const mat = new T.PointsMaterial({
|
||
color: 0xa0c0ff, size: 5 * K, transparent: true,
|
||
blending: T.AdditiveBlending, depthWrite: false, sizeAttenuation: true,
|
||
});
|
||
const points = new T.Points(geo, mat);
|
||
points.renderOrder = -1;
|
||
points.frustumCulled = false;
|
||
group.add(points);
|
||
return { points, geo, mat, N };
|
||
},
|
||
update(s, bands, dt) {
|
||
const positions = s.geo.attributes.position.array;
|
||
const dx = dt * (3 + bands.mid * 12) * K;
|
||
for (let i = 0; i < s.N; i++) {
|
||
positions[i * 3] += dx;
|
||
if (positions[i * 3] > 400 * K) positions[i * 3] -= 800 * K;
|
||
}
|
||
s.geo.attributes.position.needsUpdate = true;
|
||
s.mat.opacity = 0.55 + bands.treble * 0.45;
|
||
},
|
||
teardown(s) {
|
||
if (!s) return;
|
||
if (s.points.parent) s.points.parent.remove(s.points);
|
||
s.geo.dispose();
|
||
s.mat.dispose();
|
||
},
|
||
},
|
||
lights: {
|
||
build(group, settings) {
|
||
const N = Math.floor(6 + 8 * settings.intensity);
|
||
const lights = [];
|
||
const palette = settings.palette || PALETTES.default;
|
||
for (let i = 0; i < N; i++) {
|
||
const color = palette[i % palette.length];
|
||
const geo = new T.PlaneGeometry(30 * K, 30 * K);
|
||
const mat = new T.MeshBasicMaterial({
|
||
color, transparent: true,
|
||
blending: T.AdditiveBlending, depthWrite: false,
|
||
});
|
||
const mesh = new T.Mesh(geo, mat);
|
||
mesh.renderOrder = -1;
|
||
mesh.position.set(
|
||
(Math.random() - 0.5) * 600 * K,
|
||
(Math.random() - 0.3) * 80 * K,
|
||
-FOG_START - Math.random() * (FOG_END - FOG_START) * 0.85
|
||
);
|
||
group.add(mesh);
|
||
lights.push({ mesh, geo, mat, baseScale: 1 + Math.random() * 0.5, phase: Math.random() * Math.PI * 2 });
|
||
}
|
||
return { lights };
|
||
},
|
||
update(s, bands, dt, t) {
|
||
for (const L of s.lights) {
|
||
const pulse = 1 + bands.bass * 1.5 + Math.sin(t * 1.5 + L.phase) * 0.2;
|
||
L.mesh.scale.set(L.baseScale * pulse, L.baseScale * pulse, 1);
|
||
L.mat.opacity = 0.55 + bands.treble * 0.4;
|
||
}
|
||
},
|
||
teardown(s) {
|
||
if (!s) return;
|
||
for (const L of s.lights) {
|
||
if (L.mesh.parent) L.mesh.parent.remove(L.mesh);
|
||
L.geo.dispose();
|
||
L.mat.dispose();
|
||
}
|
||
},
|
||
},
|
||
geometric: {
|
||
build(group, settings) {
|
||
const meshes = [];
|
||
const op = 0.45 + 0.25 * settings.intensity;
|
||
const ico = new T.Mesh(
|
||
new T.IcosahedronGeometry(30 * K, 1),
|
||
new T.MeshBasicMaterial({ color: 0x6080c0, wireframe: true, transparent: true, opacity: op, depthWrite: false }),
|
||
);
|
||
ico.position.set(-100 * K, 30 * K, -FOG_END * 0.65);
|
||
ico.renderOrder = -1;
|
||
group.add(ico);
|
||
meshes.push(ico);
|
||
const torus = new T.Mesh(
|
||
new T.TorusGeometry(22 * K, 4 * K, 6, 12),
|
||
new T.MeshBasicMaterial({ color: 0xc06080, wireframe: true, transparent: true, opacity: op * 0.9, depthWrite: false }),
|
||
);
|
||
torus.position.set(120 * K, 20 * K, -FOG_END * 0.75);
|
||
torus.renderOrder = -1;
|
||
group.add(torus);
|
||
meshes.push(torus);
|
||
return { meshes };
|
||
},
|
||
update(s, bands, dt) {
|
||
const speed = 0.2 + bands.mid * 0.4;
|
||
const pulse = 1 + bands.bass * 0.25;
|
||
for (const m of s.meshes) {
|
||
m.rotation.x += dt * speed * 0.3;
|
||
m.rotation.y += dt * speed * 0.4;
|
||
m.scale.setScalar(pulse);
|
||
}
|
||
},
|
||
teardown(s) {
|
||
if (!s) return;
|
||
for (const m of s.meshes) {
|
||
if (m.parent) m.parent.remove(m);
|
||
m.geometry.dispose();
|
||
m.material.dispose();
|
||
}
|
||
},
|
||
},
|
||
};
|
||
|
||
// (Re)mount the active bg style into bgGroup. Idempotent — tears the
|
||
// old style down first. Called from initScene, on style/intensity
|
||
// settings changes, and after kit-change scene rebuilds.
|
||
function _bgMountStyle() {
|
||
if (!scene || !T) return;
|
||
if (!bgGroup) {
|
||
bgGroup = new T.Group();
|
||
scene.add(bgGroup);
|
||
}
|
||
const style = BG_STYLES[_bgStyle] || BG_STYLES.off;
|
||
if (_bgState && _bgState._style && BG_STYLES[_bgState._style]) {
|
||
try { BG_STYLES[_bgState._style].teardown(_bgState.s); } catch (_) {}
|
||
}
|
||
let s = null;
|
||
try {
|
||
s = style.build(bgGroup, { intensity: Math.min(1, Math.max(0, fx.bgIntensity)), palette: activePalette });
|
||
} catch (e) {
|
||
console.warn('[Drum-Hwy3D] bg style build failed', e);
|
||
s = null;
|
||
}
|
||
_bgState = { _style: _bgStyle, s };
|
||
}
|
||
function _bgTeardownStyle() {
|
||
if (_bgState && _bgState._style && BG_STYLES[_bgState._style]) {
|
||
try { BG_STYLES[_bgState._style].teardown(_bgState.s); } catch (_) {}
|
||
}
|
||
_bgState = null;
|
||
bgGroup = null; // its children were removed by teardown; group dies with the scene
|
||
}
|
||
|
||
/* -- score-FX overlay (guitar drawScoreFx adapted to internal scoring) -- */
|
||
|
||
function _ensureFxCanvas() {
|
||
if (_fxCanvas || !highwayCanvas) return;
|
||
const parent = highwayCanvas.parentElement;
|
||
if (!parent) return;
|
||
const cur = parent.style.position || getComputedStyle(parent).position;
|
||
if (cur === 'static' || !cur) {
|
||
// Record the original inline value (usually '') so teardown
|
||
// can undo this layout mutation — same contract as the HUD.
|
||
_fxParentOrigPosition = parent.style.position;
|
||
parent.style.position = 'relative';
|
||
}
|
||
_fxCanvas = document.createElement('canvas');
|
||
_fxCanvas.className = 'drum-h3d-fx';
|
||
_fxCanvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:5';
|
||
parent.appendChild(_fxCanvas);
|
||
_fxCtx = _fxCanvas.getContext('2d');
|
||
_sizeFxCanvas();
|
||
}
|
||
function _sizeFxCanvas() {
|
||
if (!_fxCanvas || !highwayCanvas) return;
|
||
const w = highwayCanvas.clientWidth | 0, h = highwayCanvas.clientHeight | 0;
|
||
if (!w || !h) return;
|
||
const r = Math.min(window.devicePixelRatio || 1, 2);
|
||
if (_fxCanvas.width !== (w * r | 0) || _fxCanvas.height !== (h * r | 0)) {
|
||
_fxCanvas.width = w * r | 0;
|
||
_fxCanvas.height = h * r | 0;
|
||
}
|
||
// The draw path works in CSS pixels and scales the context by
|
||
// this — fixed font/line sizes stay physically consistent on
|
||
// HiDPI instead of shrinking with the backing store.
|
||
_fxDpr = r;
|
||
}
|
||
function _removeFxCanvas() {
|
||
if (_fxCanvas && _fxCanvas.parentNode) {
|
||
const parent = _fxCanvas.parentNode;
|
||
parent.removeChild(_fxCanvas);
|
||
// Restore position only if _ensureFxCanvas changed it (and
|
||
// nothing else — e.g. the HUD — still needs it; the HUD sets
|
||
// and restores its own copy of the same guard first).
|
||
if (_fxParentOrigPosition !== null) {
|
||
parent.style.position = _fxParentOrigPosition;
|
||
_fxParentOrigPosition = null;
|
||
}
|
||
}
|
||
_fxCanvas = null;
|
||
_fxCtx = null;
|
||
}
|
||
function _scoreSpawnPop(lane) {
|
||
const laneCfg = LANES[lane];
|
||
if (!laneCfg) return;
|
||
const nowMs = performance.now();
|
||
for (const pop of _scorePops) {
|
||
if (pop.active) continue;
|
||
pop.active = true;
|
||
pop.x = laneCfg.kind === 'kick' ? 0 : (LANE_X0 + lane * LANE_GAP);
|
||
pop.z = 0;
|
||
pop.bornMs = nowMs;
|
||
pop.text = '+1';
|
||
return;
|
||
}
|
||
}
|
||
function _scoreSpawnBurst(nowMs) {
|
||
for (const b of _scoreBursts) {
|
||
if (b.active) continue;
|
||
b.active = true;
|
||
b.bornMs = nowMs;
|
||
for (let j = 0; j < _SCORE_BURST_N; j++) {
|
||
const a = (j / _SCORE_BURST_N) * Math.PI * 2;
|
||
const sp = 2 + (j % 5) * 0.8;
|
||
b.px[j] = 0; b.py[j] = 0;
|
||
b.vx[j] = Math.cos(a) * sp;
|
||
b.vy[j] = Math.sin(a) * sp - 1.2;
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
// Called from the scoring paths: hit → pop (+ ring on 10-combo
|
||
// tier-ups, burst on 25/50/100 milestones); a broken streak of 3+
|
||
// → brief red wash.
|
||
function _scoreOnHit(lane) {
|
||
if (!fx.scoreFx) return;
|
||
const nowMs = performance.now();
|
||
_scoreSpawnPop(lane);
|
||
if (_streak > 0 && _streak % 10 === 0) _scoreRingMs = nowMs;
|
||
if (_streak === 25 || _streak === 50 || _streak === 100) _scoreSpawnBurst(nowMs);
|
||
}
|
||
function _scoreOnBreak(prevStreak) {
|
||
if (!fx.scoreFx) return;
|
||
if (prevStreak >= 3) _scoreBreakMs = performance.now();
|
||
}
|
||
function _drawScoreFx() {
|
||
if (!_fxCtx || !_fxCanvas || !cam) return;
|
||
// CSS-pixel coordinate space; the context transform applies the
|
||
// DPR so strokes/fonts render at their intended physical size.
|
||
const r = _fxDpr || 1;
|
||
const W = _fxCanvas.width / r, H = _fxCanvas.height / r;
|
||
_fxCtx.setTransform(r, 0, 0, r, 0, 0);
|
||
const nowMs = performance.now();
|
||
let anyPop = false;
|
||
for (const pop of _scorePops) if (pop.active) { anyPop = true; break; }
|
||
let anyBurst = false;
|
||
for (const b of _scoreBursts) if (b.active) { anyBurst = true; break; }
|
||
const ringAge = nowMs - _scoreRingMs;
|
||
const breakAge = nowMs - _scoreBreakMs;
|
||
// Cheap early-out: clear only while something was recently live.
|
||
if (!anyPop && !anyBurst && ringAge >= 600 && breakAge >= 350) {
|
||
if (_fxCanvas._dirty) { _fxCtx.clearRect(0, 0, W, H); _fxCanvas._dirty = false; }
|
||
return;
|
||
}
|
||
_fxCanvas._dirty = true;
|
||
if (!_probe) _probe = new T.Vector3();
|
||
const ctx = _fxCtx;
|
||
ctx.clearRect(0, 0, W, H);
|
||
ctx.save();
|
||
// Streak-break flicker: brief red wash over the panel.
|
||
if (breakAge < 350) {
|
||
ctx.fillStyle = '#ef4444';
|
||
ctx.globalAlpha = 0.10 * (1 - breakAge / 350);
|
||
ctx.fillRect(0, 0, W, H);
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
// Hit-line center in screen px — anchor for rings + bursts.
|
||
let cx = W / 2, cy = H * 0.72, centerOk = false;
|
||
_probe.set(0, 0.5 * K, 0);
|
||
_probe.project(cam);
|
||
if (_probe.z >= -1 && _probe.z <= 1) {
|
||
cx = (_probe.x * 0.5 + 0.5) * W;
|
||
cy = (-_probe.y * 0.5 + 0.5) * H;
|
||
centerOk = true;
|
||
}
|
||
// Combo tier-up ring (every 10-streak).
|
||
if (centerOk && ringAge < 600) {
|
||
const t = ringAge / 600;
|
||
const ease = 1 - Math.pow(1 - t, 2);
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, 20 + ease * Math.min(W, H) * 0.28, 0, Math.PI * 2);
|
||
ctx.strokeStyle = _streak >= 30 ? '#fde047' : '#86efac';
|
||
ctx.globalAlpha = 0.6 * (1 - t);
|
||
ctx.lineWidth = 3;
|
||
ctx.stroke();
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
// Milestone bursts (25/50/100 streak).
|
||
if (anyBurst && centerOk) {
|
||
for (const b of _scoreBursts) {
|
||
if (!b.active) continue;
|
||
const age = nowMs - b.bornMs;
|
||
if (age >= 900) { b.active = false; continue; }
|
||
const t = age / 900;
|
||
ctx.globalAlpha = 1 - t;
|
||
for (let j = 0; j < _SCORE_BURST_N; j++) {
|
||
b.px[j] += b.vx[j];
|
||
b.py[j] += b.vy[j];
|
||
b.vy[j] += 0.08;
|
||
ctx.fillStyle = (j & 1) ? '#fde047' : '#86efac';
|
||
ctx.fillRect(cx + b.px[j] - 2, cy + b.py[j] - 2, 4, 4);
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
}
|
||
// "+1" pops: rise off the struck lane and fade over the back half.
|
||
if (anyPop) {
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
for (const pop of _scorePops) {
|
||
if (!pop.active) continue;
|
||
const age = nowMs - pop.bornMs;
|
||
if (age >= 800) { pop.active = false; continue; }
|
||
_probe.set(pop.x, 2 * K, pop.z);
|
||
_probe.project(cam);
|
||
if (_probe.z < -1 || _probe.z > 1) continue;
|
||
const t = age / 800;
|
||
const sx = (_probe.x * 0.5 + 0.5) * W;
|
||
const sy = (-_probe.y * 0.5 + 0.5) * H - t * 30;
|
||
ctx.globalAlpha = t < 0.4 ? 1 : 1 - (t - 0.4) / 0.6;
|
||
ctx.font = 'bold 15px system-ui, sans-serif';
|
||
ctx.lineWidth = 4;
|
||
ctx.strokeStyle = 'rgba(0,0,0,0.8)';
|
||
ctx.strokeText(pop.text, sx, sy);
|
||
ctx.fillStyle = '#86efac';
|
||
ctx.fillText(pop.text, sx, sy);
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
/* -- one-time scene setup --------------------------------------- */
|
||
|
||
// Emissive multiplier from the Glow slider — 0.5 is neutral (the
|
||
// stock look), so `base * _glowMul()` leaves defaults byte-identical.
|
||
function _glowMul() {
|
||
return Math.min(1, Math.max(0, fx.glow)) * 2;
|
||
}
|
||
|
||
// Cinematic lighting: dimmer ambient + stronger key light, same
|
||
// rebalance idea as highway_3d's _applyCinematic. Off = the plugin's
|
||
// pre-parity values.
|
||
function _applyCinematic() {
|
||
if (!ambLight || !dirLight) return;
|
||
ambLight.intensity = fx.cinematic ? 0.3 : 0.4;
|
||
dirLight.intensity = fx.cinematic ? 1.2 : 1.0;
|
||
}
|
||
|
||
// Procedural "studio" environment for image-based lighting: a dark
|
||
// room shell with three emissive light strips (cool overhead key,
|
||
// warm left fill, cool right accent), baked through PMREMGenerator.
|
||
// No addon needed (PMREMGenerator ships in the core three build) —
|
||
// RoomEnvironment is NOT vendored, hence this hand-rolled stand-in.
|
||
// Returns the PMREM render target ({texture}) or null; caller owns
|
||
// disposing the RT.
|
||
function _makeStudioEnv(ThreeLib, renderer) {
|
||
if (!renderer) return null;
|
||
try {
|
||
const envScene = new ThreeLib.Scene();
|
||
const own = []; // geometries/materials to free after baking
|
||
const shellGeo = new ThreeLib.BoxGeometry(100, 100, 100);
|
||
const shellMat = new ThreeLib.MeshBasicMaterial({ color: 0x11131c, side: ThreeLib.BackSide });
|
||
envScene.add(new ThreeLib.Mesh(shellGeo, shellMat));
|
||
own.push(shellGeo, shellMat);
|
||
const strip = (w, h, hex, intensity, x, y, z, rx, ry) => {
|
||
const g = new ThreeLib.PlaneGeometry(w, h);
|
||
// DoubleSide: orientation-proof — the bake only runs once,
|
||
// so the extra faces are free insurance.
|
||
const m = new ThreeLib.MeshBasicMaterial({ color: hex, side: ThreeLib.DoubleSide });
|
||
m.color.multiplyScalar(intensity);
|
||
const mesh = new ThreeLib.Mesh(g, m);
|
||
mesh.position.set(x, y, z);
|
||
mesh.rotation.set(rx, ry, 0);
|
||
envScene.add(mesh);
|
||
own.push(g, m);
|
||
};
|
||
strip(60, 8, 0xdfe8ff, 18, 0, 45, 0, Math.PI / 2, 0); // overhead key
|
||
strip(30, 50, 0xffd9a8, 6, -48, 5, 0, 0, Math.PI / 2); // warm left wall
|
||
strip(30, 50, 0x9fd8ff, 6, 48, 5, 0, 0, -Math.PI / 2); // cool right wall
|
||
const pmrem = new ThreeLib.PMREMGenerator(renderer);
|
||
const rt = pmrem.fromScene(envScene, 0.04);
|
||
pmrem.dispose();
|
||
for (const r of own) r.dispose();
|
||
return rt;
|
||
} catch (e) {
|
||
console.warn('[Drum-Hwy3D] studio env failed (continuing without IBL)', e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function _disposeEnv() {
|
||
if (_envRT) {
|
||
try { _envRT.dispose(); } catch (_) {}
|
||
_envRT = null;
|
||
}
|
||
}
|
||
|
||
// Apply the active theme to the live scene (background/fog/clear +
|
||
// floor + lane stripes). Notes keep their piece palette — themes own
|
||
// the SCENE, palettes own the KIT.
|
||
function _applyTheme() {
|
||
const c = _bgThemeColors(_theme);
|
||
if (scene) {
|
||
if (scene.background && scene.background.isColor) scene.background.setHex(c.clear);
|
||
if (scene.fog) scene.fog.color.setHex(c.fog);
|
||
}
|
||
if (ren) ren.setClearColor(c.clear, 1);
|
||
if (mFloor) mFloor.color.setHex(c.board);
|
||
_applyStripeColors();
|
||
}
|
||
|
||
// Lane stripe colors + base opacity (theme lane pair or the stock
|
||
// teal/blue; vibrancy drives how strongly they read). draw()'s
|
||
// approach highlight adds on top of the vibrancy base each frame.
|
||
function _stripeBaseOpacity() {
|
||
return 0.12 + 0.24 * Math.min(1, Math.max(0, fx.vibrancy));
|
||
}
|
||
function _applyStripeColors() {
|
||
const c = _bgThemeColors(_theme);
|
||
const pair = (c.lane != null && c.laneDim != null)
|
||
? [c.laneDim, c.lane]
|
||
: [0x2d5476, 0x42759d];
|
||
for (let i = 0; i < laneStripeMats.length; i++) {
|
||
laneStripeMats[i].color.setHex(pair[i % 2]);
|
||
}
|
||
}
|
||
|
||
function initScene() {
|
||
scene = new T.Scene();
|
||
const themeCols = _bgThemeColors(_theme);
|
||
scene.background = new T.Color(themeCols.clear);
|
||
scene.fog = new T.Fog(themeCols.fog, FOG_START, FOG_END);
|
||
if (ren) ren.setClearColor(themeCols.clear, 1);
|
||
|
||
// Studio environment map: emissive-strip room baked through
|
||
// PMREM so MeshStandardMaterial metalness/roughness finally get
|
||
// specular payoff (the cymbals' 0.85 metalness reads as metal).
|
||
// Rebuilt here so the kit-change renderer recreation gets a
|
||
// fresh one; the old RT is disposed by _disposeScene/teardown.
|
||
_envRT = _makeStudioEnv(T, ren);
|
||
if (_envRT) scene.environment = _envRT.texture;
|
||
|
||
cam = new T.PerspectiveCamera(60, 16 / 9, 0.1, 1000 * K);
|
||
positionCamera();
|
||
|
||
lights = new T.Group();
|
||
ambLight = new T.AmbientLight(0xffffff, 0.4);
|
||
dirLight = new T.DirectionalLight(0xffffff, 1.0);
|
||
dirLight.position.set(-50 * K, 200 * K, 200 * K);
|
||
lights.add(ambLight);
|
||
lights.add(dirLight);
|
||
scene.add(lights);
|
||
_applyCinematic();
|
||
|
||
// Floor plane — wide darkened quad under the lanes.
|
||
// Use Math.max(1, LANE_COUNT) so a kick-only kit (LANE_COUNT=0)
|
||
// still produces a floor at least as wide as the kick bar (KICK_W).
|
||
const floorW = Math.max(1, LANE_COUNT) * LANE_GAP + 8 * K;
|
||
const floorD = (AHEAD + BEHIND + 0.5) * TS + 60 * K;
|
||
_gFloor = new T.PlaneGeometry(floorW, floorD);
|
||
const gFloor = _gFloor;
|
||
mFloor = new T.MeshStandardMaterial({
|
||
color: themeCols.board,
|
||
// 0.7 (was 0.95): just enough sheen to catch the env strips
|
||
// without turning the floor into a mirror.
|
||
roughness: 0.7,
|
||
metalness: 0.0,
|
||
envMapIntensity: 0.35,
|
||
});
|
||
const floor = new T.Mesh(gFloor, mFloor);
|
||
floor.rotation.x = -Math.PI / 2;
|
||
floor.position.set(0, -0.3 * K, -floorD / 2 + BEHIND * TS);
|
||
scene.add(floor);
|
||
|
||
// Build lane stripes / dividers (static).
|
||
buildLanes(floorW, floorD);
|
||
|
||
// Build the kit silhouette backboard.
|
||
kitMapGroup = new T.Group();
|
||
scene.add(kitMapGroup);
|
||
rebuildKitMap();
|
||
|
||
// Hit-line bar in front of the camera, perpendicular to scroll.
|
||
_gHitBar = new T.BoxGeometry(floorW, 0.6 * K, 0.6 * K);
|
||
const gHit = _gHitBar;
|
||
const mHit = new T.MeshStandardMaterial({
|
||
color: 0xffffff,
|
||
emissive: 0xffffff,
|
||
emissiveIntensity: 0.9 * _glowMul(),
|
||
roughness: 0.3,
|
||
metalness: 0.6,
|
||
envMapIntensity: 1.0,
|
||
});
|
||
_hitBarMat = mHit;
|
||
const hitBar = new T.Mesh(gHit, mHit);
|
||
hitBar.position.set(0, 0.3 * K, 0);
|
||
scene.add(hitBar);
|
||
|
||
// (Lane-flash boxes removed — hit/miss feedback now lives on
|
||
// the chart note itself via a per-note material clone in
|
||
// placeNote. Simpler visual: green note = you struck it,
|
||
// red note = it scrolled past unstruck.)
|
||
|
||
// Notes group is rebuilt every frame — pooling could come later
|
||
// but at <100 visible notes per frame the GC cost is negligible.
|
||
notesGroup = new T.Group();
|
||
scene.add(notesGroup);
|
||
|
||
// Shared geometry.
|
||
gDrumDisc = new T.CylinderGeometry(DISC_R_BASE, DISC_R_BASE, DISC_H, 32);
|
||
gCymbalGem = new T.CylinderGeometry(CYMBAL_R * 0.15, CYMBAL_R, CYMBAL_H, 8, 1, false);
|
||
gKickBar = new T.BoxGeometry(KICK_W, KICK_H, KICK_D);
|
||
gAccentRing = new T.RingGeometry(DISC_R_BASE * 1.15, DISC_R_BASE * 1.4, 32);
|
||
gGhostRing = new T.RingGeometry(DISC_R_BASE * 0.55, DISC_R_BASE * 0.75, 24);
|
||
gSnareStripe = new T.BoxGeometry(DISC_R_BASE * 1.9, 0.25 * K, 0.4 * K);
|
||
gBellDot = new T.CircleGeometry(CYMBAL_R * 0.3, 16);
|
||
gFlamGrace = new T.CylinderGeometry(
|
||
DISC_R_BASE * FLAM_GRACE_SCALE,
|
||
DISC_R_BASE * FLAM_GRACE_SCALE,
|
||
DISC_H,
|
||
24,
|
||
);
|
||
// Open hi-hat: thin warm ring around the gem (closed = bare gem).
|
||
gOpenRing = new T.RingGeometry(CYMBAL_R * 1.05, CYMBAL_R * 1.25, 24);
|
||
mOpenRing = new T.MeshBasicMaterial({
|
||
color: 0xffe9a8,
|
||
transparent: true,
|
||
opacity: 0.85,
|
||
side: T.DoubleSide,
|
||
});
|
||
|
||
// Hit sparks (PORTED FROM highway_3d): pooled additive Points
|
||
// cloud, burst on verified hits, integrated in draw().
|
||
_sparkPos = new Float32Array(_SPARK_N * 3); _sparkCol = new Float32Array(_SPARK_N * 3);
|
||
_sparkVel = new Float32Array(_SPARK_N * 3); _sparkLife = new Float32Array(_SPARK_N);
|
||
{
|
||
const sg = new T.BufferGeometry();
|
||
sg.setAttribute('position', new T.BufferAttribute(_sparkPos, 3).setUsage(T.DynamicDrawUsage));
|
||
sg.setAttribute('color', new T.BufferAttribute(_sparkCol, 3).setUsage(T.DynamicDrawUsage));
|
||
const sm = new T.PointsMaterial({ size: 1.0 * K, vertexColors: true, transparent: true, opacity: 0.8, depthWrite: false, blending: T.AdditiveBlending, sizeAttenuation: true });
|
||
_sparkPts = new T.Points(sg, sm); _sparkPts.frustumCulled = false; _sparkPts.renderOrder = 8;
|
||
scene.add(_sparkPts);
|
||
}
|
||
|
||
// Lane-flash quads: one pooled additive quad per hand lane plus a
|
||
// full-width one for the kick, parked at the hit line with the
|
||
// shared gauss falloff. _applyLaneFlashes drives color/opacity —
|
||
// this resurrects the lane-flash feedback that was removed when
|
||
// note recoloring landed, now as real light instead of boxes.
|
||
_gaussTex = _makeGaussTex(T, 128, 0.28);
|
||
_laneFlashQuads = [];
|
||
for (let i = 0; i < LANE_COUNT; i++) {
|
||
const g = new T.PlaneGeometry(LANE_GAP * 0.9, 12 * K);
|
||
const m = new T.MeshBasicMaterial({
|
||
color: 0xffffff, map: _gaussTex, transparent: true, opacity: 0,
|
||
blending: T.AdditiveBlending, depthWrite: false,
|
||
});
|
||
const quad = new T.Mesh(g, m);
|
||
quad.rotation.x = -Math.PI / 2;
|
||
quad.position.set(LANE_X0 + i * LANE_GAP, 0.06 * K, 0);
|
||
quad.renderOrder = 7;
|
||
scene.add(quad);
|
||
_laneFlashQuads.push(quad);
|
||
}
|
||
{
|
||
const g = new T.PlaneGeometry(floorW * 0.92, 8 * K);
|
||
const m = new T.MeshBasicMaterial({
|
||
color: KICK_COLOR, map: _gaussTex, transparent: true, opacity: 0,
|
||
blending: T.AdditiveBlending, depthWrite: false,
|
||
});
|
||
_kickFlashQuad = new T.Mesh(g, m);
|
||
_kickFlashQuad.rotation.x = -Math.PI / 2;
|
||
_kickFlashQuad.position.set(0, 0.05 * K, 0);
|
||
_kickFlashQuad.renderOrder = 7;
|
||
scene.add(_kickFlashQuad);
|
||
}
|
||
// Kick pulse floor wash — wide, faint, pulsed in draw().
|
||
{
|
||
const g = new T.PlaneGeometry(floorW, 40 * K);
|
||
const m = new T.MeshBasicMaterial({
|
||
color: KICK_COLOR, map: _gaussTex, transparent: true, opacity: 0,
|
||
blending: T.AdditiveBlending, depthWrite: false,
|
||
});
|
||
_floorFlash = new T.Mesh(g, m);
|
||
_floorFlash.rotation.x = -Math.PI / 2;
|
||
_floorFlash.position.set(0, 0.04 * K, -6 * K);
|
||
_floorFlash.renderOrder = 6;
|
||
scene.add(_floorFlash);
|
||
}
|
||
|
||
// Halo and ghost ring materials — additive emissive, palette-agnostic.
|
||
mAccentRing = new T.MeshBasicMaterial({
|
||
color: 0xffffff,
|
||
transparent: true,
|
||
opacity: 0.85,
|
||
side: T.DoubleSide,
|
||
});
|
||
mGhostRing = new T.MeshBasicMaterial({
|
||
color: 0xffffff,
|
||
transparent: true,
|
||
opacity: 0.55,
|
||
side: T.DoubleSide,
|
||
});
|
||
mSnareStripe = new T.MeshStandardMaterial({
|
||
color: 0xffffff,
|
||
emissive: 0xffffff,
|
||
emissiveIntensity: 0.5 * _glowMul(),
|
||
roughness: 0.3,
|
||
});
|
||
mBellDot = new T.MeshBasicMaterial({
|
||
color: 0xffeecc,
|
||
transparent: true,
|
||
opacity: 0.95,
|
||
side: T.DoubleSide,
|
||
});
|
||
|
||
rebuildPaletteMaterials();
|
||
_applyVibrancy(); // halo/ghost ring opacities from the slider
|
||
|
||
// Background ambience behind everything (renderOrder -1).
|
||
_bgMountStyle();
|
||
}
|
||
|
||
function positionCamera() {
|
||
// cameraAngle: 0 = down the lanes (low + forward), 1 = top-down.
|
||
const a = settings.cameraAngle;
|
||
// Camera height ramps from low-ish to high; depth pulls back as we
|
||
// tilt down so the lanes still fit the frame.
|
||
const h = (45 + 180 * a) * K;
|
||
const d = (60 + 60 * (1 - a)) * K;
|
||
// Remember the unpulsed pose — the kick pulse in draw() dips
|
||
// cam.position.y relative to this and must not accumulate.
|
||
_camBaseH = h;
|
||
_camBaseD = d;
|
||
cam.position.set(0, h, d);
|
||
cam.lookAt(0, 0, -AHEAD * TS * 0.45);
|
||
}
|
||
|
||
/**
|
||
* Camera Director bridge for THIS panel — delegates to the pure, unit-
|
||
* tested _resolveFreeCam / _ssApi (resolver block above the factory).
|
||
* Reads the live globals: per-panel map __h3dCamCtlPanels → this panel's
|
||
* camera, else the global __h3dCamCtl, else null (stock framing).
|
||
* @param {HTMLCanvasElement} canvas this panel's highway canvas
|
||
* @returns {object|null} the resolved free-camera bridge, or null
|
||
*/
|
||
function _freeCamFor(canvas) {
|
||
return _resolveFreeCam(canvas, _ssApi(), window.__h3dCamCtlPanels, window.__h3dCamCtl);
|
||
}
|
||
|
||
// Per-frame camera write: static base pose (positionCamera) + kick-pulse Y
|
||
// dip, then layer Camera Director free-cam offsets (dolly/height/orbit on
|
||
// the camera-from-target vector; pan/pitch on the look target). Runs every
|
||
// frame so a live free-cam drag is smooth; allocation-free; NaN-safe; a
|
||
// null/disabled bridge reproduces the stock static+pulse pose exactly.
|
||
function applyCamera() {
|
||
if (_camBaseH == null) return; // before first positionCamera()
|
||
const _dip = (_kickPulse > 0.001) ? (0.8 * K * _kickPulse * fx.hitFx) : 0;
|
||
let _cx = 0, _cy = _camBaseH - _dip, _cz = _camBaseD;
|
||
let _lx = 0, _ly = 0, _lz = -AHEAD * TS * 0.45;
|
||
const _fc = _freeCamFor(highwayCanvas);
|
||
if (_fc && _fc.enabled) {
|
||
const _dm = Number.isFinite(_fc.distMul) ? _fc.distMul : 1;
|
||
const _hm = Number.isFinite(_fc.heightMul) ? _fc.heightMul : 1;
|
||
const _yaw = Number.isFinite(_fc.yaw) ? _fc.yaw : 0;
|
||
let _vx = _cx - _lx, _vy = _cy - _ly, _vz = _cz - _lz;
|
||
_vx *= _dm; _vy *= _dm; _vz *= _dm; // dolly (zoom)
|
||
_vy *= _hm; // height
|
||
const _cyw = Math.cos(_yaw), _syw = Math.sin(_yaw);
|
||
const _rx = _vx * _cyw - _vz * _syw, _rz = _vx * _syw + _vz * _cyw; // orbit around Y
|
||
_cx = _lx + _rx; _cy = _ly + _vy; _cz = _lz + _rz;
|
||
const _px = Number.isFinite(_fc.panX) ? _fc.panX : 0;
|
||
const _py = Number.isFinite(_fc.panY) ? _fc.panY : 0;
|
||
const _pt = Number.isFinite(_fc.pitch) ? _fc.pitch : 0;
|
||
_lx += _px * K; _ly += (_pt + _py) * K;
|
||
}
|
||
cam.position.set(_cx, _cy, _cz);
|
||
cam.lookAt(_lx, _ly, _lz);
|
||
}
|
||
|
||
function buildLanes(_floorW, floorD) {
|
||
laneGroup = new T.Group();
|
||
laneStripeMats = [];
|
||
// Alternating lane stripes for the 7 hand lanes — theme lane
|
||
// pair when the active theme defines one, else the stock
|
||
// teal/blue (a notch darker than highway_3d so discs pop).
|
||
// _applyStripeColors() retints these on a live theme switch.
|
||
const tc = _bgThemeColors(_theme);
|
||
const colors = (tc.lane != null && tc.laneDim != null)
|
||
? [tc.laneDim, tc.lane]
|
||
: [0x2d5476, 0x42759d];
|
||
for (let i = 0; i < LANE_COUNT; i++) {
|
||
const x = LANE_X0 + i * LANE_GAP;
|
||
const g = new T.PlaneGeometry(LANE_GAP * 0.96, floorD);
|
||
const m = new T.MeshBasicMaterial({
|
||
color: colors[i % 2],
|
||
transparent: true,
|
||
opacity: _stripeBaseOpacity(),
|
||
});
|
||
const stripe = new T.Mesh(g, m);
|
||
stripe.rotation.x = -Math.PI / 2;
|
||
stripe.position.set(x, -0.25 * K, -floorD / 2 + BEHIND * TS);
|
||
laneGroup.add(stripe);
|
||
// Kept for the per-frame approach highlight (rebuildNotes
|
||
// raises a lane's stripe opacity as its next note nears the
|
||
// hit line).
|
||
laneStripeMats.push(m);
|
||
}
|
||
scene.add(laneGroup);
|
||
}
|
||
|
||
function rebuildKitMap() {
|
||
// Clear existing children.
|
||
while (kitMapGroup.children.length) {
|
||
const c = kitMapGroup.children.pop();
|
||
if (c.geometry) c.geometry.dispose();
|
||
if (c.material) {
|
||
if (Array.isArray(c.material)) c.material.forEach(m => m.dispose());
|
||
else c.material.dispose();
|
||
}
|
||
}
|
||
|
||
// Backboard plane far down the highway where guitar's fretboard
|
||
// would terminate. Each lane's piece silhouette sits over its
|
||
// lane's X position so the kit reads as a top-down map.
|
||
const farZ = -AHEAD * TS - 10 * K;
|
||
const boardW = LANE_COUNT * LANE_GAP + 6 * K;
|
||
const boardH = 28 * K;
|
||
const gBoard = new T.PlaneGeometry(boardW, boardH);
|
||
const mBoard = new T.MeshBasicMaterial({
|
||
color: 0x0a1422,
|
||
transparent: true,
|
||
opacity: 0.85,
|
||
});
|
||
const board = new T.Mesh(gBoard, mBoard);
|
||
board.position.set(0, boardH / 2 + 2 * K, farZ);
|
||
kitMapGroup.add(board);
|
||
|
||
// Per-piece silhouettes — circle outlines (drums/cymbals) for
|
||
// each HAND lane. LANE_COUNT excludes the kick (which is
|
||
// rendered as a wide rectangle below instead).
|
||
for (let i = 0; i < LANE_COUNT; i++) {
|
||
const lane = LANES[i];
|
||
if (!lane) continue;
|
||
const color = activePalette[lane.paletteIdx];
|
||
const x = LANE_X0 + i * LANE_GAP;
|
||
const r = lane.kind === 'cymbal' ? 3.2 * K : 2.6 * K;
|
||
const segs = lane.kind === 'cymbal' ? 8 : 32;
|
||
const gOutline = new T.RingGeometry(r * 0.92, r, segs);
|
||
const mOutline = new T.MeshBasicMaterial({
|
||
color,
|
||
transparent: true,
|
||
opacity: 0.85,
|
||
side: T.DoubleSide,
|
||
});
|
||
const ring = new T.Mesh(gOutline, mOutline);
|
||
ring.position.set(x, boardH * 0.55 + 2 * K, farZ + 0.1 * K);
|
||
kitMapGroup.add(ring);
|
||
}
|
||
// Kick rectangle — only rendered when the active kit includes
|
||
// a kick lane (LANES.length > LANE_COUNT means a kick is appended
|
||
// at the end). Otherwise no bar appears, kit silhouette omits it.
|
||
const hasKick = LANES.length > LANE_COUNT;
|
||
if (hasKick) {
|
||
const kickG = new T.PlaneGeometry(boardW * 0.86, 2.0 * K);
|
||
const kickM = new T.MeshBasicMaterial({
|
||
color: KICK_COLOR,
|
||
transparent: true,
|
||
opacity: 0.85,
|
||
});
|
||
const kick = new T.Mesh(kickG, kickM);
|
||
kick.position.set(0, boardH * 0.18 + 2 * K, farZ + 0.1 * K);
|
||
kitMapGroup.add(kick);
|
||
}
|
||
}
|
||
|
||
/* -- per-frame note rendering ----------------------------------- */
|
||
|
||
function buildNoteMesh(lane, variant, open) {
|
||
const laneCfg = LANES[lane];
|
||
const group = new T.Group();
|
||
|
||
if (laneCfg.kind === 'kick') {
|
||
const bar = new T.Mesh(gKickBar, mKick);
|
||
if (variant === 'accent') bar.scale.setScalar(ACCENT_SCALE);
|
||
group.add(bar);
|
||
if (variant === 'accent') {
|
||
// Bright leading edge bar (a thin white strip on the
|
||
// camera-facing edge of the kick). Geometry + material
|
||
// are per-note — flag both as transient so disposeMeshTree
|
||
// releases them when the note recycles.
|
||
const edgeGeo = new T.BoxGeometry(KICK_W * 0.96, KICK_H * 1.05, 0.6 * K);
|
||
const edgeMat = new T.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.9 });
|
||
edgeGeo.userData.transient = true;
|
||
edgeMat.userData.transient = true;
|
||
const edge = new T.Mesh(edgeGeo, edgeMat);
|
||
edge.position.set(0, 0, KICK_D * 0.5);
|
||
group.add(edge);
|
||
}
|
||
return group;
|
||
}
|
||
|
||
if (laneCfg.kind === 'drum') {
|
||
let scale = 1.0;
|
||
if (variant === 'ghost') scale = GHOST_SCALE;
|
||
else if (variant === 'accent') scale = ACCENT_SCALE;
|
||
|
||
if (variant === 'ghost') {
|
||
// Hollow ring instead of disc — easier to read as "quiet".
|
||
// Material is per-note (palette-tinted but transparent) —
|
||
// flag it transient so disposeMeshTree releases it.
|
||
const ghostMat = new T.MeshBasicMaterial({
|
||
color: activePalette[laneCfg.paletteIdx],
|
||
transparent: true,
|
||
// Vibrancy-driven, evaluated per build — notesGroup is
|
||
// rebuilt every frame, so slider changes apply live.
|
||
// (The shared mGhostRing is only the hit/miss-tint
|
||
// template, not what pending ghosts render with.)
|
||
opacity: 0.5 + 0.29 * Math.min(1, Math.max(0, fx.vibrancy)),
|
||
side: T.DoubleSide,
|
||
});
|
||
ghostMat.userData.transient = true;
|
||
ghostMat.userData.isGhostRing = true;
|
||
const ring = new T.Mesh(gGhostRing, ghostMat);
|
||
ring.rotation.x = -Math.PI / 2;
|
||
ring.scale.setScalar(scale);
|
||
group.add(ring);
|
||
} else {
|
||
const disc = new T.Mesh(gDrumDisc, mDrumByLane[lane]);
|
||
disc.scale.set(scale, 1, scale);
|
||
group.add(disc);
|
||
}
|
||
|
||
if (laneCfg.subKind === 'snare' && variant !== 'ghost') {
|
||
const stripe = new T.Mesh(gSnareStripe, mSnareStripe);
|
||
stripe.position.set(0, DISC_H * 0.55, 0);
|
||
stripe.scale.set(scale, 1, scale);
|
||
group.add(stripe);
|
||
}
|
||
|
||
if (variant === 'accent') {
|
||
const halo = new T.Mesh(gAccentRing, mAccentRing);
|
||
halo.rotation.x = -Math.PI / 2;
|
||
halo.position.y = DISC_H * 0.6;
|
||
halo.scale.setScalar(scale);
|
||
group.add(halo);
|
||
}
|
||
return group;
|
||
}
|
||
|
||
if (laneCfg.kind === 'cymbal') {
|
||
// Cymbal "gem" — flattened bipyramid (truncated cone). The
|
||
// top-radius/bottom-radius ratio gives a faceted gem look.
|
||
let scale = 1.0;
|
||
if (variant === 'ghost') scale = GHOST_SCALE;
|
||
else if (variant === 'accent') scale = ACCENT_SCALE;
|
||
|
||
const gem = new T.Mesh(gCymbalGem, mCymbalByLane[lane]);
|
||
gem.scale.setScalar(scale);
|
||
group.add(gem);
|
||
|
||
if (laneCfg.subKind === 'hihat' && open) {
|
||
// Open-hat: a thin warm ring around the gem (closed-hat
|
||
// stays the bare gem) — mirrors standard drum notation's
|
||
// "o" marking. Shared geometry/material: zero per-frame
|
||
// GC despite the every-frame notesGroup rebuild.
|
||
const ring = new T.Mesh(gOpenRing, mOpenRing);
|
||
ring.rotation.x = -Math.PI / 2;
|
||
ring.position.y = CYMBAL_H * 0.55;
|
||
ring.scale.setScalar(scale);
|
||
group.add(ring);
|
||
}
|
||
if (laneCfg.subKind === 'ride' && variant === 'bell') {
|
||
const dot = new T.Mesh(gBellDot, mBellDot);
|
||
dot.rotation.x = -Math.PI / 2;
|
||
dot.position.y = CYMBAL_H * 0.55;
|
||
group.add(dot);
|
||
}
|
||
if (variant === 'accent') {
|
||
const halo = new T.Mesh(gAccentRing, mAccentRing);
|
||
halo.rotation.x = -Math.PI / 2;
|
||
halo.position.y = CYMBAL_H * 0.55;
|
||
halo.scale.setScalar(scale * 1.05);
|
||
group.add(halo);
|
||
}
|
||
return group;
|
||
}
|
||
|
||
return group;
|
||
}
|
||
|
||
// Cache: converted drum_tab.hits → [{lane, t, variant}] sorted by t.
|
||
// Re-derive only when the bundle's drumTab object identity changes
|
||
// (one allocation per song-load, not per frame).
|
||
let _cachedDrumTabKey = null;
|
||
let _cachedRealNotes = null;
|
||
|
||
function _realNotesFromDrumTab(drumTab) {
|
||
const hits = (drumTab && Array.isArray(drumTab.hits)) ? drumTab.hits : [];
|
||
const out = [];
|
||
for (const h of hits) {
|
||
const lane = PIECE_TO_LANE[h.p];
|
||
if (lane === undefined) continue; // unknown piece — skip silently
|
||
// `open` is orthogonal to variant so an accented or flammed
|
||
// open hat keeps both cues (ring + halo / grace disc).
|
||
out.push({ lane, t: +h.t || 0, variant: _variantForHit(h), open: h.p === 'hh_open' });
|
||
}
|
||
out.sort((a, b) => a.t - b.t);
|
||
return out;
|
||
}
|
||
|
||
function rebuildNotes(bundle) {
|
||
// Clear the existing notes group — for <100 simultaneously visible
|
||
// notes the GC churn is fine and the code stays simple.
|
||
while (notesGroup.children.length) {
|
||
const c = notesGroup.children.pop();
|
||
disposeMeshTree(c);
|
||
}
|
||
|
||
// Approach highlight: the visible-window walks below record each
|
||
// hand lane's strongest proximity; _applyApproach() then raises
|
||
// that lane's stripe so the eye is drawn to where the next hit
|
||
// lands.
|
||
if (_laneProx.length !== LANE_COUNT) _laneProx = new Array(LANE_COUNT).fill(0);
|
||
else _laneProx.fill(0);
|
||
|
||
// Real-data path: when the active sloppak ships a drum_tab,
|
||
// bundle.drumTab is populated by static/highway.js (slopsmith
|
||
// drums-from-scratch PR). Otherwise fall back to the hardcoded
|
||
// demo loop so the viz still has something to show on songs
|
||
// without drums or in standalone playback.
|
||
const drumTab = bundle && bundle.drumTab;
|
||
if (drumTab && Array.isArray(drumTab.hits)) {
|
||
if (_cachedDrumTabKey !== drumTab) {
|
||
_cachedDrumTabKey = drumTab;
|
||
_cachedRealNotes = _realNotesFromDrumTab(drumTab);
|
||
// New chart — reset scoring so combo/accuracy don't
|
||
// carry over from the previous song. Mirrors the v3
|
||
// 2D plugin's bundle.isReady edge-detect behaviour.
|
||
_resetScoring();
|
||
}
|
||
const t = (bundle && typeof bundle.currentTime === 'number') ? bundle.currentTime : 0;
|
||
// Snapshot for the MIDI handler — has to run BEFORE the
|
||
// visible-window walk so an in-flight pad hit fired
|
||
// between frames scores against the same view the user
|
||
// sees. _updateMissed also reads these to retire passed
|
||
// notes that the user didn't strike.
|
||
_latestNotes = _cachedRealNotes;
|
||
_latestTime = t;
|
||
// Only accumulate misses when a MIDI device is connected.
|
||
// With no device selected the user cannot hit notes, so
|
||
// counting passes as misses would corrupt the HUD accuracy.
|
||
// When a device was just connected, reset scoring first so
|
||
// notes that elapsed while no device was connected are not
|
||
// retroactively counted as misses.
|
||
// Gate on _midiHandle (the live wired session), NOT _midiInput: the
|
||
// latter is set as soon as a device is picked, but the async
|
||
// mi.open() may still be pending (slow / permission prompt), during
|
||
// which no events can arrive — counting passes then would bank false
|
||
// misses. _midiHandle is truthy only after a handle is opened+wired.
|
||
if (_midiHandle) {
|
||
if (_midiJustConnected) {
|
||
_midiJustConnected = false;
|
||
_resetScoring();
|
||
// Floor the sweep at the connect frame: without this
|
||
// the _updateMissed call right below would bank the
|
||
// last ~2 s of notes as misses even though no input
|
||
// could have arrived for them yet.
|
||
_missSweepFloor = t;
|
||
}
|
||
// Seek-back past the floor makes those notes playable
|
||
// again — let them count.
|
||
if (t < _missSweepFloor) _missSweepFloor = t;
|
||
_updateMissed(t);
|
||
}
|
||
// Linear walk with early break — notes are sorted by time so
|
||
// the first note beyond AHEAD ends the visible window.
|
||
// Hit / missed notes get a tag passed through to placeNote
|
||
// so it can render them differently (hit → green tint, miss →
|
||
// red tint). The match is keyed on (t, lane), same as hit
|
||
// detection — multiple piece-ids on a lane (crash_l +
|
||
// crash_r) share the visual feedback.
|
||
for (const note of _cachedRealNotes) {
|
||
const dt = note.t - t;
|
||
if (dt > AHEAD) break;
|
||
if (dt < -BEHIND) continue;
|
||
const key = _hitKey(note.t, note.lane);
|
||
const status = _hitKeys.has(key) ? 'hit' :
|
||
_missedKeys.has(key) ? 'missed' :
|
||
'pending';
|
||
placeNote(note, dt, status);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Demo-loop fallback: drumTab is absent or empty. If we just
|
||
// transitioned away from a real chart, clear stale scoring state
|
||
// so MIDI hits (and the HUD) don't score against the old notes.
|
||
if (_cachedDrumTabKey !== null) {
|
||
_cachedDrumTabKey = null;
|
||
_cachedRealNotes = [];
|
||
_latestNotes = [];
|
||
_latestTime = 0;
|
||
_resetScoring();
|
||
}
|
||
const pat = DEMO_PATTERNS[settings.pattern];
|
||
if (!pat) return;
|
||
const now = performance.now() / 1000 - t0;
|
||
const phase = now % pat.length;
|
||
for (let cycle = -1; cycle <= 1; cycle++) {
|
||
const cycleBase = cycle * pat.length;
|
||
for (const note of pat.notes) {
|
||
const dt = note.t + cycleBase - phase;
|
||
if (dt < -BEHIND || dt > AHEAD) continue;
|
||
placeNote(note, dt);
|
||
}
|
||
}
|
||
}
|
||
|
||
function placeNote(note, dt, status) {
|
||
const laneCfg = LANES[note.lane];
|
||
if (!laneCfg) return;
|
||
|
||
const z = -dt * TS; // dt > 0 → upstream (negative Z)
|
||
const x = laneCfg.kind === 'kick' ? 0 : (LANE_X0 + note.lane * LANE_GAP);
|
||
const y = laneCfg.kind === 'kick' ? 0 : DISC_H * 0.5;
|
||
|
||
const mesh = buildNoteMesh(note.lane, note.variant, note.open);
|
||
mesh.position.set(x, y, z);
|
||
|
||
// Brighten emissive as the note approaches the hit line.
|
||
// 0 at AHEAD, peak at 0, then linger briefly past it.
|
||
const proximity = Math.max(0, 1 - Math.abs(dt) / 0.6);
|
||
// Feed the lane approach highlight (strongest note wins per frame).
|
||
if (note.lane < LANE_COUNT && proximity > (_laneProx[note.lane] || 0)) {
|
||
_laneProx[note.lane] = proximity;
|
||
}
|
||
const g = _glowMul(); // Glow slider (0.5 default = 1.0x, stock)
|
||
if (laneCfg.kind === 'drum' && note.variant !== 'ghost' && mDrumByLane[note.lane]) {
|
||
// Subtle pulse via emissiveIntensity — palette-driven base + pulse.
|
||
mDrumByLane[note.lane].emissiveIntensity = (0.45 + proximity * 0.35) * g;
|
||
} else if (laneCfg.kind === 'cymbal' && mCymbalByLane[note.lane]) {
|
||
mCymbalByLane[note.lane].emissiveIntensity = (0.55 + proximity * 0.35) * g;
|
||
} else if (laneCfg.kind === 'kick' && mKick) {
|
||
mKick.emissiveIntensity = (0.6 + proximity * 0.5) * g;
|
||
}
|
||
|
||
// Slight scale-up as notes near the hit line gives the eye a
|
||
// "this is the moment" cue. Capped so it doesn't overshoot.
|
||
const approach = 1.0 + Math.max(0, 1 - Math.abs(dt) / 0.3) * 0.12;
|
||
mesh.scale.multiplyScalar(approach);
|
||
|
||
// Status overrides — clone the shared lane material per-note so
|
||
// we can recolor JUST this note (green = hit, red = miss). The
|
||
// Swap in pre-cloned hit/miss tint materials — avoids per-frame
|
||
// per-note material.clone() calls while notesGroup is rebuilt.
|
||
if (status === 'hit' || status === 'missed') {
|
||
const isHit = status === 'hit';
|
||
if (laneCfg.kind === 'kick') {
|
||
// Kick uses a single shared bar — swap to the pre-cloned
|
||
// tint material for consistent hit/miss feedback.
|
||
const tintKick = isHit ? mKickHit : mKickMiss;
|
||
mesh.traverse((child) => {
|
||
if (!child.isMesh || !child.material) return;
|
||
if (child.material === mKick && tintKick) child.material = tintKick;
|
||
});
|
||
} else {
|
||
const tintDrum = isHit ? mDrumHitByLane[note.lane] : mDrumMissByLane[note.lane];
|
||
const tintCymbal = isHit ? mCymbalHitByLane[note.lane] : mCymbalMissByLane[note.lane];
|
||
const ghostColor = isHit ? 0x00ff88 : 0xff4444;
|
||
mesh.traverse((child) => {
|
||
if (!child.isMesh || !child.material) return;
|
||
const isBase = child.material === mDrumByLane[note.lane];
|
||
const isCym = child.material === mCymbalByLane[note.lane];
|
||
// Ghost ring uses a dedicated per-note MeshBasicMaterial
|
||
// with userData.isGhostRing — tint it in-place (already
|
||
// unique per note, no clone needed). Not the same as the
|
||
// kick accent edge which only sets userData.transient.
|
||
const isGhostRing = child.material.userData && child.material.userData.isGhostRing;
|
||
if (isBase && tintDrum) child.material = tintDrum;
|
||
else if (isCym && tintCymbal) child.material = tintCymbal;
|
||
else if (isGhostRing) child.material.color.setHex(ghostColor);
|
||
});
|
||
}
|
||
}
|
||
|
||
notesGroup.add(mesh);
|
||
|
||
// Flam grace note — small auxiliary disc slightly before the main.
|
||
// Hit status applies to the whole flam (grace + main vanish
|
||
// together); missed status leaves the grace at normal size
|
||
// since shrinking just the main reads as enough miss-feedback.
|
||
if (note.variant === 'flam' && laneCfg.kind === 'drum') {
|
||
const graceDt = dt + FLAM_GRACE_OFFSET;
|
||
if (graceDt >= -BEHIND && graceDt <= AHEAD) {
|
||
const grace = new T.Mesh(gFlamGrace, mDrumByLane[note.lane]);
|
||
grace.position.set(x - DISC_R_BASE * 0.9, y, -graceDt * TS);
|
||
notesGroup.add(grace);
|
||
}
|
||
}
|
||
}
|
||
|
||
function disposeMeshTree(node) {
|
||
// Shared geometries/materials (gDrumDisc, mDrumByLane[i], etc.)
|
||
// are owned by the renderer and disposed in teardown(). Per-note
|
||
// ephemeral geometries/materials are flagged with
|
||
// .userData.transient = true at construction time — dispose those.
|
||
node.traverse((child) => {
|
||
if (!child.isMesh) return;
|
||
if (child.geometry && child.geometry.userData && child.geometry.userData.transient) {
|
||
child.geometry.dispose();
|
||
}
|
||
if (child.material && child.material.userData && child.material.userData.transient) {
|
||
child.material.dispose();
|
||
}
|
||
});
|
||
}
|
||
|
||
/* -- size handling ---------------------------------------------- */
|
||
|
||
function applySize(w, h) {
|
||
if (!ren || !cam || !highwayCanvas) return;
|
||
const W = Math.max(1, Math.round(w || highwayCanvas.clientWidth || highwayCanvas.width || 1));
|
||
const H = Math.max(1, Math.round(h || highwayCanvas.clientHeight || highwayCanvas.height || 1));
|
||
// Splitscreen: cap the base DPR harder (1.25 vs 2, mirroring
|
||
// highway_3d) so two panels don't double the fill cost. Checks
|
||
// the host split state (covers a mixed split with another
|
||
// renderer) plus our own instance count (covers multi-instance
|
||
// without host state). _renderScale is the host's adaptive
|
||
// quality scale from bundle.renderScale.
|
||
const baseDPR = (_ssActive() || _instances.size > 1)
|
||
? Math.min(window.devicePixelRatio || 1, 1.25)
|
||
: Math.min(window.devicePixelRatio || 1, 2);
|
||
ren.setPixelRatio(_renderScale * baseDPR);
|
||
ren.setSize(W, H, false);
|
||
if (_composer) {
|
||
// EffectComposer snapshots the renderer's pixelRatio — it must
|
||
// be told about both ratio and box changes or bloom renders at
|
||
// the wrong resolution.
|
||
_composer.setPixelRatio(ren.getPixelRatio());
|
||
_composer.setSize(W, H);
|
||
_bloomW = W; _bloomH = H;
|
||
}
|
||
cam.aspect = W / H;
|
||
cam.updateProjectionMatrix();
|
||
// Record the logical size actually framed for, so the draw()
|
||
// drift check can tell when the live canvas box has moved away
|
||
// from it (PORTED FROM highway_3d).
|
||
_appliedW = W; _appliedH = H;
|
||
_sizeFxCanvas();
|
||
}
|
||
|
||
/* -- teardown --------------------------------------------------- */
|
||
|
||
// Drop scene geometry / materials / renderer without unsubscribing
|
||
// lifecycle handlers (settings, kit, MIDI). Used for kit-change
|
||
// rebuild where the renderer comes right back up against the same
|
||
// canvas. Returns true when the renderer was re-created cleanly,
|
||
// false when WebGL re-init failed — callers must NOT proceed to
|
||
// initScene() on false (the scene would draw into nothing).
|
||
function _disposeScene() {
|
||
_bloomDispose(); // composer is bound to the renderer we're about to replace
|
||
_disposeFxObjects();
|
||
_disposeEnv(); // PMREM RT is renderer-bound too; initScene rebuilds it
|
||
_bgTeardownStyle();
|
||
if (notesGroup) {
|
||
while (notesGroup.children.length) {
|
||
disposeMeshTree(notesGroup.children.pop());
|
||
}
|
||
}
|
||
if (kitMapGroup) {
|
||
while (kitMapGroup.children.length) {
|
||
const c = kitMapGroup.children.pop();
|
||
if (c.geometry) c.geometry.dispose();
|
||
if (c.material) c.material.dispose();
|
||
}
|
||
}
|
||
// Dispose laneGroup stripe meshes (PlaneGeometry + MeshBasicMaterial per
|
||
// lane) so kit-change rebuilds don't leak GPU resources.
|
||
if (laneGroup) {
|
||
while (laneGroup.children.length) {
|
||
const c = laneGroup.children.pop();
|
||
if (c.geometry) c.geometry.dispose();
|
||
if (c.material) c.material.dispose();
|
||
}
|
||
}
|
||
disposeMaterialArray(mDrumByLane);
|
||
disposeMaterialArray(mCymbalByLane);
|
||
disposeMaterialArray(mDrumHitByLane);
|
||
disposeMaterialArray(mDrumMissByLane);
|
||
disposeMaterialArray(mCymbalHitByLane);
|
||
disposeMaterialArray(mCymbalMissByLane);
|
||
if (mKick) mKick.dispose();
|
||
if (mKickHit) mKickHit.dispose();
|
||
if (mKickMiss) mKickMiss.dispose();
|
||
if (mAccentRing) mAccentRing.dispose();
|
||
if (mGhostRing) mGhostRing.dispose();
|
||
if (mSnareStripe) mSnareStripe.dispose();
|
||
if (mBellDot) mBellDot.dispose();
|
||
if (gDrumDisc) gDrumDisc.dispose();
|
||
if (gCymbalGem) gCymbalGem.dispose();
|
||
if (gKickBar) gKickBar.dispose();
|
||
if (gAccentRing) gAccentRing.dispose();
|
||
if (gGhostRing) gGhostRing.dispose();
|
||
if (gSnareStripe) gSnareStripe.dispose();
|
||
if (gBellDot) gBellDot.dispose();
|
||
if (gFlamGrace) gFlamGrace.dispose();
|
||
if (gOpenRing) gOpenRing.dispose();
|
||
if (mOpenRing) mOpenRing.dispose();
|
||
if (ren) ren.dispose();
|
||
// Floor + hit-bar resources (tracked since the themes PR — they
|
||
// previously leaked across kit-change scene rebuilds).
|
||
if (mFloor) mFloor.dispose();
|
||
if (_gFloor) _gFloor.dispose();
|
||
if (_hitBarMat) _hitBarMat.dispose();
|
||
if (_gHitBar) _gHitBar.dispose();
|
||
scene = cam = ren = lights = laneGroup = kitMapGroup = notesGroup = null;
|
||
ambLight = dirLight = mFloor = _gFloor = _hitBarMat = _gHitBar = null;
|
||
mDrumByLane = mCymbalByLane = mKick = mKickHit = mKickMiss = null;
|
||
mDrumHitByLane = mDrumMissByLane = mCymbalHitByLane = mCymbalMissByLane = null;
|
||
mAccentRing = mGhostRing = mSnareStripe = mBellDot = null;
|
||
gDrumDisc = gCymbalGem = gKickBar = null;
|
||
gAccentRing = gGhostRing = gSnareStripe = gBellDot = gFlamGrace = null;
|
||
gOpenRing = mOpenRing = null;
|
||
// Re-create the renderer against the existing canvas. initScene
|
||
// populates everything we just disposed.
|
||
try {
|
||
ren = new T.WebGLRenderer({
|
||
canvas: highwayCanvas,
|
||
antialias: true,
|
||
alpha: false,
|
||
});
|
||
ren.setClearColor(FOG_COLOR, 1);
|
||
return true;
|
||
} catch (e) {
|
||
console.error('[Drum-Hwy3D] WebGL2 re-init failed:', e);
|
||
ren = null;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function teardown() {
|
||
_bloomDispose();
|
||
_disposeFxObjects();
|
||
_disposeEnv();
|
||
_bgTeardownStyle();
|
||
_removeFxCanvas();
|
||
// HUD cleanup lives here (not only destroy): init() re-runs
|
||
// teardown() for renderer re-initialization, possibly against a
|
||
// different canvas — a stale _hudEl would both linger in the old
|
||
// parent and make the next _injectHud() an early-return no-op.
|
||
_removeHud();
|
||
if (_settingsHandler) {
|
||
window.removeEventListener('drum_h3d:settings', _settingsHandler);
|
||
_settingsHandler = null;
|
||
}
|
||
if (_kitHandler) {
|
||
window.removeEventListener('drum_h3d:kit', _kitHandler);
|
||
_kitHandler = null;
|
||
}
|
||
if (notesGroup) {
|
||
while (notesGroup.children.length) {
|
||
disposeMeshTree(notesGroup.children.pop());
|
||
}
|
||
}
|
||
if (kitMapGroup) {
|
||
while (kitMapGroup.children.length) {
|
||
const c = kitMapGroup.children.pop();
|
||
if (c.geometry) c.geometry.dispose();
|
||
if (c.material) c.material.dispose();
|
||
}
|
||
}
|
||
disposeMaterialArray(mDrumByLane);
|
||
disposeMaterialArray(mCymbalByLane);
|
||
disposeMaterialArray(mDrumHitByLane);
|
||
disposeMaterialArray(mDrumMissByLane);
|
||
disposeMaterialArray(mCymbalHitByLane);
|
||
disposeMaterialArray(mCymbalMissByLane);
|
||
if (mKick) mKick.dispose();
|
||
if (mKickHit) mKickHit.dispose();
|
||
if (mKickMiss) mKickMiss.dispose();
|
||
if (mAccentRing) mAccentRing.dispose();
|
||
if (mGhostRing) mGhostRing.dispose();
|
||
if (mSnareStripe) mSnareStripe.dispose();
|
||
if (mBellDot) mBellDot.dispose();
|
||
if (gDrumDisc) gDrumDisc.dispose();
|
||
if (gCymbalGem) gCymbalGem.dispose();
|
||
if (gKickBar) gKickBar.dispose();
|
||
if (gAccentRing) gAccentRing.dispose();
|
||
if (gGhostRing) gGhostRing.dispose();
|
||
if (gSnareStripe) gSnareStripe.dispose();
|
||
if (gBellDot) gBellDot.dispose();
|
||
if (gFlamGrace) gFlamGrace.dispose();
|
||
if (gOpenRing) gOpenRing.dispose();
|
||
if (mOpenRing) mOpenRing.dispose();
|
||
if (ren) ren.dispose();
|
||
// Floor + hit-bar resources (tracked since the themes PR — they
|
||
// previously leaked across kit-change scene rebuilds).
|
||
if (mFloor) mFloor.dispose();
|
||
if (_gFloor) _gFloor.dispose();
|
||
if (_hitBarMat) _hitBarMat.dispose();
|
||
if (_gHitBar) _gHitBar.dispose();
|
||
scene = cam = ren = lights = laneGroup = kitMapGroup = notesGroup = null;
|
||
ambLight = dirLight = mFloor = _gFloor = _hitBarMat = _gHitBar = null;
|
||
mDrumByLane = mCymbalByLane = mKick = mKickHit = mKickMiss = null;
|
||
mDrumHitByLane = mDrumMissByLane = mCymbalHitByLane = mCymbalMissByLane = null;
|
||
mAccentRing = mGhostRing = mSnareStripe = mBellDot = null;
|
||
gDrumDisc = gCymbalGem = gKickBar = null;
|
||
gAccentRing = gGhostRing = gSnareStripe = gBellDot = gFlamGrace = null;
|
||
gOpenRing = mOpenRing = null;
|
||
_isReady = false;
|
||
}
|
||
|
||
/* -- setRenderer contract --------------------------------------- */
|
||
|
||
const instance = {
|
||
contextType: 'webgl2',
|
||
|
||
init(canvas, _bundle) {
|
||
if (_isReady) teardown();
|
||
highwayCanvas = canvas;
|
||
settings = readSettings();
|
||
activePalette = PALETTES[settings.palette];
|
||
fx = readFxSettings();
|
||
// Persisted string settings refresh here too — a theme saved
|
||
// while no instance was listening must not come up stale on a
|
||
// later init().
|
||
_theme = readThemeSetting();
|
||
_bgStyle = readBgStyleSetting();
|
||
|
||
loadThree().then(() => {
|
||
if (!highwayCanvas) return; // destroyed before load resolved
|
||
try {
|
||
ren = new T.WebGLRenderer({
|
||
canvas: highwayCanvas,
|
||
antialias: true,
|
||
alpha: false,
|
||
});
|
||
ren.setClearColor(FOG_COLOR, 1);
|
||
} catch (e) {
|
||
console.error('[Drum-Hwy] WebGL2 init failed:', e);
|
||
return;
|
||
}
|
||
initScene();
|
||
applySize(highwayCanvas.clientWidth, highwayCanvas.clientHeight);
|
||
|
||
_settingsHandler = (ev) => applySettings(ev && ev.detail);
|
||
window.addEventListener('drum_h3d:settings', _settingsHandler);
|
||
|
||
// Kit-change: full scene rebuild because lane count,
|
||
// positions, kit silhouette and all materials depend
|
||
// on the active kit. Cheaper than dirty-tracking each
|
||
// affected piece.
|
||
_kitHandler = () => {
|
||
if (!_isReady || !ren) return;
|
||
_cachedDrumTabKey = null; // force note re-resolution
|
||
_cachedRealNotes = null;
|
||
// Drop the scene + rebuild against the new LANES.
|
||
// initScene reads the module-level LANES which was
|
||
// recomputed by drumH3dSetKit before this event.
|
||
const w = highwayCanvas ? highwayCanvas.clientWidth : 0;
|
||
const h = highwayCanvas ? highwayCanvas.clientHeight : 0;
|
||
if (!_disposeScene()) {
|
||
// WebGL renderer re-init failed — don't build a
|
||
// scene that would draw into nothing. Mark not
|
||
// ready and fully tear down so the plugin fails
|
||
// loudly rather than freezing half-initialised.
|
||
_isReady = false;
|
||
teardown();
|
||
return;
|
||
}
|
||
initScene();
|
||
applySize(w, h);
|
||
};
|
||
window.addEventListener('drum_h3d:kit', _kitHandler);
|
||
|
||
// MIDI lifecycle. _midiInit is idempotent across init
|
||
// calls (re-running setRenderer for the same plugin
|
||
// doesn't re-request browser permission); _midiResume
|
||
// wires the message handler whenever this instance
|
||
// claims focus, which for the non-splitscreen case is
|
||
// immediately at init.
|
||
_instances.add(instance);
|
||
_activeInstance = instance;
|
||
// Re-apply size now that this instance is counted in
|
||
// _instances: the applySize above ran before the add, so
|
||
// its splitscreen DPR check (size > 1) undercounted — the
|
||
// second panel of a splitscreen mount would otherwise keep
|
||
// full 2x DPR until some later resize. The already-mounted
|
||
// panel is corrected by the host's own layout resize when
|
||
// the split activates (panels change box size), same as on
|
||
// split teardown.
|
||
if (_instances.size > 1 || _ssActive()) {
|
||
applySize(highwayCanvas.clientWidth, highwayCanvas.clientHeight);
|
||
}
|
||
_midiInit();
|
||
_synthInit();
|
||
_midiResume();
|
||
|
||
_injectHud();
|
||
|
||
_isReady = true;
|
||
});
|
||
},
|
||
|
||
draw(bundle) {
|
||
if (!_isReady || !ren || !scene || !cam) return;
|
||
// Host adaptive quality: consume bundle.renderScale like
|
||
// highway_3d — the host lowers it under GPU load ("Quality"
|
||
// + "Min res" controls) and applySize folds it into the DPR.
|
||
const newScale = (bundle && bundle.renderScale) || 1;
|
||
if (newScale !== _renderScale) {
|
||
_renderScale = newScale;
|
||
applySize(highwayCanvas.clientWidth, highwayCanvas.clientHeight);
|
||
}
|
||
// Keep the render matched to the highway canvas's real box
|
||
// (PORTED FROM highway_3d — keep in sync). Two drifts to catch:
|
||
// 1. Backing store (canvas.width/height) changed out from under
|
||
// us — the splitscreen hw.resize override resizes the element
|
||
// but never calls renderer.resize().
|
||
// 2. The CSS box (clientWidth/Height) drifted while the backing
|
||
// store held — e.g. the flex #highway box settling after a
|
||
// fullscreen transition, with no backing-store change and no
|
||
// resize() call, so branch 1 never fires. Without this the
|
||
// drum/keys panels stay framed for the pre-fullscreen size.
|
||
if (highwayCanvas) {
|
||
const _bsChanged = highwayCanvas.width !== _lastHwW
|
||
|| highwayCanvas.height !== _lastHwH;
|
||
_boxCheckCountdown = (_boxCheckCountdown + 1) % 10;
|
||
if (_bsChanged || _boxCheckCountdown === 0) {
|
||
const _bw = highwayCanvas.clientWidth | 0;
|
||
const _bh = highwayCanvas.clientHeight | 0;
|
||
if (_bsChanged) {
|
||
_lastHwW = highwayCanvas.width;
|
||
_lastHwH = highwayCanvas.height;
|
||
if (_bw > 0 && _bh > 0) applySize(_bw, _bh);
|
||
} else if (_bw > 0 && _bh > 0 &&
|
||
(Math.abs(_bw - _appliedW) > 1 || Math.abs(_bh - _appliedH) > 1)) {
|
||
applySize(_bw, _bh);
|
||
}
|
||
}
|
||
}
|
||
rebuildNotes(bundle);
|
||
// Wall-clock FX step (sparks, kick pulse) — decoupled from
|
||
// song time so effects keep settling while paused/seeking.
|
||
{
|
||
const nowMs = performance.now();
|
||
const fdt = _fxLastWall === 0 ? 1 / 60 : Math.min(0.05, (nowMs - _fxLastWall) / 1000);
|
||
_fxLastWall = nowMs;
|
||
_sparkUpdate(fdt);
|
||
// Background ambience: advance the active style with the
|
||
// audio bands (zeros when reactivity is off). Uses this
|
||
// block's fdt — recomputing from _fxLastWall after the
|
||
// line above would read ~0 and freeze the animation.
|
||
if (_bgState && _bgState.s && BG_STYLES[_bgState._style]) {
|
||
const bands = fx.bgReactive ? _bgReadBands() : BG_ZERO_BANDS;
|
||
try {
|
||
BG_STYLES[_bgState._style].update(_bgState.s, bands, fdt, nowMs / 1000);
|
||
} catch (_) { /* visual-only */ }
|
||
}
|
||
// Kick pulse decays each frame; it drives the floor flash and,
|
||
// via applyCamera(), the camera Y dip.
|
||
if (_kickPulse > 0.001) {
|
||
_kickPulse *= Math.exp(-fdt * 7);
|
||
if (_floorFlash) _floorFlash.material.opacity = 0.25 * _kickPulse * fx.hitFx;
|
||
} else if (_kickPulse !== 0) {
|
||
_kickPulse = 0;
|
||
if (_floorFlash) _floorFlash.material.opacity = 0;
|
||
}
|
||
// Write the camera every frame: static base pose + kick dip +
|
||
// Camera Director free-cam offsets (per-panel-aware).
|
||
applyCamera();
|
||
}
|
||
// Approach highlight: raise each lane stripe toward its next
|
||
// note (accumulated by the rebuildNotes walk above).
|
||
for (let i = 0; i < laneStripeMats.length; i++) {
|
||
laneStripeMats[i].opacity = _stripeBaseOpacity() + 0.28 * (_laneProx[i] || 0) * fx.hitFx;
|
||
}
|
||
_applyLaneFlashes();
|
||
_refreshHud();
|
||
if (fx.scoreFx) { _ensureFxCanvas(); _drawScoreFx(); }
|
||
else if (_fxCtx && _fxCanvas && _fxCanvas._dirty) {
|
||
_fxCtx.clearRect(0, 0, _fxCanvas.width, _fxCanvas.height);
|
||
_fxCanvas._dirty = false;
|
||
}
|
||
// Bloom path (PORTED FROM highway_3d): composer + ACES tone
|
||
// mapping when enabled and single-instance; direct render
|
||
// otherwise (including the frames while addons stream in).
|
||
const comp = (fx.bloom && _instances.size === 1 && !_ssActive()) ? _bloomEnsure() : null;
|
||
if (comp) {
|
||
const w = highwayCanvas.clientWidth | 0, h = highwayCanvas.clientHeight | 0;
|
||
if (w > 0 && h > 0 && (w !== _bloomW || h !== _bloomH)) {
|
||
comp.setSize(w, h); _bloomW = w; _bloomH = h;
|
||
}
|
||
if (ren.toneMapping !== T.ACESFilmicToneMapping) ren.toneMapping = T.ACESFilmicToneMapping;
|
||
comp.render();
|
||
} else {
|
||
if (ren.toneMapping !== T.NoToneMapping) ren.toneMapping = T.NoToneMapping;
|
||
ren.render(scene, cam);
|
||
}
|
||
},
|
||
|
||
resize(w, h) {
|
||
if (!_isReady) return;
|
||
applySize(w, h);
|
||
},
|
||
|
||
destroy() {
|
||
_instances.delete(instance);
|
||
// If the focused instance is going away but others remain
|
||
// (splitscreen teardown of one panel), promote a survivor so
|
||
// _midiOnMessage keeps routing instead of dropping every
|
||
// event on its `if (!_activeInstance) return` guard — same
|
||
// behavior as keys_highway_3d.
|
||
if (_activeInstance === instance) {
|
||
_activeInstance = null;
|
||
for (const inst of _instances) { _activeInstance = inst; break; }
|
||
}
|
||
if (_instances.size === 0) _midiReleaseSession();
|
||
teardown(); // includes _removeHud()
|
||
// Instances are reused across songs (destroy() → init()); stale
|
||
// applied/backing dims would suppress the first reframe of the
|
||
// next song (PORTED FROM highway_3d).
|
||
_lastHwW = 0; _lastHwH = 0;
|
||
_appliedW = 0; _appliedH = 0;
|
||
highwayCanvas = null;
|
||
},
|
||
// Exposed for module-level MIDI router. The receiver runs on
|
||
// every note-on dispatched to _activeInstance.
|
||
_handleDrumHit,
|
||
// Headless FX-state probe for window.__drumHwTest — reports
|
||
// whether sparks are live, the strongest lane-flash opacity, and
|
||
// the kick-pulse envelope so tests can assert the FX pipeline
|
||
// without pixel-diffing a scrolling scene.
|
||
_fxProbe() {
|
||
let maxFlash = 0;
|
||
for (const q of _laneFlashQuads) {
|
||
if (q && q.material.opacity > maxFlash) maxFlash = q.material.opacity;
|
||
}
|
||
if (_kickFlashQuad && _kickFlashQuad.material.opacity > maxFlash) {
|
||
maxFlash = _kickFlashQuad.material.opacity;
|
||
}
|
||
let popsLive = 0;
|
||
for (const pop of _scorePops) if (pop.active) popsLive++;
|
||
return {
|
||
sparksLive: !!(_sparkPts && _sparkPts.visible),
|
||
maxFlash,
|
||
kickPulse: _kickPulse,
|
||
flashCount: _laneFlashes.length,
|
||
bgStyle: _bgState ? _bgState._style : null,
|
||
bgLive: !!(_bgState && _bgState.s),
|
||
popsLive,
|
||
};
|
||
},
|
||
};
|
||
return instance;
|
||
}
|
||
|
||
/* ======================================================================
|
||
* Register
|
||
* ====================================================================== */
|
||
|
||
window.slopsmithViz_drum_highway_3d = createFactory;
|
||
// slopsmith→feedBack rename: host viz picker looks up `window.feedBackViz_<id>`.
|
||
window.feedBackViz_drum_highway_3d = window.slopsmithViz_drum_highway_3d;
|
||
// Static contextType so core can read it for canvas-swap decisions
|
||
// before constructing a throwaway renderer instance.
|
||
window.slopsmithViz_drum_highway_3d.contextType = 'webgl2';
|
||
// Auto-select on sloppaks that carry a drum_tab.json (sloppak-spec
|
||
// §5.3). Sorts before `drums` alphabetically so Auto mode picks the
|
||
// 3D viz over the 2D fallback. The 2D drums plugin keeps its own
|
||
// matchesArrangement as a defence-in-depth fallback for WebGL2-less
|
||
// browsers — _autoMatchViz gates webgl2 contextType on _canRun3D
|
||
// before installing the renderer, so non-WebGL2 environments will
|
||
// skip past this and pick the 2D one.
|
||
//
|
||
// Steal-guard (bundling): `has_drum_tab` is a PACK-level flag, and Auto
|
||
// mode is first-match-wins in plugin order — `drum_highway_3d` sorts
|
||
// before `highway_3d`, so matching on the flag alone would take every
|
||
// full-band pack away from the guitar highway even when the active
|
||
// arrangement is Lead/Bass. Only auto-claim when the ACTIVE arrangement
|
||
// is a drum part, or when nothing more specific can render the song
|
||
// (no keys notation, no guitar-family arrangement). A drummer on a
|
||
// full-band pack picks this viz from the picker as before; instrument-
|
||
// selector routing (server.py highway_ws) will auto-route once a drums
|
||
// selector entry lands.
|
||
window.slopsmithViz_drum_highway_3d.matchesArrangement = function (songInfo) {
|
||
if (!songInfo || !songInfo.has_drum_tab) return false;
|
||
const arr = songInfo.arrangement || '';
|
||
if (/\b(?:drums?|percussion)\b/i.test(arr)) return true;
|
||
if (songInfo.has_notation) return false;
|
||
return !/\b(?:lead|rhythm|bass|combo|guitar)\b/i.test(arr);
|
||
};
|
||
// Pure helpers exposed for the node:test suite (tests/ — screen.js is
|
||
// vm-loaded with no DOM/WebGL; everything here must stay side-effect
|
||
// free to call).
|
||
window.slopsmithViz_drum_highway_3d.__test = {
|
||
_resolveFreeCam,
|
||
_ssApi,
|
||
_variantForHit,
|
||
_classifyTiming,
|
||
readFxSettings,
|
||
readThemeSetting,
|
||
readBgStyleSetting,
|
||
_bgThemeColors,
|
||
BG_THEMES,
|
||
BG_STYLE_IDS,
|
||
FX_DEFAULTS,
|
||
MIDI_TO_PIECE,
|
||
HIT_TOLERANCE_S,
|
||
};
|
||
|
||
// Headless verification hook (keys __keysHwTest pattern): lets
|
||
// Playwright fire synthetic pad hits through the full hit-detection +
|
||
// FX path without a physical MIDI kit, and read the live FX state back
|
||
// (the demo pattern scrolls every frame, so pixel-diff assertions can't
|
||
// distinguish FX from scroll — this probe can).
|
||
window.__drumHwTest = {
|
||
injectHit(midiNote, velocity) {
|
||
if (_activeInstance && typeof _activeInstance._handleDrumHit === 'function') {
|
||
_activeInstance._handleDrumHit(midiNote, velocity == null ? 100 : velocity);
|
||
}
|
||
},
|
||
probe() {
|
||
return (_activeInstance && typeof _activeInstance._fxProbe === 'function')
|
||
? _activeInstance._fxProbe() : null;
|
||
},
|
||
};
|
||
})();
|