mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-16 21:53:23 +00:00
feat(drum_highway_3d): hit FX — sparks, timing colors, lane flashes, kick pulse, open hi-hat (D2) (#697)
- Pooled spark bursts (PORTED highway_3d, pool 160) at the struck lane, timing-colored via _timingHex/_classifyTiming (±50ms window, inner 40% = on-time); streak-scaled counts (streakFx) - Lane flashes resurrected as pooled additive gauss-tex quads at the hit line (timing-colored; red for wrong hits); kick = full-width quad - Kick pulse: camera dip + amber floor wash, exp decay, hitFx-scaled - Approach highlight: lane stripes brighten toward their next note - Open hi-hat: hh_open renders a warm ring around the gem (closes TODO); orthogonal to accent/ghost/flam variants - Settings: Hit sparks / Timing colours / Streak feedback toggles + Hit feedback intensity slider (drum_h3d_bg_*, live-applying) - All FX resources pooled/shared, disposed in BOTH teardown paths - Tests: _classifyTiming boundaries + FX defaults (10 total) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
beefd7bc09
commit
56419dc789
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **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: bloom glow + adaptive-resolution support — the first slice of visual parity with the guitar highway.** The drum highway now renders through the same post-processing path as `highway_3d`: an `UnrealBloomPass` (strength 0.65, radius 0.5, threshold 0.82 — high, so only emissive/bright surfaces bleed) on a multisampled HalfFloat target with ACES filmic tone mapping, so the white hit-line bar and proximity-lit notes get a real glow instead of a flat emissive tint. **On by default**, with a new **Graphics → "Glow (bloom)"** toggle in the plugin settings (`drum_h3d_bg_bloom`, applies live, no reload); if the vendored postprocessing addons can't load (older self-hosted core), the plugin silently falls back to the direct render path. The plugin also now honors the host's **adaptive render scale** (`bundle.renderScale` — the Quality/"Min res" controls that the guitar highway already respected), multiplying it into the device pixel ratio, and caps DPR at 1.25 when more than one viz instance is live (splitscreen) so two panels don't double the GPU fill cost. Groundwork for the rest of the parity series: an FX-settings scaffold (`FX_DEFAULTS`/`readFxSettings`/`window.drumH3dSetFx`, `drum_h3d_bg_*` localStorage keys) that the sparks/themes/backgrounds PRs extend, plus a first node test suite for the plugin (`plugins/drum_highway_3d/tests/data_layer.test.js` — vm-loaded like the keys plugin's, covering the hit-variant precedence, the Auto-mode steal-guard predicate, and FX defaults; 8 tests, runs in CI via the `plugins/*/tests/*.test.js` glob).
|
- **3D Drum Highway: bloom glow + adaptive-resolution support — the first slice of visual parity with the guitar highway.** The drum highway now renders through the same post-processing path as `highway_3d`: an `UnrealBloomPass` (strength 0.65, radius 0.5, threshold 0.82 — high, so only emissive/bright surfaces bleed) on a multisampled HalfFloat target with ACES filmic tone mapping, so the white hit-line bar and proximity-lit notes get a real glow instead of a flat emissive tint. **On by default**, with a new **Graphics → "Glow (bloom)"** toggle in the plugin settings (`drum_h3d_bg_bloom`, applies live, no reload); if the vendored postprocessing addons can't load (older self-hosted core), the plugin silently falls back to the direct render path. The plugin also now honors the host's **adaptive render scale** (`bundle.renderScale` — the Quality/"Min res" controls that the guitar highway already respected), multiplying it into the device pixel ratio, and caps DPR at 1.25 when more than one viz instance is live (splitscreen) so two panels don't double the GPU fill cost. Groundwork for the rest of the parity series: an FX-settings scaffold (`FX_DEFAULTS`/`readFxSettings`/`window.drumH3dSetFx`, `drum_h3d_bg_*` localStorage keys) that the sparks/themes/backgrounds PRs extend, plus a first node test suite for the plugin (`plugins/drum_highway_3d/tests/data_layer.test.js` — vm-loaded like the keys plugin's, covering the hit-variant precedence, the Auto-mode steal-guard predicate, and FX defaults; 8 tests, runs in CI via the `plugins/*/tests/*.test.js` glob).
|
||||||
- **3D Keys Highway: sharp HiDPI rendering, bloom glow, a live combo HUD, and a graphics settings panel — the first slice of visual parity with the guitar highway.** The biggest single fix is resolution: the plugin never called `setPixelRatio`, so on HiDPI/retina displays (and Windows display scaling) it rendered at CSS resolution and was upscaled — soft and aliased. It now multiplies the device pixel ratio (capped at 2, or 1.25 when two viz panels are live in splitscreen) with the host's **adaptive render scale** (`bundle.renderScale`, the Quality/"Min res" controls), exactly like `highway_3d`. On top of that: the same **bloom** post-processing path as the guitar highway (UnrealBloomPass 0.65/0.5/0.82 on a multisampled HalfFloat target + ACES tone mapping — the cyan hit-line, hit flames and the sustain "consume" glow finally bleed light instead of reading flat), **on by default** with a graceful direct-render fallback when the vendored addons can't load. The plugin gains its first **settings panel** (`settings.html`, Settings → graphics category, `"settings"` block in plugin.json) with a live-applying "Glow (bloom)" toggle (`keys3d_bg_bloom`), plus the FX scaffold (`FX_DEFAULTS`/`readFxSettings`/`window.keys3dSetFx`, `keys3d_bg_*` keys) the later parity PRs extend. And the score state the plugin was already tracking is finally visible: a **combo / accuracy / best-streak HUD** overlay (drum-highway pattern), shown only while a MIDI keyboard session is wired so it never renders a frozen 0× combo. Tests: `plugins/keys_highway_3d/tests/fx_settings.test.js` (defaults, localStorage overrides + type coercion, setter persist/dispatch/unknown-key guard; 3 tests alongside the existing 20).
|
- **3D Keys Highway: sharp HiDPI rendering, bloom glow, a live combo HUD, and a graphics settings panel — the first slice of visual parity with the guitar highway.** The biggest single fix is resolution: the plugin never called `setPixelRatio`, so on HiDPI/retina displays (and Windows display scaling) it rendered at CSS resolution and was upscaled — soft and aliased. It now multiplies the device pixel ratio (capped at 2, or 1.25 when two viz panels are live in splitscreen) with the host's **adaptive render scale** (`bundle.renderScale`, the Quality/"Min res" controls), exactly like `highway_3d`. On top of that: the same **bloom** post-processing path as the guitar highway (UnrealBloomPass 0.65/0.5/0.82 on a multisampled HalfFloat target + ACES tone mapping — the cyan hit-line, hit flames and the sustain "consume" glow finally bleed light instead of reading flat), **on by default** with a graceful direct-render fallback when the vendored addons can't load. The plugin gains its first **settings panel** (`settings.html`, Settings → graphics category, `"settings"` block in plugin.json) with a live-applying "Glow (bloom)" toggle (`keys3d_bg_bloom`), plus the FX scaffold (`FX_DEFAULTS`/`readFxSettings`/`window.keys3dSetFx`, `keys3d_bg_*` keys) the later parity PRs extend. And the score state the plugin was already tracking is finally visible: a **combo / accuracy / best-streak HUD** overlay (drum-highway pattern), shown only while a MIDI keyboard session is wired so it never renders a frozen 0× combo. Tests: `plugins/keys_highway_3d/tests/fx_settings.test.js` (defaults, localStorage overrides + type coercion, setter persist/dispatch/unknown-key guard; 3 tests alongside the existing 20).
|
||||||
- **The 3D Drum Highway and 3D Keys Highway are now bundled core plugins** (`plugins/drum_highway_3d/`, `plugins/keys_highway_3d/`), imported from their former standalone repos (`feedBack-plugin-drum-highway-3d`, `feedBack-plugin-keys-highway-3d`, now archived) via `git subtree` so their history is preserved. They join the other in-tree plugins-as-plugins: the loader treats them identically to user-installed ones, both are marked `"bundled": true` in their manifests, and `.gitignore` gains the matching `!plugins/<id>/` exceptions. This puts all three 3D highways (guitar, drums, keys) in one repo ahead of a visual-parity pass that ports the guitar highway's polish (bloom, sparks, themes, reactive backgrounds) to the other two — shared helper code and theme tables can now be reviewed and kept in sync in a single place. The keys plugin's existing node test suite is wired into CI (the JS test step gains a `plugins/*/tests/*.test.js` glob, +20 tests), and `static/tailwind.min.css` is regenerated since the core Tailwind build scans `plugins/**`. One deliberate behavior change ships with the bundling: the drum highway's Auto-mode predicate is **narrowed** (it used to claim any pack with `has_drum_tab` — a pack-level flag — which, now that the plugin ships to everyone and sorts before `highway_3d` in first-match-wins Auto order, would have stolen full-band packs from the guitar highway even on Lead/Bass arrangements; it now claims only drum arrangements, or packs nothing more specific can render). Picking the drum highway manually from the viz picker is unchanged.
|
- **The 3D Drum Highway and 3D Keys Highway are now bundled core plugins** (`plugins/drum_highway_3d/`, `plugins/keys_highway_3d/`), imported from their former standalone repos (`feedBack-plugin-drum-highway-3d`, `feedBack-plugin-keys-highway-3d`, now archived) via `git subtree` so their history is preserved. They join the other in-tree plugins-as-plugins: the loader treats them identically to user-installed ones, both are marked `"bundled": true` in their manifests, and `.gitignore` gains the matching `!plugins/<id>/` exceptions. This puts all three 3D highways (guitar, drums, keys) in one repo ahead of a visual-parity pass that ports the guitar highway's polish (bloom, sparks, themes, reactive backgrounds) to the other two — shared helper code and theme tables can now be reviewed and kept in sync in a single place. The keys plugin's existing node test suite is wired into CI (the JS test step gains a `plugins/*/tests/*.test.js` glob, +20 tests), and `static/tailwind.min.css` is regenerated since the core Tailwind build scans `plugins/**`. One deliberate behavior change ships with the bundling: the drum highway's Auto-mode predicate is **narrowed** (it used to claim any pack with `has_drum_tab` — a pack-level flag — which, now that the plugin ships to everyone and sorts before `highway_3d` in first-match-wins Auto order, would have stolen full-band packs from the guitar highway even on Lead/Bass arrangements; it now claims only drum arrangements, or packs nothing more specific can render). Picking the drum highway manually from the viz picker is unchanged.
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ tunes one of these, mirror the change here (and in `keys_highway_3d`):
|
|||||||
mapping switch in `draw()`; addons dynamic-imported from
|
mapping switch in `draw()`; addons dynamic-imported from
|
||||||
`/static/vendor/three/addons/` (no CDN fallback — direct render is the
|
`/static/vendor/three/addons/` (no CDN fallback — direct render is the
|
||||||
graceful degrade)
|
graceful degrade)
|
||||||
|
- `_sparkBurst()` / `_sparkUpdate()` — pooled additive Points hit sparks
|
||||||
|
(pool 160 here vs the guitar's 256)
|
||||||
|
- `_makeGaussTex()` — soft-falloff DataTexture for the additive lane-flash
|
||||||
|
quads
|
||||||
|
- `_timingHex()` — early/late/on-time feedback colors (green/cyan/amber)
|
||||||
|
- `_ssActive()` — host splitscreen probe (minus the guitar's focus-API
|
||||||
|
checks, which it needs for input routing and we don't)
|
||||||
|
|
||||||
## Why a separate plugin (vs. drum mode inside highway_3d)?
|
## Why a separate plugin (vs. drum mode inside highway_3d)?
|
||||||
|
|
||||||
|
|||||||
@@ -297,6 +297,43 @@
|
|||||||
}
|
}
|
||||||
_rebuildPieceToLaneMap(_activeKit);
|
_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
|
// Resolve a drum_tab hit's visual variant. Order matters: ghost wins over
|
||||||
// accent (ghost notes are intentionally quiet so their flag dominates),
|
// accent (ghost notes are intentionally quiet so their flag dominates),
|
||||||
// flam adds the leading grace disc, ride_bell adds the bright dot. A
|
// flam adds the leading grace disc, ride_bell adds the bright dot. A
|
||||||
@@ -1074,6 +1111,10 @@
|
|||||||
// Everything defaults ON — the settings screen is the opt-out.
|
// Everything defaults ON — the settings screen is the opt-out.
|
||||||
const FX_DEFAULTS = {
|
const FX_DEFAULTS = {
|
||||||
bloom: true,
|
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)
|
||||||
};
|
};
|
||||||
const FX_LS_PREFIX = 'drum_h3d_bg_';
|
const FX_LS_PREFIX = 'drum_h3d_bg_';
|
||||||
|
|
||||||
@@ -1091,7 +1132,10 @@
|
|||||||
else if (raw === '0' || raw === 'false') fx[k] = false;
|
else if (raw === '0' || raw === 'false') fx[k] = false;
|
||||||
} else {
|
} else {
|
||||||
const n = parseFloat(raw);
|
const n = parseFloat(raw);
|
||||||
if (Number.isFinite(n)) fx[k] = n;
|
// 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 */ }
|
} catch (_) { /* localStorage unavailable — use defaults */ }
|
||||||
@@ -1111,6 +1155,7 @@
|
|||||||
} else {
|
} else {
|
||||||
v = Number(value);
|
v = Number(value);
|
||||||
if (!Number.isFinite(v)) return;
|
if (!Number.isFinite(v)) return;
|
||||||
|
v = Math.min(1, Math.max(0, v)); // all numeric FX keys are 0-1
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(FX_LS_PREFIX + key, typeof v === 'boolean' ? (v ? '1' : '0') : String(v));
|
localStorage.setItem(FX_LS_PREFIX + key, typeof v === 'boolean' ? (v ? '1' : '0') : String(v));
|
||||||
@@ -1148,6 +1193,21 @@
|
|||||||
let _bloomW = 0, _bloomH = 0;
|
let _bloomW = 0, _bloomH = 0;
|
||||||
let _bloomGen = 0; // bumped by _bloomDispose so stale loads no-op
|
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 = 0, _camBaseD = 0; // positionCamera's unpulsed pose
|
||||||
|
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
|
||||||
|
|
||||||
// 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
|
||||||
@@ -1178,6 +1238,8 @@
|
|||||||
let gSnareStripe = null;
|
let gSnareStripe = null;
|
||||||
let gBellDot = null;
|
let gBellDot = null;
|
||||||
let gFlamGrace = 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
|
// Demo loop clock — anchored to performance.now() so animation
|
||||||
// proceeds regardless of audio playback state.
|
// proceeds regardless of audio playback state.
|
||||||
@@ -1273,16 +1335,128 @@
|
|||||||
// Wallclock decay window — matches the 2D plugin's 300 ms flash.
|
// Wallclock decay window — matches the 2D plugin's 300 ms flash.
|
||||||
const FLASH_MS = 300;
|
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() {
|
function _applyLaneFlashes() {
|
||||||
// The visual consumer (pooled additive lane-flash quads) lands in
|
// Drop expired entries first so the buffer can't grow forever if
|
||||||
// the hit-FX parity PR; chart notes already turn green/red on
|
// MIDI hits arrive while no chart is loaded.
|
||||||
// hit/miss via placeNote. Until then just drop expired entries so
|
|
||||||
// the buffer doesn't grow forever if MIDI hits arrive while no
|
|
||||||
// chart is loaded.
|
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
while (_laneFlashes.length && now - _laneFlashes[0].wall > FLASH_MS) {
|
while (_laneFlashes.length && now - _laneFlashes[0].wall > FLASH_MS) {
|
||||||
_laneFlashes.shift();
|
_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
|
/* ── Bloom (PORTED FROM highway_3d/screen.js _bloomEnsure — keep in
|
||||||
@@ -1387,13 +1561,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!_latestNotes || _latestNotes.length === 0) {
|
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' });
|
_laneFlashes.push({ lane, wall: performance.now(), kind: 'hit' });
|
||||||
|
_spawnHitFx(lane, null, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = _latestTime;
|
const t = _latestTime;
|
||||||
|
|
||||||
let foundHit = false;
|
let foundHit = false;
|
||||||
|
let matchedDelta = 0; // note.t - now of the matched note (timing verdict)
|
||||||
for (const n of _latestNotes) {
|
for (const n of _latestNotes) {
|
||||||
if (n.t > t + HIT_TOLERANCE_S) break;
|
if (n.t > t + HIT_TOLERANCE_S) break;
|
||||||
if (n.t < t - HIT_TOLERANCE_S) continue;
|
if (n.t < t - HIT_TOLERANCE_S) continue;
|
||||||
@@ -1402,6 +1581,7 @@
|
|||||||
if (_hitKeys.has(key)) continue;
|
if (_hitKeys.has(key)) continue;
|
||||||
_hitKeys.add(key);
|
_hitKeys.add(key);
|
||||||
foundHit = true;
|
foundHit = true;
|
||||||
|
matchedDelta = n.t - t;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1409,7 +1589,9 @@
|
|||||||
_hits++;
|
_hits++;
|
||||||
_streak++;
|
_streak++;
|
||||||
if (_streak > _bestStreak) _bestStreak = _streak;
|
if (_streak > _bestStreak) _bestStreak = _streak;
|
||||||
_laneFlashes.push({ lane, wall: performance.now(), kind: 'hit' });
|
const ts = _classifyTiming(matchedDelta, HIT_TOLERANCE_S);
|
||||||
|
_laneFlashes.push({ lane, wall: performance.now(), kind: 'hit', ts });
|
||||||
|
_spawnHitFx(lane, ts, false);
|
||||||
} else {
|
} else {
|
||||||
_misses++;
|
_misses++;
|
||||||
_streak = 0;
|
_streak = 0;
|
||||||
@@ -1612,6 +1794,73 @@
|
|||||||
DISC_H,
|
DISC_H,
|
||||||
24,
|
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.
|
// Halo and ghost ring materials — additive emissive, palette-agnostic.
|
||||||
mAccentRing = new T.MeshBasicMaterial({
|
mAccentRing = new T.MeshBasicMaterial({
|
||||||
@@ -1649,12 +1898,17 @@
|
|||||||
// tilt down so the lanes still fit the frame.
|
// tilt down so the lanes still fit the frame.
|
||||||
const h = (45 + 180 * a) * K;
|
const h = (45 + 180 * a) * K;
|
||||||
const d = (60 + 60 * (1 - 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.position.set(0, h, d);
|
||||||
cam.lookAt(0, 0, -AHEAD * TS * 0.45);
|
cam.lookAt(0, 0, -AHEAD * TS * 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLanes(_floorW, floorD) {
|
function buildLanes(_floorW, floorD) {
|
||||||
laneGroup = new T.Group();
|
laneGroup = new T.Group();
|
||||||
|
laneStripeMats = [];
|
||||||
// Alternating lane stripes for the 7 hand lanes (same teal/blue
|
// Alternating lane stripes for the 7 hand lanes (same teal/blue
|
||||||
// as highway_3d, but a notch darker so the brighter discs pop).
|
// as highway_3d, but a notch darker so the brighter discs pop).
|
||||||
const colors = [0x2d5476, 0x42759d];
|
const colors = [0x2d5476, 0x42759d];
|
||||||
@@ -1670,6 +1924,10 @@
|
|||||||
stripe.rotation.x = -Math.PI / 2;
|
stripe.rotation.x = -Math.PI / 2;
|
||||||
stripe.position.set(x, -0.25 * K, -floorD / 2 + BEHIND * TS);
|
stripe.position.set(x, -0.25 * K, -floorD / 2 + BEHIND * TS);
|
||||||
laneGroup.add(stripe);
|
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);
|
scene.add(laneGroup);
|
||||||
}
|
}
|
||||||
@@ -1741,7 +1999,7 @@
|
|||||||
|
|
||||||
/* -- per-frame note rendering ----------------------------------- */
|
/* -- per-frame note rendering ----------------------------------- */
|
||||||
|
|
||||||
function buildNoteMesh(lane, variant) {
|
function buildNoteMesh(lane, variant, open) {
|
||||||
const laneCfg = LANES[lane];
|
const laneCfg = LANES[lane];
|
||||||
const group = new T.Group();
|
const group = new T.Group();
|
||||||
|
|
||||||
@@ -1820,10 +2078,16 @@
|
|||||||
gem.scale.setScalar(scale);
|
gem.scale.setScalar(scale);
|
||||||
group.add(gem);
|
group.add(gem);
|
||||||
|
|
||||||
if (laneCfg.subKind === 'hihat') {
|
if (laneCfg.subKind === 'hihat' && open) {
|
||||||
// Open-hat hint: a thin ring around the gem (closed-hat
|
// Open-hat: a thin warm ring around the gem (closed-hat
|
||||||
// is the bare gem). For the mockup we don't differentiate
|
// stays the bare gem) — mirrors standard drum notation's
|
||||||
// open/closed yet — TODO: drive from variant.
|
// "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') {
|
if (laneCfg.subKind === 'ride' && variant === 'bell') {
|
||||||
const dot = new T.Mesh(gBellDot, mBellDot);
|
const dot = new T.Mesh(gBellDot, mBellDot);
|
||||||
@@ -1856,7 +2120,9 @@
|
|||||||
for (const h of hits) {
|
for (const h of hits) {
|
||||||
const lane = PIECE_TO_LANE[h.p];
|
const lane = PIECE_TO_LANE[h.p];
|
||||||
if (lane === undefined) continue; // unknown piece — skip silently
|
if (lane === undefined) continue; // unknown piece — skip silently
|
||||||
out.push({ lane, t: +h.t || 0, variant: _variantForHit(h) });
|
// `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);
|
out.sort((a, b) => a.t - b.t);
|
||||||
return out;
|
return out;
|
||||||
@@ -1870,6 +2136,13 @@
|
|||||||
disposeMeshTree(c);
|
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,
|
// Real-data path: when the active sloppak ships a drum_tab,
|
||||||
// bundle.drumTab is populated by static/highway.js (slopsmith
|
// bundle.drumTab is populated by static/highway.js (slopsmith
|
||||||
// drums-from-scratch PR). Otherwise fall back to the hardcoded
|
// drums-from-scratch PR). Otherwise fall back to the hardcoded
|
||||||
@@ -1971,12 +2244,16 @@
|
|||||||
const x = laneCfg.kind === 'kick' ? 0 : (LANE_X0 + note.lane * LANE_GAP);
|
const x = laneCfg.kind === 'kick' ? 0 : (LANE_X0 + note.lane * LANE_GAP);
|
||||||
const y = laneCfg.kind === 'kick' ? 0 : DISC_H * 0.5;
|
const y = laneCfg.kind === 'kick' ? 0 : DISC_H * 0.5;
|
||||||
|
|
||||||
const mesh = buildNoteMesh(note.lane, note.variant);
|
const mesh = buildNoteMesh(note.lane, note.variant, note.open);
|
||||||
mesh.position.set(x, y, z);
|
mesh.position.set(x, y, z);
|
||||||
|
|
||||||
// Brighten emissive as the note approaches the hit line.
|
// Brighten emissive as the note approaches the hit line.
|
||||||
// 0 at AHEAD, peak at 0, then linger briefly past it.
|
// 0 at AHEAD, peak at 0, then linger briefly past it.
|
||||||
const proximity = Math.max(0, 1 - Math.abs(dt) / 0.6);
|
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;
|
||||||
|
}
|
||||||
if (laneCfg.kind === 'drum' && note.variant !== 'ghost' && mDrumByLane[note.lane]) {
|
if (laneCfg.kind === 'drum' && note.variant !== 'ghost' && mDrumByLane[note.lane]) {
|
||||||
// Subtle pulse via emissiveIntensity — palette-driven base + pulse.
|
// Subtle pulse via emissiveIntensity — palette-driven base + pulse.
|
||||||
mDrumByLane[note.lane].emissiveIntensity = 0.45 + proximity * 0.35;
|
mDrumByLane[note.lane].emissiveIntensity = 0.45 + proximity * 0.35;
|
||||||
@@ -2096,6 +2373,7 @@
|
|||||||
// initScene() on false (the scene would draw into nothing).
|
// initScene() on false (the scene would draw into nothing).
|
||||||
function _disposeScene() {
|
function _disposeScene() {
|
||||||
_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();
|
||||||
if (notesGroup) {
|
if (notesGroup) {
|
||||||
while (notesGroup.children.length) {
|
while (notesGroup.children.length) {
|
||||||
disposeMeshTree(notesGroup.children.pop());
|
disposeMeshTree(notesGroup.children.pop());
|
||||||
@@ -2138,6 +2416,8 @@
|
|||||||
if (gSnareStripe) gSnareStripe.dispose();
|
if (gSnareStripe) gSnareStripe.dispose();
|
||||||
if (gBellDot) gBellDot.dispose();
|
if (gBellDot) gBellDot.dispose();
|
||||||
if (gFlamGrace) gFlamGrace.dispose();
|
if (gFlamGrace) gFlamGrace.dispose();
|
||||||
|
if (gOpenRing) gOpenRing.dispose();
|
||||||
|
if (mOpenRing) mOpenRing.dispose();
|
||||||
if (ren) ren.dispose();
|
if (ren) ren.dispose();
|
||||||
scene = cam = ren = lights = laneGroup = kitMapGroup = notesGroup = null;
|
scene = cam = ren = lights = laneGroup = kitMapGroup = notesGroup = null;
|
||||||
mDrumByLane = mCymbalByLane = mKick = mKickHit = mKickMiss = null;
|
mDrumByLane = mCymbalByLane = mKick = mKickHit = mKickMiss = null;
|
||||||
@@ -2145,6 +2425,7 @@
|
|||||||
mAccentRing = mGhostRing = mSnareStripe = mBellDot = null;
|
mAccentRing = mGhostRing = mSnareStripe = mBellDot = null;
|
||||||
gDrumDisc = gCymbalGem = gKickBar = null;
|
gDrumDisc = gCymbalGem = gKickBar = null;
|
||||||
gAccentRing = gGhostRing = gSnareStripe = gBellDot = gFlamGrace = null;
|
gAccentRing = gGhostRing = gSnareStripe = gBellDot = gFlamGrace = null;
|
||||||
|
gOpenRing = mOpenRing = null;
|
||||||
// Re-create the renderer against the existing canvas. initScene
|
// Re-create the renderer against the existing canvas. initScene
|
||||||
// populates everything we just disposed.
|
// populates everything we just disposed.
|
||||||
try {
|
try {
|
||||||
@@ -2164,6 +2445,7 @@
|
|||||||
|
|
||||||
function teardown() {
|
function teardown() {
|
||||||
_bloomDispose();
|
_bloomDispose();
|
||||||
|
_disposeFxObjects();
|
||||||
// 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
|
||||||
@@ -2210,6 +2492,8 @@
|
|||||||
if (gSnareStripe) gSnareStripe.dispose();
|
if (gSnareStripe) gSnareStripe.dispose();
|
||||||
if (gBellDot) gBellDot.dispose();
|
if (gBellDot) gBellDot.dispose();
|
||||||
if (gFlamGrace) gFlamGrace.dispose();
|
if (gFlamGrace) gFlamGrace.dispose();
|
||||||
|
if (gOpenRing) gOpenRing.dispose();
|
||||||
|
if (mOpenRing) mOpenRing.dispose();
|
||||||
if (ren) ren.dispose();
|
if (ren) ren.dispose();
|
||||||
scene = cam = ren = lights = laneGroup = kitMapGroup = notesGroup = null;
|
scene = cam = ren = lights = laneGroup = kitMapGroup = notesGroup = null;
|
||||||
mDrumByLane = mCymbalByLane = mKick = mKickHit = mKickMiss = null;
|
mDrumByLane = mCymbalByLane = mKick = mKickHit = mKickMiss = null;
|
||||||
@@ -2217,6 +2501,7 @@
|
|||||||
mAccentRing = mGhostRing = mSnareStripe = mBellDot = null;
|
mAccentRing = mGhostRing = mSnareStripe = mBellDot = null;
|
||||||
gDrumDisc = gCymbalGem = gKickBar = null;
|
gDrumDisc = gCymbalGem = gKickBar = null;
|
||||||
gAccentRing = gGhostRing = gSnareStripe = gBellDot = gFlamGrace = null;
|
gAccentRing = gGhostRing = gSnareStripe = gBellDot = gFlamGrace = null;
|
||||||
|
gOpenRing = mOpenRing = null;
|
||||||
_isReady = false;
|
_isReady = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2318,6 +2603,28 @@
|
|||||||
applySize(highwayCanvas.clientWidth, highwayCanvas.clientHeight);
|
applySize(highwayCanvas.clientWidth, highwayCanvas.clientHeight);
|
||||||
}
|
}
|
||||||
rebuildNotes(bundle);
|
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);
|
||||||
|
if (_kickPulse > 0.001) {
|
||||||
|
_kickPulse *= Math.exp(-fdt * 7);
|
||||||
|
cam.position.y = _camBaseH - 0.8 * K * _kickPulse * fx.hitFx;
|
||||||
|
if (_floorFlash) _floorFlash.material.opacity = 0.25 * _kickPulse * fx.hitFx;
|
||||||
|
} else if (_kickPulse !== 0) {
|
||||||
|
_kickPulse = 0;
|
||||||
|
cam.position.y = _camBaseH;
|
||||||
|
if (_floorFlash) _floorFlash.material.opacity = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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 = 0.32 + 0.28 * (_laneProx[i] || 0) * fx.hitFx;
|
||||||
|
}
|
||||||
_applyLaneFlashes();
|
_applyLaneFlashes();
|
||||||
_refreshHud();
|
_refreshHud();
|
||||||
// Bloom path (PORTED FROM highway_3d): composer + ACES tone
|
// Bloom path (PORTED FROM highway_3d): composer + ACES tone
|
||||||
@@ -2360,6 +2667,25 @@
|
|||||||
// Exposed for module-level MIDI router. The receiver runs on
|
// Exposed for module-level MIDI router. The receiver runs on
|
||||||
// every note-on dispatched to _activeInstance.
|
// every note-on dispatched to _activeInstance.
|
||||||
_handleDrumHit,
|
_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;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sparksLive: !!(_sparkPts && _sparkPts.visible),
|
||||||
|
maxFlash,
|
||||||
|
kickPulse: _kickPulse,
|
||||||
|
flashCount: _laneFlashes.length,
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
@@ -2404,9 +2730,27 @@
|
|||||||
// free to call).
|
// free to call).
|
||||||
window.slopsmithViz_drum_highway_3d.__test = {
|
window.slopsmithViz_drum_highway_3d.__test = {
|
||||||
_variantForHit,
|
_variantForHit,
|
||||||
|
_classifyTiming,
|
||||||
readFxSettings,
|
readFxSettings,
|
||||||
FX_DEFAULTS,
|
FX_DEFAULTS,
|
||||||
MIDI_TO_PIECE,
|
MIDI_TO_PIECE,
|
||||||
HIT_TOLERANCE_S,
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -54,6 +54,46 @@
|
|||||||
Soft light-bleed around the hit line and bright notes. Applies
|
Soft light-bleed around the hit line and bright notes. Applies
|
||||||
live; turn off to reclaim GPU headroom on weak machines.
|
live; turn off to reclaim GPU headroom on weak machines.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<label for="drumh3d-fx-sparks" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||||
|
<input type="checkbox" id="drumh3d-fx-sparks" checked
|
||||||
|
onchange="window.drumH3dSetFx && window.drumH3dSetFx('sparks', this.checked)">
|
||||||
|
Hit sparks
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
A small particle burst on every scored pad hit.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label for="drumh3d-fx-timing" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||||
|
<input type="checkbox" id="drumh3d-fx-timing" checked
|
||||||
|
onchange="window.drumH3dSetFx && window.drumH3dSetFx('timingFx', this.checked)">
|
||||||
|
Timing colours
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
Tint hit feedback by timing — on-time green, early cyan, late
|
||||||
|
amber. Off = always green.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label for="drumh3d-fx-streak" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||||
|
<input type="checkbox" id="drumh3d-fx-streak" checked
|
||||||
|
onchange="window.drumH3dSetFx && window.drumH3dSetFx('streakFx', this.checked)">
|
||||||
|
Streak feedback
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
Spark bursts grow with your combo.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label for="drumh3d-fx-hitfx" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||||
|
Hit feedback intensity <span id="drumh3d-fx-hitfx-val" class="text-gray-500 font-mono">0.70</span>
|
||||||
|
</label>
|
||||||
|
<input type="range" id="drumh3d-fx-hitfx"
|
||||||
|
min="0" max="1" step="0.05" value="0.7"
|
||||||
|
oninput="window.drumH3dSetFx && window.drumH3dSetFx('hitFx', this.value); document.getElementById('drumh3d-fx-hitfx-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||||
|
class="w-full">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
Drives the lane flashes, approach glow and the kick camera pulse.
|
||||||
|
0 turns them off.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MIDI input -->
|
<!-- MIDI input -->
|
||||||
@@ -440,11 +480,20 @@
|
|||||||
// FX toggles (drum_h3d_bg_* — guitar-parity graphics controls).
|
// FX toggles (drum_h3d_bg_* — guitar-parity graphics controls).
|
||||||
// Only explicit values override; absent/corrupt keys keep the
|
// Only explicit values override; absent/corrupt keys keep the
|
||||||
// default (ON), matching screen.js readFxSettings.
|
// default (ON), matching screen.js readFxSettings.
|
||||||
const storedBloom = localStorage.getItem('drum_h3d_bg_bloom');
|
const hydrateFxBool = (key, elId) => {
|
||||||
if (storedBloom === '1' || storedBloom === 'true') {
|
const raw = localStorage.getItem('drum_h3d_bg_' + key);
|
||||||
document.getElementById('drumh3d-fx-bloom').checked = true;
|
if (raw === '1' || raw === 'true') document.getElementById(elId).checked = true;
|
||||||
} else if (storedBloom === '0' || storedBloom === 'false') {
|
else if (raw === '0' || raw === 'false') document.getElementById(elId).checked = false;
|
||||||
document.getElementById('drumh3d-fx-bloom').checked = false;
|
};
|
||||||
|
hydrateFxBool('bloom', 'drumh3d-fx-bloom');
|
||||||
|
hydrateFxBool('sparks', 'drumh3d-fx-sparks');
|
||||||
|
hydrateFxBool('timingFx', 'drumh3d-fx-timing');
|
||||||
|
hydrateFxBool('streakFx', 'drumh3d-fx-streak');
|
||||||
|
const storedHitFx = parseFloat(localStorage.getItem('drum_h3d_bg_hitFx'));
|
||||||
|
if (Number.isFinite(storedHitFx)) {
|
||||||
|
const v = Math.min(1, Math.max(0, storedHitFx));
|
||||||
|
document.getElementById('drumh3d-fx-hitfx').value = String(v);
|
||||||
|
document.getElementById('drumh3d-fx-hitfx-val').textContent = v.toFixed(2);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Drum-Hwy3D settings] hydration failed:', e);
|
console.warn('[Drum-Hwy3D settings] hydration failed:', e);
|
||||||
|
|||||||
@@ -92,3 +92,26 @@ test('MIDI map: open hi-hat is a first-class piece (46 → hh_open)', () => {
|
|||||||
// ±50 ms window matches the 2D drums plugin.
|
// ±50 ms window matches the 2D drums plugin.
|
||||||
assert.equal(HIT_TOLERANCE_S, 0.05);
|
assert.equal(HIT_TOLERANCE_S, 0.05);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('_classifyTiming: OK band is 40% of the window, sign maps early/late', () => {
|
||||||
|
const { _classifyTiming, HIT_TOLERANCE_S } = load().__test;
|
||||||
|
const tol = HIT_TOLERANCE_S; // 0.05
|
||||||
|
assert.equal(_classifyTiming(0, tol), 'OK');
|
||||||
|
assert.equal(_classifyTiming(tol * 0.4, tol), 'OK'); // boundary inclusive
|
||||||
|
assert.equal(_classifyTiming(-tol * 0.4, tol), 'OK');
|
||||||
|
// delta = note.t - now: positive → struck before the note → EARLY.
|
||||||
|
assert.equal(_classifyTiming(tol * 0.41, tol), 'EARLY');
|
||||||
|
assert.equal(_classifyTiming(-tol * 0.41, tol), 'LATE');
|
||||||
|
assert.equal(_classifyTiming(tol, tol), 'EARLY');
|
||||||
|
assert.equal(_classifyTiming(-tol, tol), 'LATE');
|
||||||
|
// Degenerate inputs read as on-time rather than throwing.
|
||||||
|
assert.equal(_classifyTiming(NaN, tol), 'OK');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FX defaults: hit-FX controls ship enabled', () => {
|
||||||
|
const { FX_DEFAULTS } = load().__test;
|
||||||
|
assert.equal(FX_DEFAULTS.sparks, true);
|
||||||
|
assert.equal(FX_DEFAULTS.timingFx, true);
|
||||||
|
assert.equal(FX_DEFAULTS.streakFx, true);
|
||||||
|
assert.equal(FX_DEFAULTS.hitFx, 0.7);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user