mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
feat(drum_highway_3d): audio-reactive ambience + score FX overlay (D4) (#708)
- BG_STYLES port (off/particles/lights/geometric) into a renderOrder -1 group; _bgGetAnalyser/_bgReadBands (stems-first, one-shot #audio fallback, permanent-failure latch, 5ms bands cache); Ambience intensity slider + Audio-reactive toggle; remounts on style/intensity/ palette change and across kit-change scene rebuilds - Score-FX overlay canvas (guitar drawScoreFx adapted to internal scoring): +1 pops at the struck lane, ring pulse every 10-combo, milestone bursts at 25/50/100, red wash on 3+ streak break; pooled, cleared when idle, removed in teardown - butterchurn/image/video deliberately out of scope - Tests: style id validation + FX defaults (15 total) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4214ef365e
commit
d409d55615
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **3D Keys Highway: the anti-plastic pass — lacquered note gems, glossy piano-black keys, a studio environment, scene themes and a gradient sky (parity slice 3).** The note gems move to `MeshPhysicalMaterial` with a full **clearcoat** (roughness 0.32, clearcoat 1.0/0.18, envMapIntensity 0.9): a sharp lacquer highlight over the colored body instead of the old dead matte surface — glass, not plastic. What sells it is **image-based lighting**: the same procedural PMREM "studio" environment as the drum highway (dark room + cool overhead / warm+cool side light strips, no addon dependency) feeds `scene.environment`, so the **black keys finally read as glossy piano black** (roughness 0.55 → 0.22, envMapIntensity 1.3) with visible strip reflections, whites keep an ivory sheen (0.42/0.55), and the highway floor gets a stage sheen (roughness 0.9 → 0.55, metalness 0.15). The flat background becomes a **vertical gradient** (lighter above the horizon → theme color → darker toward the keyboard), and the guitar highway's **11 scene themes** arrive (same names/values — your look carries across instruments; `default` preserves the original keys palette; pitch-class note/key colors are never themed — themes own the scene, Synthesia colors own the notes). Plus **Cinematic lighting** (ambient 0.55/key 1.3, on by default) and a **Glow strength** slider multiplying the note glow, key approach-glow and the sustain consume-flash (0.5 = stock). All live-applying from the Graphics settings (`keys3d_bg_theme` + `keys3d_bg_*`); the PMREM target and gradient texture are disposed with the scene. Tests: theme-table id parity + default-look preservation + fallbacks (28 total).
|
- **3D Keys Highway: the anti-plastic pass — lacquered note gems, glossy piano-black keys, a studio environment, scene themes and a gradient sky (parity slice 3).** The note gems move to `MeshPhysicalMaterial` with a full **clearcoat** (roughness 0.32, clearcoat 1.0/0.18, envMapIntensity 0.9): a sharp lacquer highlight over the colored body instead of the old dead matte surface — glass, not plastic. What sells it is **image-based lighting**: the same procedural PMREM "studio" environment as the drum highway (dark room + cool overhead / warm+cool side light strips, no addon dependency) feeds `scene.environment`, so the **black keys finally read as glossy piano black** (roughness 0.55 → 0.22, envMapIntensity 1.3) with visible strip reflections, whites keep an ivory sheen (0.42/0.55), and the highway floor gets a stage sheen (roughness 0.9 → 0.55, metalness 0.15). The flat background becomes a **vertical gradient** (lighter above the horizon → theme color → darker toward the keyboard), and the guitar highway's **11 scene themes** arrive (same names/values — your look carries across instruments; `default` preserves the original keys palette; pitch-class note/key colors are never themed — themes own the scene, Synthesia colors own the notes). Plus **Cinematic lighting** (ambient 0.55/key 1.3, on by default) and a **Glow strength** slider multiplying the note glow, key approach-glow and the sustain consume-flash (0.5 = stock). All live-applying from the Graphics settings (`keys3d_bg_theme` + `keys3d_bg_*`); the PMREM target and gradient texture are disposed with the scene. Tests: theme-table id parity + default-look preservation + fallbacks (28 total).
|
||||||
|
- **3D Drum Highway: audio-reactive background ambience + score effects (parity slice 4 — completes the drum side of the visual-parity epic).** The empty fog band behind the kit gains the guitar highway's **background ambience styles**: drifting **Particles**, palette-colored pulsing **Stage lights**, and slowly-tumbling wireframe **Geometric** shapes (plus Off) — driven by the same audio-analyser bridge the guitar uses (prefers the stems plugin's per-song analyser on sloppaks, falls back to a one-shot `#audio` tap; bass/mid/treble bands with a 5 ms cache), with an **Ambience intensity** slider and an **Audio-reactive** toggle (off = the styles animate on time only; a permanently-tapped `#audio` in a mixed split degrades the same way). The guitar's butterchurn/image/video styles are deliberately out of scope (vendored megabytes / upload plumbing; the style enum is extensible). And your combo finally talks back: a **score-FX overlay** (2D canvas over the WebGL scene, guitar `drawScoreFx` adapted to this plugin's internal scoring) draws rising **“+1” pops** off each scored lane, an **expanding ring pulse every 10-combo tier**, **milestone particle bursts** at 25/50/100 streak, and a brief **red flicker** when a 3+ streak breaks. Everything is settings-gated (Background ambience dropdown + intensity + reactive, Score effects toggle — all on by default, live-applying, `drum_h3d_bg_*` keys), pooled (zero per-frame allocation), and torn down with the scene across kit changes. Tests: bg-style id validation + FX defaults (15 total).
|
||||||
- **3D Drum Highway: real materials + scene themes (parity slice 3).** The scene gets **image-based lighting**: a procedural PMREM "studio" environment (dark room + three emissive light strips — cool overhead key, warm/cool side fills; no vendored-addon dependency) feeds `scene.environment`, so the cymbals' metalness **finally reads as metal** (retuned to roughness 0.2 / metalness 0.85 / envMapIntensity 1.2 — the old 0.7-metalness look was matte because there was nothing to reflect), drumheads get a satin sheen, and the floor (roughness 0.95 → 0.7) catches the strips without turning into a mirror. **Scene themes arrive** — the same 11 theme names as the guitar highway (Midnight, Charcoal, Deep Purple, Forest, Warm Slate, Deep Focus, Deep Sea, Cathode, Cathode Green, Hearth) retint the background/fog, floor and lane stripes so your look carries across instruments; `default` preserves the original drum palette byte-for-byte, and piece colours stay with the existing Palette picker (themes own the scene, palettes own the kit). Plus **Cinematic lighting** (dimmer ambient / stronger key, on by default), a **Glow strength** slider (0–1, 0.5 = stock) multiplying every emissive base — notes, hit line, snare wires — and a **Lane vibrancy** slider driving stripe/halo/ghost-ring strength (the hit-FX approach highlight stacks on top). Everything applies live from the plugin settings (`drum_h3d_bg_theme` + `drum_h3d_bg_*` keys); the PMREM render target is rebuilt across kit-change renderer recreation and disposed in both teardown paths — and the floor/hit-bar geometry+materials that previously leaked on every kit change are now tracked and disposed too. Tests: theme-table parity with the guitar ids + default-look preservation + fallbacks (13 total).
|
- **3D Drum Highway: real materials + scene themes (parity slice 3).** The scene gets **image-based lighting**: a procedural PMREM "studio" environment (dark room + three emissive light strips — cool overhead key, warm/cool side fills; no vendored-addon dependency) feeds `scene.environment`, so the cymbals' metalness **finally reads as metal** (retuned to roughness 0.2 / metalness 0.85 / envMapIntensity 1.2 — the old 0.7-metalness look was matte because there was nothing to reflect), drumheads get a satin sheen, and the floor (roughness 0.95 → 0.7) catches the strips without turning into a mirror. **Scene themes arrive** — the same 11 theme names as the guitar highway (Midnight, Charcoal, Deep Purple, Forest, Warm Slate, Deep Focus, Deep Sea, Cathode, Cathode Green, Hearth) retint the background/fog, floor and lane stripes so your look carries across instruments; `default` preserves the original drum palette byte-for-byte, and piece colours stay with the existing Palette picker (themes own the scene, palettes own the kit). Plus **Cinematic lighting** (dimmer ambient / stronger key, on by default), a **Glow strength** slider (0–1, 0.5 = stock) multiplying every emissive base — notes, hit line, snare wires — and a **Lane vibrancy** slider driving stripe/halo/ghost-ring strength (the hit-FX approach highlight stacks on top). Everything applies live from the plugin settings (`drum_h3d_bg_theme` + `drum_h3d_bg_*` keys); the PMREM render target is rebuilt across kit-change renderer recreation and disposed in both teardown paths — and the floor/hit-bar geometry+materials that previously leaked on every kit change are now tracked and disposed too. Tests: theme-table parity with the guitar ids + default-look preservation + fallbacks (13 total).
|
||||||
- **3D Drum Highway: hit FX — sparks, timing-colored lane flashes, kick camera pulse, approach glow, and open hi-hat notation (parity slice 2).** Striking a pad now *feels* struck: a pooled additive **spark burst** fires at the lane (ported from the guitar highway's Points-cloud system, pool 160), colored by **timing** — on-time green, early cyan, late amber (same `_timingHex` vocabulary as `highway_3d`, classified against the ±50 ms hit window with the inner 40% reading as on-time); with **Streak feedback** on, bursts grow with your combo. The **lane flash** feedback that was removed when note-recoloring landed is resurrected properly: pooled additive quads with a soft gaussian falloff light up the struck lane at the hit line (timing-colored; red for wrong-pad hits), and a **kick** hit fires triple amber bursts across the bar plus a subtle **camera dip + amber floor wash** that decays exponentially. Lanes also glow ahead of time: each stripe brightens as its next note approaches the hit line, so the eye is led to where the next hit lands. **Open hi-hat finally renders distinctly** — `hh_open` chart hits get a thin warm ring around the cymbal gem (standard notation's "o"), closing the long-standing TODO; the flag is orthogonal to accents/ghosts/flams so combined cues stack. All of it is settings-gated (Graphics → Hit sparks / Timing colours / Streak feedback / a 0–1 **Hit feedback intensity** slider driving flashes, approach glow and the kick pulse; everything on by default, `drum_h3d_bg_*` keys, live-applying) and GPU-frugal: every new visual is pooled or shares geometry/materials — zero per-note allocation on top of the per-frame notes rebuild, all registered in both dispose paths (kit-change renderer recreation included). Tests: timing-classifier boundaries + FX defaults added to `plugins/drum_highway_3d/tests/data_layer.test.js` (10 total).
|
- **3D Drum Highway: hit FX — sparks, timing-colored lane flashes, kick camera pulse, approach glow, and open hi-hat notation (parity slice 2).** Striking a pad now *feels* struck: a pooled additive **spark burst** fires at the lane (ported from the guitar highway's Points-cloud system, pool 160), colored by **timing** — on-time green, early cyan, late amber (same `_timingHex` vocabulary as `highway_3d`, classified against the ±50 ms hit window with the inner 40% reading as on-time); with **Streak feedback** on, bursts grow with your combo. The **lane flash** feedback that was removed when note-recoloring landed is resurrected properly: pooled additive quads with a soft gaussian falloff light up the struck lane at the hit line (timing-colored; red for wrong-pad hits), and a **kick** hit fires triple amber bursts across the bar plus a subtle **camera dip + amber floor wash** that decays exponentially. Lanes also glow ahead of time: each stripe brightens as its next note approaches the hit line, so the eye is led to where the next hit lands. **Open hi-hat finally renders distinctly** — `hh_open` chart hits get a thin warm ring around the cymbal gem (standard notation's "o"), closing the long-standing TODO; the flag is orthogonal to accents/ghosts/flams so combined cues stack. All of it is settings-gated (Graphics → Hit sparks / Timing colours / Streak feedback / a 0–1 **Hit feedback intensity** slider driving flashes, approach glow and the kick pulse; everything on by default, `drum_h3d_bg_*` keys, live-applying) and GPU-frugal: every new visual is pooled or shares geometry/materials — zero per-note allocation on top of the per-frame notes rebuild, all registered in both dispose paths (kit-change renderer recreation included). Tests: timing-classifier boundaries + FX defaults added to `plugins/drum_highway_3d/tests/data_layer.test.js` (10 total).
|
||||||
- **3D Keys Highway: hit FX — vibrant note gems, timing-colored sparks, and a hit-line that reacts to your playing (parity slice 2).** The washed-out note look is gone: gem opacity is now driven by a **Note vibrancy** slider (default 0.85 → opacity 0.92, up from a fixed 0.8; lane guides scale with it too, live-applying without a chart rebuild) and the resting emissive glow rises 0.08 → 0.22, so the falling notes finally read saturated against the dark floor. Scored key presses fire a pooled additive **spark burst** at the struck key (guitar-highway port, pool 96) **colored by timing** — on-time green, early cyan, late amber, classified against the ±100 ms window with the inner 40% reading as on-time (the timing delta is recovered from the matched note's key, so `judgeHit`'s tested contract is untouched); the per-pitch-class flame sprite keeps its identity color so pitch and timing stay separate signals. With **Streak feedback** on, bursts grow with the combo. The **hit line kicks brighter** for a beat on every scored press (exponential decay, scaled by a 0–1 **Hit feedback intensity** slider). All new controls live in the plugin's Graphics settings (on by default, `keys3d_bg_*` keys, live-applying), and the spark pool is disposed with the scene like every other GPU resource. Tests: timing-classifier boundaries, the noteKey time round-trip that the delta recovery relies on, and the new FX defaults (26 total).
|
- **3D Keys Highway: hit FX — vibrant note gems, timing-colored sparks, and a hit-line that reacts to your playing (parity slice 2).** The washed-out note look is gone: gem opacity is now driven by a **Note vibrancy** slider (default 0.85 → opacity 0.92, up from a fixed 0.8; lane guides scale with it too, live-applying without a chart rebuild) and the resting emissive glow rises 0.08 → 0.22, so the falling notes finally read saturated against the dark floor. Scored key presses fire a pooled additive **spark burst** at the struck key (guitar-highway port, pool 96) **colored by timing** — on-time green, early cyan, late amber, classified against the ±100 ms window with the inner 40% reading as on-time (the timing delta is recovered from the matched note's key, so `judgeHit`'s tested contract is untouched); the per-pitch-class flame sprite keeps its identity color so pitch and timing stay separate signals. With **Streak feedback** on, bursts grow with the combo. The **hit line kicks brighter** for a beat on every scored press (exponential decay, scaled by a 0–1 **Hit feedback intensity** slider). All new controls live in the plugin's Graphics settings (on by default, `keys3d_bg_*` keys, live-applying), and the spark pool is disposed with the scene like every other GPU resource. Tests: timing-classifier boundaries, the noteKey time round-trip that the delta recovery relies on, and the new FX defaults (26 total).
|
||||||
|
|||||||
@@ -82,6 +82,13 @@ tunes one of these, mirror the change here (and in `keys_highway_3d`):
|
|||||||
as the guitar's, except `default` which is this plugin's original
|
as the guitar's, except `default` which is this plugin's original
|
||||||
palette); one pick drives both of the guitar's background/highway axes
|
palette); one pick drives both of the guitar's background/highway axes
|
||||||
- `_applyCinematic()` — ambient/key rebalance (values tuned per plugin)
|
- `_applyCinematic()` — ambient/key rebalance (values tuned per plugin)
|
||||||
|
- `BG_STYLES` (off/particles/lights/geometric) + `_bgGetAnalyser()` /
|
||||||
|
`_bgReadBands()` — background ambience + the stems-first audio-analyser
|
||||||
|
bridge (guitar's diagnostics plumbing dropped; butterchurn/image/video
|
||||||
|
out of scope)
|
||||||
|
- `_drawScoreFx()` — the guitar's drawScoreFx overlay adapted to this
|
||||||
|
plugin's internal scoring (pops / tier rings / milestone bursts /
|
||||||
|
streak-break wash)
|
||||||
|
|
||||||
## Why a separate plugin (vs. drum mode inside highway_3d)?
|
## Why a separate plugin (vs. drum mode inside highway_3d)?
|
||||||
|
|
||||||
|
|||||||
@@ -1118,11 +1118,153 @@
|
|||||||
cinematic: true, // rebalanced lighting (dimmer ambient, stronger key)
|
cinematic: true, // rebalanced lighting (dimmer ambient, stronger key)
|
||||||
glow: 0.5, // 0–1 emissive multiplier (0.5 = the stock look)
|
glow: 0.5, // 0–1 emissive multiplier (0.5 = the stock look)
|
||||||
vibrancy: 0.85, // lane stripe / halo / ghost opacity strength
|
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_';
|
const FX_LS_PREFIX = 'drum_h3d_bg_';
|
||||||
// Theme id lives OUTSIDE FX_DEFAULTS (string, not bool/number) — its own
|
// Theme id lives OUTSIDE FX_DEFAULTS (string, not bool/number) — its own
|
||||||
// localStorage key + validation against BG_THEMES.
|
// localStorage key + validation against BG_THEMES.
|
||||||
const FX_LS_THEME = 'drum_h3d_bg_theme';
|
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,
|
// 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).
|
// names and values in sync so a user's look carries across instruments).
|
||||||
@@ -1265,6 +1407,27 @@
|
|||||||
let laneStripeMats = []; // buildLanes stripe materials (approach highlight)
|
let laneStripeMats = []; // buildLanes stripe materials (approach highlight)
|
||||||
let _laneProx = []; // per-frame max approach proximity per lane
|
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.
|
// Scene groups / pooled meshes.
|
||||||
let laneGroup = null; // lane stripes + dividers
|
let laneGroup = null; // lane stripes + dividers
|
||||||
let kitMapGroup = null; // top-of-highway kit silhouette
|
let kitMapGroup = null; // top-of-highway kit silhouette
|
||||||
@@ -1649,10 +1812,13 @@
|
|||||||
const ts = _classifyTiming(matchedDelta, HIT_TOLERANCE_S);
|
const ts = _classifyTiming(matchedDelta, HIT_TOLERANCE_S);
|
||||||
_laneFlashes.push({ lane, wall: performance.now(), kind: 'hit', ts });
|
_laneFlashes.push({ lane, wall: performance.now(), kind: 'hit', ts });
|
||||||
_spawnHitFx(lane, ts, false);
|
_spawnHitFx(lane, ts, false);
|
||||||
|
_scoreOnHit(lane);
|
||||||
} else {
|
} else {
|
||||||
|
const prevStreak = _streak;
|
||||||
_misses++;
|
_misses++;
|
||||||
_streak = 0;
|
_streak = 0;
|
||||||
_laneFlashes.push({ lane, wall: performance.now(), kind: 'wrong' });
|
_laneFlashes.push({ lane, wall: performance.now(), kind: 'wrong' });
|
||||||
|
_scoreOnBreak(prevStreak);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1669,8 +1835,10 @@
|
|||||||
const key = _hitKey(n.t, n.lane);
|
const key = _hitKey(n.t, n.lane);
|
||||||
if (_hitKeys.has(key) || _missedKeys.has(key)) continue;
|
if (_hitKeys.has(key) || _missedKeys.has(key)) continue;
|
||||||
_missedKeys.add(key);
|
_missedKeys.add(key);
|
||||||
|
const prevStreak = _streak;
|
||||||
_misses++;
|
_misses++;
|
||||||
_streak = 0;
|
_streak = 0;
|
||||||
|
_scoreOnBreak(prevStreak);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1681,6 +1849,7 @@
|
|||||||
activePalette = PALETTES[detail.palette];
|
activePalette = PALETTES[detail.palette];
|
||||||
rebuildPaletteMaterials();
|
rebuildPaletteMaterials();
|
||||||
rebuildKitMap();
|
rebuildKitMap();
|
||||||
|
if (_bgStyle === 'lights') _bgMountStyle(); // lights use the palette
|
||||||
}
|
}
|
||||||
if (detail.pattern && DEMO_PATTERNS[detail.pattern]) {
|
if (detail.pattern && DEMO_PATTERNS[detail.pattern]) {
|
||||||
settings.pattern = detail.pattern;
|
settings.pattern = detail.pattern;
|
||||||
@@ -1706,11 +1875,16 @@
|
|||||||
if (mSnareStripe) mSnareStripe.emissiveIntensity = 0.5 * _glowMul();
|
if (mSnareStripe) mSnareStripe.emissiveIntensity = 0.5 * _glowMul();
|
||||||
}
|
}
|
||||||
if ('vibrancy' in detail.fx) _applyVibrancy();
|
if ('vibrancy' in detail.fx) _applyVibrancy();
|
||||||
|
if ('bgIntensity' in detail.fx) _bgMountStyle();
|
||||||
}
|
}
|
||||||
if (detail.theme && BG_THEMES[detail.theme]) {
|
if (detail.theme && BG_THEMES[detail.theme]) {
|
||||||
_theme = detail.theme;
|
_theme = detail.theme;
|
||||||
_applyTheme();
|
_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()
|
// Vibrancy slider: stripes get their new base next frame (draw()
|
||||||
@@ -1804,6 +1978,357 @@
|
|||||||
for (const m of arr) if (m) m.dispose();
|
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 --------------------------------------- */
|
/* -- one-time scene setup --------------------------------------- */
|
||||||
|
|
||||||
// Emissive multiplier from the Glow slider — 0.5 is neutral (the
|
// Emissive multiplier from the Glow slider — 0.5 is neutral (the
|
||||||
@@ -2091,6 +2616,9 @@
|
|||||||
|
|
||||||
rebuildPaletteMaterials();
|
rebuildPaletteMaterials();
|
||||||
_applyVibrancy(); // halo/ghost ring opacities from the slider
|
_applyVibrancy(); // halo/ghost ring opacities from the slider
|
||||||
|
|
||||||
|
// Background ambience behind everything (renderOrder -1).
|
||||||
|
_bgMountStyle();
|
||||||
}
|
}
|
||||||
|
|
||||||
function positionCamera() {
|
function positionCamera() {
|
||||||
@@ -2573,6 +3101,7 @@
|
|||||||
}
|
}
|
||||||
cam.aspect = W / H;
|
cam.aspect = W / H;
|
||||||
cam.updateProjectionMatrix();
|
cam.updateProjectionMatrix();
|
||||||
|
_sizeFxCanvas();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -- teardown --------------------------------------------------- */
|
/* -- teardown --------------------------------------------------- */
|
||||||
@@ -2587,6 +3116,7 @@
|
|||||||
_bloomDispose(); // composer is bound to the renderer we're about to replace
|
_bloomDispose(); // composer is bound to the renderer we're about to replace
|
||||||
_disposeFxObjects();
|
_disposeFxObjects();
|
||||||
_disposeEnv(); // PMREM RT is renderer-bound too; initScene rebuilds it
|
_disposeEnv(); // PMREM RT is renderer-bound too; initScene rebuilds it
|
||||||
|
_bgTeardownStyle();
|
||||||
if (notesGroup) {
|
if (notesGroup) {
|
||||||
while (notesGroup.children.length) {
|
while (notesGroup.children.length) {
|
||||||
disposeMeshTree(notesGroup.children.pop());
|
disposeMeshTree(notesGroup.children.pop());
|
||||||
@@ -2667,6 +3197,8 @@
|
|||||||
_bloomDispose();
|
_bloomDispose();
|
||||||
_disposeFxObjects();
|
_disposeFxObjects();
|
||||||
_disposeEnv();
|
_disposeEnv();
|
||||||
|
_bgTeardownStyle();
|
||||||
|
_removeFxCanvas();
|
||||||
// HUD cleanup lives here (not only destroy): init() re-runs
|
// HUD cleanup lives here (not only destroy): init() re-runs
|
||||||
// teardown() for renderer re-initialization, possibly against a
|
// teardown() for renderer re-initialization, possibly against a
|
||||||
// different canvas — a stale _hudEl would both linger in the old
|
// different canvas — a stale _hudEl would both linger in the old
|
||||||
@@ -2748,6 +3280,7 @@
|
|||||||
// while no instance was listening must not come up stale on a
|
// while no instance was listening must not come up stale on a
|
||||||
// later init().
|
// later init().
|
||||||
_theme = readThemeSetting();
|
_theme = readThemeSetting();
|
||||||
|
_bgStyle = readBgStyleSetting();
|
||||||
|
|
||||||
loadThree().then(() => {
|
loadThree().then(() => {
|
||||||
if (!highwayCanvas) return; // destroyed before load resolved
|
if (!highwayCanvas) return; // destroyed before load resolved
|
||||||
@@ -2842,6 +3375,16 @@
|
|||||||
const fdt = _fxLastWall === 0 ? 1 / 60 : Math.min(0.05, (nowMs - _fxLastWall) / 1000);
|
const fdt = _fxLastWall === 0 ? 1 / 60 : Math.min(0.05, (nowMs - _fxLastWall) / 1000);
|
||||||
_fxLastWall = nowMs;
|
_fxLastWall = nowMs;
|
||||||
_sparkUpdate(fdt);
|
_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 */ }
|
||||||
|
}
|
||||||
if (_kickPulse > 0.001) {
|
if (_kickPulse > 0.001) {
|
||||||
_kickPulse *= Math.exp(-fdt * 7);
|
_kickPulse *= Math.exp(-fdt * 7);
|
||||||
cam.position.y = _camBaseH - 0.8 * K * _kickPulse * fx.hitFx;
|
cam.position.y = _camBaseH - 0.8 * K * _kickPulse * fx.hitFx;
|
||||||
@@ -2859,6 +3402,11 @@
|
|||||||
}
|
}
|
||||||
_applyLaneFlashes();
|
_applyLaneFlashes();
|
||||||
_refreshHud();
|
_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
|
// Bloom path (PORTED FROM highway_3d): composer + ACES tone
|
||||||
// mapping when enabled and single-instance; direct render
|
// mapping when enabled and single-instance; direct render
|
||||||
// otherwise (including the frames while addons stream in).
|
// otherwise (including the frames while addons stream in).
|
||||||
@@ -2911,11 +3459,16 @@
|
|||||||
if (_kickFlashQuad && _kickFlashQuad.material.opacity > maxFlash) {
|
if (_kickFlashQuad && _kickFlashQuad.material.opacity > maxFlash) {
|
||||||
maxFlash = _kickFlashQuad.material.opacity;
|
maxFlash = _kickFlashQuad.material.opacity;
|
||||||
}
|
}
|
||||||
|
let popsLive = 0;
|
||||||
|
for (const pop of _scorePops) if (pop.active) popsLive++;
|
||||||
return {
|
return {
|
||||||
sparksLive: !!(_sparkPts && _sparkPts.visible),
|
sparksLive: !!(_sparkPts && _sparkPts.visible),
|
||||||
maxFlash,
|
maxFlash,
|
||||||
kickPulse: _kickPulse,
|
kickPulse: _kickPulse,
|
||||||
flashCount: _laneFlashes.length,
|
flashCount: _laneFlashes.length,
|
||||||
|
bgStyle: _bgState ? _bgState._style : null,
|
||||||
|
bgLive: !!(_bgState && _bgState.s),
|
||||||
|
popsLive,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -2965,8 +3518,10 @@
|
|||||||
_classifyTiming,
|
_classifyTiming,
|
||||||
readFxSettings,
|
readFxSettings,
|
||||||
readThemeSetting,
|
readThemeSetting,
|
||||||
|
readBgStyleSetting,
|
||||||
_bgThemeColors,
|
_bgThemeColors,
|
||||||
BG_THEMES,
|
BG_THEMES,
|
||||||
|
BG_STYLE_IDS,
|
||||||
FX_DEFAULTS,
|
FX_DEFAULTS,
|
||||||
MIDI_TO_PIECE,
|
MIDI_TO_PIECE,
|
||||||
HIT_TOLERANCE_S,
|
HIT_TOLERANCE_S,
|
||||||
|
|||||||
@@ -101,6 +101,42 @@
|
|||||||
Lane stripe and accent-ring strength — lower for a calmer deck.
|
Lane stripe and accent-ring strength — lower for a calmer deck.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<label for="drumh3d-fx-bgstyle" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">Background ambience</label>
|
||||||
|
<select id="drumh3d-fx-bgstyle"
|
||||||
|
onchange="window.drumH3dSetBgStyle && window.drumH3dSetBgStyle(this.value)"
|
||||||
|
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||||
|
<option value="off">Off</option>
|
||||||
|
<option value="particles" selected>Particles</option>
|
||||||
|
<option value="lights">Stage lights</option>
|
||||||
|
<option value="geometric">Geometric</option>
|
||||||
|
</select>
|
||||||
|
<label for="drumh3d-fx-bgintensity" class="text-xs font-medium text-gray-400 mb-1 mt-2 block">
|
||||||
|
Ambience intensity <span id="drumh3d-fx-bgintensity-val" class="text-gray-500 font-mono">0.50</span>
|
||||||
|
</label>
|
||||||
|
<input type="range" id="drumh3d-fx-bgintensity"
|
||||||
|
min="0" max="1" step="0.05" value="0.5"
|
||||||
|
oninput="window.drumH3dSetFx && window.drumH3dSetFx('bgIntensity', this.value); document.getElementById('drumh3d-fx-bgintensity-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||||
|
class="w-full">
|
||||||
|
<label for="drumh3d-fx-bgreactive" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-2">
|
||||||
|
<input type="checkbox" id="drumh3d-fx-bgreactive" checked
|
||||||
|
onchange="window.drumH3dSetFx && window.drumH3dSetFx('bgReactive', this.checked)">
|
||||||
|
Audio-reactive ambience
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
The backdrop pulses with the mix (stems analyser when a sloppak
|
||||||
|
is loaded). Off = it animates on time only.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label for="drumh3d-fx-scorefx" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||||
|
<input type="checkbox" id="drumh3d-fx-scorefx" checked
|
||||||
|
onchange="window.drumH3dSetFx && window.drumH3dSetFx('scoreFx', this.checked)">
|
||||||
|
Score effects
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
+1 pops on hits, a ring pulse every 10-combo, milestone bursts at
|
||||||
|
25/50/100, and a brief red flicker when a streak breaks.
|
||||||
|
</p>
|
||||||
|
|
||||||
<label for="drumh3d-fx-bloom" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
<label for="drumh3d-fx-bloom" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||||
<input type="checkbox" id="drumh3d-fx-bloom" checked
|
<input type="checkbox" id="drumh3d-fx-bloom" checked
|
||||||
onchange="window.drumH3dSetFx && window.drumH3dSetFx('bloom', this.checked)">
|
onchange="window.drumH3dSetFx && window.drumH3dSetFx('bloom', this.checked)">
|
||||||
@@ -546,6 +582,8 @@
|
|||||||
hydrateFxBool('timingFx', 'drumh3d-fx-timing');
|
hydrateFxBool('timingFx', 'drumh3d-fx-timing');
|
||||||
hydrateFxBool('streakFx', 'drumh3d-fx-streak');
|
hydrateFxBool('streakFx', 'drumh3d-fx-streak');
|
||||||
hydrateFxBool('cinematic', 'drumh3d-fx-cinematic');
|
hydrateFxBool('cinematic', 'drumh3d-fx-cinematic');
|
||||||
|
hydrateFxBool('bgReactive', 'drumh3d-fx-bgreactive');
|
||||||
|
hydrateFxBool('scoreFx', 'drumh3d-fx-scorefx');
|
||||||
const hydrateFxRange = (key, elId, valId) => {
|
const hydrateFxRange = (key, elId, valId) => {
|
||||||
const n = parseFloat(localStorage.getItem('drum_h3d_bg_' + key));
|
const n = parseFloat(localStorage.getItem('drum_h3d_bg_' + key));
|
||||||
if (!Number.isFinite(n)) return;
|
if (!Number.isFinite(n)) return;
|
||||||
@@ -556,6 +594,12 @@
|
|||||||
hydrateFxRange('hitFx', 'drumh3d-fx-hitfx', 'drumh3d-fx-hitfx-val');
|
hydrateFxRange('hitFx', 'drumh3d-fx-hitfx', 'drumh3d-fx-hitfx-val');
|
||||||
hydrateFxRange('glow', 'drumh3d-fx-glow', 'drumh3d-fx-glow-val');
|
hydrateFxRange('glow', 'drumh3d-fx-glow', 'drumh3d-fx-glow-val');
|
||||||
hydrateFxRange('vibrancy', 'drumh3d-fx-vibrancy', 'drumh3d-fx-vibrancy-val');
|
hydrateFxRange('vibrancy', 'drumh3d-fx-vibrancy', 'drumh3d-fx-vibrancy-val');
|
||||||
|
hydrateFxRange('bgIntensity', 'drumh3d-fx-bgintensity', 'drumh3d-fx-bgintensity-val');
|
||||||
|
const storedStyle = localStorage.getItem('drum_h3d_bg_style');
|
||||||
|
const styleSel = document.getElementById('drumh3d-fx-bgstyle');
|
||||||
|
if (storedStyle && Array.from(styleSel.options).some(o => o.value === storedStyle)) {
|
||||||
|
styleSel.value = storedStyle;
|
||||||
|
}
|
||||||
const storedTheme = localStorage.getItem('drum_h3d_bg_theme');
|
const storedTheme = localStorage.getItem('drum_h3d_bg_theme');
|
||||||
const themeSel = document.getElementById('drumh3d-fx-theme');
|
const themeSel = document.getElementById('drumh3d-fx-theme');
|
||||||
if (storedTheme && Array.from(themeSel.options).some(o => o.value === storedTheme)) {
|
if (storedTheme && Array.from(themeSel.options).some(o => o.value === storedTheme)) {
|
||||||
|
|||||||
@@ -148,3 +148,17 @@ test('FX defaults: theme-PR controls ship enabled at stock-neutral values', () =
|
|||||||
assert.equal(FX_DEFAULTS.glow, 0.5); // 0.5 = 1.0x multiplier (stock)
|
assert.equal(FX_DEFAULTS.glow, 0.5); // 0.5 = 1.0x multiplier (stock)
|
||||||
assert.equal(FX_DEFAULTS.vibrancy, 0.85); // ≈ the stock 0.32 stripe base
|
assert.equal(FX_DEFAULTS.vibrancy, 0.85); // ≈ the stock 0.32 stripe base
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('bg styles: validated id set, particles default, no out-of-scope styles', () => {
|
||||||
|
const { BG_STYLE_IDS, readBgStyleSetting } = load().__test;
|
||||||
|
// Host-realm copy — the vm array's foreign prototype trips deepEqual.
|
||||||
|
assert.deepEqual([...BG_STYLE_IDS], ['off', 'particles', 'lights', 'geometric']);
|
||||||
|
assert.equal(readBgStyleSetting(), 'particles'); // no localStorage in the vm
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FX defaults: ambience + score FX ship enabled', () => {
|
||||||
|
const { FX_DEFAULTS } = load().__test;
|
||||||
|
assert.equal(FX_DEFAULTS.scoreFx, true);
|
||||||
|
assert.equal(FX_DEFAULTS.bgIntensity, 0.5);
|
||||||
|
assert.equal(FX_DEFAULTS.bgReactive, true);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user