diff --git a/plugins/highway_3d/plugin.json b/plugins/highway_3d/plugin.json index 10b0182..437c2be 100644 --- a/plugins/highway_3d/plugin.json +++ b/plugins/highway_3d/plugin.json @@ -1,7 +1,7 @@ { "id": "highway_3d", "name": "3D Highway", - "version": "3.45.0", + "version": "3.46.0", "type": "visualization", "scriptType": "module", "bundled": true, diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 9162fe3..8b0b23d 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -15,6 +15,7 @@ import { createMaterialBuilders } from './src/materials.js'; // h3d-carve-6 import { createOverlay } from './src/overlay.js'; // h3d-carve-7 import { createFx } from './src/fx.js'; // h3d-carve-8 import { createCamera } from './src/camera.js'; // h3d-carve-9 +import { createScoreFx } from './src/score-fx.js'; // h3d-carve-10 (function () { 'use strict'; @@ -3616,108 +3617,16 @@ import { createCamera } from './src/camera.js'; // h3d-carve-9 // silent — see the live-latch handling in the per-gem loop below). let _susVerdictLatch = new Map(); - // ── Score FX (notedetect game-scoring layer, notedetect ≥1.13) ── - // Two channels: (1) per-note "+N" score pops, sourced from the - // note-state provider's new { points, mult, popKey } fields at the - // moment a gem's verdict lands; (2) session-level bursts/pulses from - // the new `notedetect:fx` event (streak milestones, multiplier tier - // changes, streak breaks). Everything renders on the 2D overlay - // canvas (same layer as drawNotedetectLabels) — no Three.js objects, - // no txtMat() cache entries, nothing to dispose. Pools are fixed- - // size slot arrays created once per factory instance; when all slots - // are busy a new effect is simply dropped. - const _FX_POP_LIFE_MS = 700; - const _FX_BURST_LIFE_MS = 900; - const _FX_BURST_N = 36; - const _fxPops = Array.from({ length: 24 }, () => ( - { active: false, x: 0, y: 0, z: 0, bornMs: 0, text: '', mult: 1 } - )); - const _fxBursts = Array.from({ length: 4 }, () => ({ - active: false, bornMs: 0, - px: new Float32Array(_FX_BURST_N), py: new Float32Array(_FX_BURST_N), - vx: new Float32Array(_FX_BURST_N), vy: new Float32Array(_FX_BURST_N), - })); - // popKey -> expiry ms. Dedupes pops (chord members share the chord's - // popKey; sustains keep returning points for the whole glow window). - const _fxSeen = new Map(); - let _fxOnFx = null; // notedetect:fx listener (window) - let _fxOnSkin = null; // notedetect:skin bus listener - // Generation counter: bumped by teardown() so the deferred window- - // copy fallback (a zero-delay task the listener removal can't cancel) - // bails instead of re-arming ring/burst state after teardown — or, - // worse, leaking a stale event into a subsequent init's fresh state. - let _fxGen = 0; - let _fxLastFxDetail = null; // reference dedup: window + instanceRoot dispatches share one detail - // Details seen via element-scoped (bubbled) dispatch. A WeakSet, not a - // single slot: one judged hit can emit several fx in the same task - // (milestone + multiplier tier-up), and the deferred window-copy - // fallback for the FIRST must still see that its element copy arrived - // after the SECOND overwrote any last-detail slot. GC reclaims - // entries once notedetect drops the detail objects. - let _fxElemSeen = new WeakSet(); - let _fxRingMs = -1e9; // multiplier ring-pulse anchor - let _fxRingMult = 1; - let _fxBreakMs = -1e9; // streak-break flicker anchor - // Canvas-side palette per notedetect skin (mirrors the accents in - // notedetect's assets/plugin.css; fonts are document-loaded by that - // stylesheet so the overlay canvas can use the family names). - const _FX_PALETTES = { - neon: { accent: '#00f0ff', accent2: '#ff2ec4', miss: '#ff4444', font: 'Orbitron' }, - esports: { accent: '#e8b43a', accent2: '#f5f5f4', miss: '#f87171', font: 'Rajdhani' }, - metal: { accent: '#ffb347', accent2: '#ff6b35', miss: '#ef4444', font: 'Russo One' }, - }; - let _fxPalette = _FX_PALETTES.neon; - function _fxResolvePalette() { - let skin = null; - try { skin = localStorage.getItem('feedBack_notedetect_skin'); } catch (e) {} - _fxPalette = _FX_PALETTES[skin] || _FX_PALETTES.neon; - } - function _fxSpawnPop(popKey, points, mult, x, y, z) { - if (_fxSeen.has(popKey)) return; - const nowMs = _ndFrameNowMs || performance.now(); - _fxSeen.set(popKey, nowMs + 4000); - for (let i = 0; i < _fxPops.length; i++) { - const p = _fxPops[i]; - if (p.active) continue; - p.active = true; - p.x = x; p.y = y; p.z = z; - p.bornMs = nowMs; - p.text = '+' + points; - p.mult = mult || 1; - return; - } - } - function _fxSpawnBurst(nowMs) { - for (let i = 0; i < _fxBursts.length; i++) { - const b = _fxBursts[i]; - if (b.active) continue; - b.active = true; - b.bornMs = nowMs; - for (let j = 0; j < _FX_BURST_N; j++) { - const a = (j / _FX_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; - } - } - function _fxHandle(d) { - // Reference dedup — notedetect dispatches the SAME detail object - // on window and on its instanceRoot; whichever arrives first wins. - if (d === _fxLastFxDetail) return; - _fxLastFxDetail = d; - const nowMs = performance.now(); - if (d.fxType === 'milestone') { - _fxSpawnBurst(nowMs); - } else if (d.fxType === 'multiplier' && d.mult > (d.prevMult || 1)) { - _fxRingMs = nowMs; - _fxRingMult = d.mult; - } else if (d.fxType === 'streakBreak') { - _fxBreakMs = nowMs; - } - } + /* ── h3d-carve-10: K-section (score FX) → src/score-fx.js ──────── */ + const { fxInit, fxTeardown, fxSpawnPop: _fxSpawnPop, drawScoreFx } = createScoreFx({ + getHighwayCanvas: () => highwayCanvas, + getNdFrameNowMs: () => _ndFrameNowMs, + getCam: () => cam, + getProbe: () => _probe, + getNStr: () => nStr, + getCurX: () => curX, + sY, + }); // Object pools let pNote, pSus, pLbl, pBeat, pSec; @@ -5824,42 +5733,8 @@ import { createCamera } from './src/camera.js'; // h3d-carve-9 window.feedBack.on('note:miss', _ndOnBusMiss); } - // Score FX (notedetect ≥1.13). notedetect dispatches each fx - // detail object twice in the same task: first explicitly on - // window (unscoped), then as a bubbling CustomEvent from its - // per-panel instanceRoot (scoped). Element-targeted copies are - // authoritative — accept only the ones whose root lives in this - // panel's container. The window copy is DEFERRED a task: by the - // time it runs, the element copy (same detail reference) has - // either arrived — making the window copy a duplicate to drop — - // or it never will (detector root not attached to the DOM), in - // which case the window copy is the compat fallback. This keeps - // splitscreen panels from rendering each other's FX even for - // the first event of a session. - _fxResolvePalette(); - _fxOnFx = (e) => { - const d = e && e.detail; - if (!d) return; - const t = e.target; - if (t && t.parentElement) { - _fxElemSeen.add(d); - if (!highwayCanvas || !t.parentElement.contains(highwayCanvas)) return; - _fxHandle(d); - return; - } - const gen = _fxGen; - setTimeout(() => { - if (gen !== _fxGen) return; // torn down (or re-inited) meanwhile - if (_fxElemSeen.has(d)) return; - _fxHandle(d); - }, 0); - }; - window.addEventListener('notedetect:fx', _fxOnFx); - if (window.feedBack && typeof window.feedBack.on === 'function' - && typeof window.feedBack.off === 'function') { - _fxOnSkin = () => _fxResolvePalette(); - window.feedBack.on('notedetect:skin', _fxOnSkin); - } + /* ── h3d-carve-10: score-FX init → fxInit() in src/score-fx.js */ + fxInit(); return true; } @@ -12556,120 +12431,7 @@ import { createCamera } from './src/camera.js'; // h3d-carve-9 ctx.restore(); } - // Score FX overlay pass — "+N" pops rising off their gems, milestone - // particle bursts / multiplier ring-pulses / streak-break flickers - // anchored on the strike line. Same overlay layer + projection - // pattern as drawNotedetectLabels; costs one early-out when nothing - // is active. - function drawScoreFx(ctx, W, H) { - if (!cam || !_probe) return; - const nowMs = _ndFrameNowMs || performance.now(); - // TTL-prune the pop dedup keys (bounded: only notes hit in the - // last few seconds). - if (_fxSeen.size) { - for (const [k, exp] of _fxSeen) { - if (exp <= nowMs) _fxSeen.delete(k); - } - } - let anyPop = false; - for (let i = 0; i < _fxPops.length; i++) { - if (_fxPops[i].active) { anyPop = true; break; } - } - let anyBurst = false; - for (let i = 0; i < _fxBursts.length; i++) { - if (_fxBursts[i].active) { anyBurst = true; break; } - } - const ringAge = nowMs - _fxRingMs; - const breakAge = nowMs - _fxBreakMs; - if (!anyPop && !anyBurst && ringAge >= 600 && breakAge >= 350) return; - - const pal = _fxPalette; - ctx.save(); - - // Streak-break flicker: brief red wash over the whole panel. - if (breakAge < 350) { - const a = 0.10 * (1 - breakAge / 350); - ctx.fillStyle = pal.miss; - ctx.globalAlpha = a; - ctx.fillRect(0, 0, W, H); - ctx.globalAlpha = 1; - } - - // Strike-line center in screen px — anchor for bursts + pulses. - let cx = W / 2, cy = H * 0.72, centerOk = false; - { - const fretMidY = (sY(0) + sY(nStr - 1)) / 2; - _probe.set(curX, fretMidY, 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; - } - } - - // Multiplier ring-pulse: one expanding ring on tier-up; the ×4 - // tier pulses in the secondary accent like the HUD badge. - 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 = _fxRingMult >= 4 ? pal.accent2 : pal.accent; - ctx.globalAlpha = 0.6 * (1 - t); - ctx.lineWidth = 3; - ctx.stroke(); - ctx.globalAlpha = 1; - } - - // Milestone bursts. - if (anyBurst && centerOk) { - for (let i = 0; i < _fxBursts.length; i++) { - const b = _fxBursts[i]; - if (!b.active) continue; - const age = nowMs - b.bornMs; - if (age >= _FX_BURST_LIFE_MS) { b.active = false; continue; } - const t = age / _FX_BURST_LIFE_MS; - ctx.globalAlpha = 1 - t; - for (let j = 0; j < _FX_BURST_N; j++) { - b.px[j] += b.vx[j]; - b.py[j] += b.vy[j]; - b.vy[j] += 0.08; - ctx.fillStyle = (j & 1) ? pal.accent : pal.accent2; - ctx.fillRect(cx + b.px[j] - 2, cy + b.py[j] - 2, 4, 4); - } - ctx.globalAlpha = 1; - } - } - - // "+N" pops: rise off the gem and fade over the back half. - if (anyPop) { - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - for (let i = 0; i < _fxPops.length; i++) { - const p = _fxPops[i]; - if (!p.active) continue; - const age = nowMs - p.bornMs; - if (age >= _FX_POP_LIFE_MS) { p.active = false; continue; } - _probe.set(p.x, p.y, p.z); - _probe.project(cam); - if (_probe.z < -1 || _probe.z > 1) continue; - const t = age / _FX_POP_LIFE_MS; - const sx = (_probe.x * 0.5 + 0.5) * W; - const sy2 = (-_probe.y * 0.5 + 0.5) * H - t * 30; - ctx.globalAlpha = t < 0.4 ? 1 : 1 - (t - 0.4) / 0.6; - ctx.font = `bold ${13 + (p.mult - 1) * 2}px '${pal.font}', sans-serif`; - ctx.lineWidth = 4; - ctx.strokeStyle = 'rgba(0,0,0,0.8)'; - ctx.strokeText(p.text, sx, sy2); - ctx.fillStyle = pal.accent; - ctx.fillText(p.text, sx, sy2); - } - ctx.globalAlpha = 1; - } - - ctx.restore(); - } + /* ── h3d-carve-10: drawScoreFx → src/score-fx.js ────────────────── */ /* ── h3d-carve-9: W-section (camera lerp) → src/camera.js ─────── */ const { effectiveVfov, camUpdate } = createCamera({ @@ -12793,9 +12555,9 @@ import { createCamera } from './src/camera.js'; // h3d-carve-9 // that next init() may reuse (drawNote keys on (s, f, t)). if (_ndOnHit) { window.removeEventListener('notedetect:hit', _ndOnHit); _ndOnHit = null; } if (_ndOnMiss) { window.removeEventListener('notedetect:miss', _ndOnMiss); _ndOnMiss = null; } - if (_fxOnFx) { window.removeEventListener('notedetect:fx', _fxOnFx); _fxOnFx = null; } + /* ── h3d-carve-10: score-FX teardown → fxTeardown() in src/score-fx.js */ + fxTeardown(); if (window.feedBack && typeof window.feedBack.off === 'function') { - if (_fxOnSkin) { try { window.feedBack.off('notedetect:skin', _fxOnSkin); } catch (e) {} _fxOnSkin = null; } if (_ndOnBusHit) window.feedBack.off('note:hit', _ndOnBusHit); if (_ndOnBusMiss) window.feedBack.off('note:miss', _ndOnBusMiss); if (_visibilityHandler) { @@ -12811,13 +12573,6 @@ import { createCamera } from './src/camera.js'; // h3d-carve-9 _ndHitMarks = []; _ndMissMarks = []; _ndLabels = []; - for (const p of _fxPops) p.active = false; - for (const b of _fxBursts) b.active = false; - _fxSeen.clear(); - _fxGen++; // invalidate any pending deferred window-copy fallbacks - _fxLastFxDetail = null; - _fxElemSeen = new WeakSet(); - _fxRingMs = _fxBreakMs = -1e9; _chordVerdicts = new Map(); if (bcCtrl) { try { bcCtrl.destroy(); } catch (e) {} bcCtrl = null; } _bgUnmountStyle(); diff --git a/plugins/highway_3d/src/score-fx.js b/plugins/highway_3d/src/score-fx.js new file mode 100644 index 0000000..5b85e5b --- /dev/null +++ b/plugins/highway_3d/src/score-fx.js @@ -0,0 +1,296 @@ +// h3d-carve-10: K-section (score FX) extracted from screen.js. +// VERBATIM-MOVE: function bodies are byte-for-byte identical to their +// screen.js originals except for 7 DI-rewires (see inline // DI: comments). +// No logic changes, no new guards, no structural additions. +// +// Beyond-subst changes (all mechanical DI rewires): +// 1. _fxSpawnPop: _ndFrameNowMs → getNdFrameNowMs() +// 2. drawScoreFx: cam → aliased const cam = getCam() +// 3. drawScoreFx: _probe → aliased const _probe = getProbe() +// 4. drawScoreFx: _ndFrameNowMs → getNdFrameNowMs() +// 5. drawScoreFx: nStr → getNStr() +// 6. drawScoreFx: curX → getCurX() +// 7. fxInit: highwayCanvas (closure) → getHighwayCanvas() + +export function createScoreFx({ getHighwayCanvas, getNdFrameNowMs, getCam, getProbe, getNStr, getCurX, sY }) { + + // ── Score FX (notedetect game-scoring layer, notedetect ≥1.13) ── + // Two channels: (1) per-note "+N" score pops, sourced from the + // note-state provider's new { points, mult, popKey } fields at the + // moment a gem's verdict lands; (2) session-level bursts/pulses from + // the new `notedetect:fx` event (streak milestones, multiplier tier + // changes, streak breaks). Everything renders on the 2D overlay + // canvas (same layer as drawNotedetectLabels) — no Three.js objects, + // no txtMat() cache entries, nothing to dispose. Pools are fixed- + // size slot arrays created once per factory instance; when all slots + // are busy a new effect is simply dropped. + const _FX_POP_LIFE_MS = 700; + const _FX_BURST_LIFE_MS = 900; + const _FX_BURST_N = 36; + const _fxPops = Array.from({ length: 24 }, () => ( + { active: false, x: 0, y: 0, z: 0, bornMs: 0, text: '', mult: 1 } + )); + const _fxBursts = Array.from({ length: 4 }, () => ({ + active: false, bornMs: 0, + px: new Float32Array(_FX_BURST_N), py: new Float32Array(_FX_BURST_N), + vx: new Float32Array(_FX_BURST_N), vy: new Float32Array(_FX_BURST_N), + })); + // popKey -> expiry ms. Dedupes pops (chord members share the chord's + // popKey; sustains keep returning points for the whole glow window). + const _fxSeen = new Map(); + let _fxOnFx = null; // notedetect:fx listener (window) + let _fxOnSkin = null; // notedetect:skin bus listener + // Generation counter: bumped by teardown() so the deferred window- + // copy fallback (a zero-delay task the listener removal can't cancel) + // bails instead of re-arming ring/burst state after teardown — or, + // worse, leaking a stale event into a subsequent init's fresh state. + let _fxGen = 0; + let _fxLastFxDetail = null; // reference dedup: window + instanceRoot dispatches share one detail + // Details seen via element-scoped (bubbled) dispatch. A WeakSet, not a + // single slot: one judged hit can emit several fx in the same task + // (milestone + multiplier tier-up), and the deferred window-copy + // fallback for the FIRST must still see that its element copy arrived + // after the SECOND overwrote any last-detail slot. GC reclaims + // entries once notedetect drops the detail objects. + let _fxElemSeen = new WeakSet(); + let _fxRingMs = -1e9; // multiplier ring-pulse anchor + let _fxRingMult = 1; + let _fxBreakMs = -1e9; // streak-break flicker anchor + // Canvas-side palette per notedetect skin (mirrors the accents in + // notedetect's assets/plugin.css; fonts are document-loaded by that + // stylesheet so the overlay canvas can use the family names). + const _FX_PALETTES = { + neon: { accent: '#00f0ff', accent2: '#ff2ec4', miss: '#ff4444', font: 'Orbitron' }, + esports: { accent: '#e8b43a', accent2: '#f5f5f4', miss: '#f87171', font: 'Rajdhani' }, + metal: { accent: '#ffb347', accent2: '#ff6b35', miss: '#ef4444', font: 'Russo One' }, + }; + let _fxPalette = _FX_PALETTES.neon; + function _fxResolvePalette() { + let skin = null; + try { skin = localStorage.getItem('feedBack_notedetect_skin'); } catch (e) {} + _fxPalette = _FX_PALETTES[skin] || _FX_PALETTES.neon; + } + function _fxSpawnPop(popKey, points, mult, x, y, z) { + if (_fxSeen.has(popKey)) return; + const nowMs = getNdFrameNowMs() || performance.now(); // DI: _ndFrameNowMs + _fxSeen.set(popKey, nowMs + 4000); + for (let i = 0; i < _fxPops.length; i++) { + const p = _fxPops[i]; + if (p.active) continue; + p.active = true; + p.x = x; p.y = y; p.z = z; + p.bornMs = nowMs; + p.text = '+' + points; + p.mult = mult || 1; + return; + } + } + function _fxSpawnBurst(nowMs) { + for (let i = 0; i < _fxBursts.length; i++) { + const b = _fxBursts[i]; + if (b.active) continue; + b.active = true; + b.bornMs = nowMs; + for (let j = 0; j < _FX_BURST_N; j++) { + const a = (j / _FX_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; + } + } + function _fxHandle(d) { + // Reference dedup — notedetect dispatches the SAME detail object + // on window and on its instanceRoot; whichever arrives first wins. + if (d === _fxLastFxDetail) return; + _fxLastFxDetail = d; + const nowMs = performance.now(); + if (d.fxType === 'milestone') { + _fxSpawnBurst(nowMs); + } else if (d.fxType === 'multiplier' && d.mult > (d.prevMult || 1)) { + _fxRingMs = nowMs; + _fxRingMult = d.mult; + } else if (d.fxType === 'streakBreak') { + _fxBreakMs = nowMs; + } + } + + // Score FX overlay pass — "+N" pops rising off their gems, milestone + // particle bursts / multiplier ring-pulses / streak-break flickers + // anchored on the strike line. Same overlay layer + projection + // pattern as drawNotedetectLabels; costs one early-out when nothing + // is active. + function drawScoreFx(ctx, W, H) { + const cam = getCam(); // DI: cam + const _probe = getProbe(); // DI: _probe + if (!cam || !_probe) return; + const nowMs = getNdFrameNowMs() || performance.now(); // DI: _ndFrameNowMs + // TTL-prune the pop dedup keys (bounded: only notes hit in the + // last few seconds). + if (_fxSeen.size) { + for (const [k, exp] of _fxSeen) { + if (exp <= nowMs) _fxSeen.delete(k); + } + } + let anyPop = false; + for (let i = 0; i < _fxPops.length; i++) { + if (_fxPops[i].active) { anyPop = true; break; } + } + let anyBurst = false; + for (let i = 0; i < _fxBursts.length; i++) { + if (_fxBursts[i].active) { anyBurst = true; break; } + } + const ringAge = nowMs - _fxRingMs; + const breakAge = nowMs - _fxBreakMs; + if (!anyPop && !anyBurst && ringAge >= 600 && breakAge >= 350) return; + + const pal = _fxPalette; + ctx.save(); + + // Streak-break flicker: brief red wash over the whole panel. + if (breakAge < 350) { + const a = 0.10 * (1 - breakAge / 350); + ctx.fillStyle = pal.miss; + ctx.globalAlpha = a; + ctx.fillRect(0, 0, W, H); + ctx.globalAlpha = 1; + } + + // Strike-line center in screen px — anchor for bursts + pulses. + let cx = W / 2, cy = H * 0.72, centerOk = false; + { + const fretMidY = (sY(0) + sY(getNStr() - 1)) / 2; // DI: nStr + _probe.set(getCurX(), fretMidY, 0); // DI: curX + _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; + } + } + + // Multiplier ring-pulse: one expanding ring on tier-up; the ×4 + // tier pulses in the secondary accent like the HUD badge. + 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 = _fxRingMult >= 4 ? pal.accent2 : pal.accent; + ctx.globalAlpha = 0.6 * (1 - t); + ctx.lineWidth = 3; + ctx.stroke(); + ctx.globalAlpha = 1; + } + + // Milestone bursts. + if (anyBurst && centerOk) { + for (let i = 0; i < _fxBursts.length; i++) { + const b = _fxBursts[i]; + if (!b.active) continue; + const age = nowMs - b.bornMs; + if (age >= _FX_BURST_LIFE_MS) { b.active = false; continue; } + const t = age / _FX_BURST_LIFE_MS; + ctx.globalAlpha = 1 - t; + for (let j = 0; j < _FX_BURST_N; j++) { + b.px[j] += b.vx[j]; + b.py[j] += b.vy[j]; + b.vy[j] += 0.08; + ctx.fillStyle = (j & 1) ? pal.accent : pal.accent2; + ctx.fillRect(cx + b.px[j] - 2, cy + b.py[j] - 2, 4, 4); + } + ctx.globalAlpha = 1; + } + } + + // "+N" pops: rise off the gem and fade over the back half. + if (anyPop) { + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + for (let i = 0; i < _fxPops.length; i++) { + const p = _fxPops[i]; + if (!p.active) continue; + const age = nowMs - p.bornMs; + if (age >= _FX_POP_LIFE_MS) { p.active = false; continue; } + _probe.set(p.x, p.y, p.z); + _probe.project(cam); + if (_probe.z < -1 || _probe.z > 1) continue; + const t = age / _FX_POP_LIFE_MS; + const sx = (_probe.x * 0.5 + 0.5) * W; + const sy2 = (-_probe.y * 0.5 + 0.5) * H - t * 30; + ctx.globalAlpha = t < 0.4 ? 1 : 1 - (t - 0.4) / 0.6; + ctx.font = `bold ${13 + (p.mult - 1) * 2}px '${pal.font}', sans-serif`; + ctx.lineWidth = 4; + ctx.strokeStyle = 'rgba(0,0,0,0.8)'; + ctx.strokeText(p.text, sx, sy2); + ctx.fillStyle = pal.accent; + ctx.fillText(p.text, sx, sy2); + } + ctx.globalAlpha = 1; + } + + ctx.restore(); + } + + // Score FX (notedetect ≥1.13). notedetect dispatches each fx + // detail object twice in the same task: first explicitly on + // window (unscoped), then as a bubbling CustomEvent from its + // per-panel instanceRoot (scoped). Element-targeted copies are + // authoritative — accept only the ones whose root lives in this + // panel's container. The window copy is DEFERRED a task: by the + // time it runs, the element copy (same detail reference) has + // either arrived — making the window copy a duplicate to drop — + // or it never will (detector root not attached to the DOM), in + // which case the window copy is the compat fallback. This keeps + // splitscreen panels from rendering each other's FX even for + // the first event of a session. + // + // NOTE (verbatim-preserved): fxInit has NO re-entry guard — + // the original screen.js init block had none. Calling fxInit() + // twice double-registers the notedetect:fx listener. The dispatch + // contract says to declare, not fix, this behavior here. + function fxInit() { + _fxResolvePalette(); + _fxOnFx = (e) => { + const d = e && e.detail; + if (!d) return; + const t = e.target; + if (t && t.parentElement) { + _fxElemSeen.add(d); + if (!getHighwayCanvas() || !t.parentElement.contains(getHighwayCanvas())) return; // DI: highwayCanvas + _fxHandle(d); + return; + } + const gen = _fxGen; + setTimeout(() => { + if (gen !== _fxGen) return; // torn down (or re-inited) meanwhile + if (_fxElemSeen.has(d)) return; + _fxHandle(d); + }, 0); + }; + window.addEventListener('notedetect:fx', _fxOnFx); + if (window.feedBack && typeof window.feedBack.on === 'function' + && typeof window.feedBack.off === 'function') { + _fxOnSkin = () => _fxResolvePalette(); + window.feedBack.on('notedetect:skin', _fxOnSkin); + } + } + + function fxTeardown() { + if (_fxOnFx) { window.removeEventListener('notedetect:fx', _fxOnFx); _fxOnFx = null; } + if (window.feedBack && typeof window.feedBack.off === 'function') { + if (_fxOnSkin) { try { window.feedBack.off('notedetect:skin', _fxOnSkin); } catch (e) {} _fxOnSkin = null; } + } + for (const p of _fxPops) p.active = false; + for (const b of _fxBursts) b.active = false; + _fxSeen.clear(); + _fxGen++; // invalidate any pending deferred window-copy fallbacks + _fxLastFxDetail = null; + _fxElemSeen = new WeakSet(); + _fxRingMs = _fxBreakMs = -1e9; + } + + return { fxInit, fxTeardown, fxSpawnPop: _fxSpawnPop, drawScoreFx }; +} diff --git a/tests/js/highway_3d_score_fx.test.js b/tests/js/highway_3d_score_fx.test.js new file mode 100644 index 0000000..e3f8979 --- /dev/null +++ b/tests/js/highway_3d_score_fx.test.js @@ -0,0 +1,463 @@ +// h3d-carve-10: Regression coverage for score FX (notedetect ≥1.13) extracted +// into plugins/highway_3d/src/score-fx.js. +// +// Strategy (source-level, matching the rest of tests/js/): +// - gut-audit every export + internal path via source-scan +// - class-killers: fxTeardown listener removal, _fxGen increment +// - verbatim-declaration of the no-re-entry-guard behavior in fxInit +// - wiring-correspondence guard for createScoreFx({...}) in screen.js + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); +const SCORE_FX_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'score-fx.js'); + +const src = fs.readFileSync(SCREEN_JS, 'utf8'); +const scoreFxSrc = fs.readFileSync(SCORE_FX_JS, 'utf8'); + +// ── Module shape ──────────────────────────────────────────────────────────── + +test('score-fx.js exports createScoreFx', () => { + assert.match(scoreFxSrc, /export\s+function\s+createScoreFx\s*\(/, + 'score-fx.js must export createScoreFx'); +}); + +test('createScoreFx returns all four expected exports', () => { + assert.match( + scoreFxSrc, + /return\s*\{\s*fxInit\s*,\s*fxTeardown\s*,\s*fxSpawnPop\s*:\s*_fxSpawnPop\s*,\s*drawScoreFx\s*\}/, + 'factory must return { fxInit, fxTeardown, fxSpawnPop: _fxSpawnPop, drawScoreFx }', + ); +}); + +// ── DI rewires — all 7 beyond-subst changes ───────────────────────────────── + +test('_fxSpawnPop uses getNdFrameNowMs() DI getter, not _ndFrameNowMs directly', () => { + assert.match( + scoreFxSrc, + /getNdFrameNowMs\(\)\s*\|\|\s*performance\.now\(\)/, + '_fxSpawnPop must call getNdFrameNowMs() for the current-time sample', + ); + // Sever: replace getNdFrameNowMs() with a literal → nowMs is always a + // stale value and the TTL dedup fires wrong. The test goes RED. + assert.doesNotMatch( + scoreFxSrc, + /const\s+nowMs\s*=\s*_ndFrameNowMs\s*\|\|/, + '_ndFrameNowMs must not appear bare in the module (must use DI getter)', + ); +}); + +test('drawScoreFx aliases cam and _probe from DI getters at function entry', () => { + assert.match( + scoreFxSrc, + /const\s+cam\s*=\s*getCam\(\)/, + 'drawScoreFx must alias cam via getCam()', + ); + assert.match( + scoreFxSrc, + /const\s+_probe\s*=\s*getProbe\(\)/, + 'drawScoreFx must alias _probe via getProbe()', + ); +}); + +test('drawScoreFx calls getNStr() and getCurX() inline (no bare nStr / curX)', () => { + assert.match( + scoreFxSrc, + /sY\(\s*getNStr\(\)\s*-\s*1\s*\)/, + 'drawScoreFx must call getNStr() for the string-count probe', + ); + assert.match( + scoreFxSrc, + /_probe\.set\(\s*getCurX\(\)/, + 'drawScoreFx must call getCurX() for the strike-line X coordinate', + ); +}); + +test('fxInit uses getHighwayCanvas() inside the event closure, not a captured ref', () => { + assert.match( + scoreFxSrc, + /getHighwayCanvas\(\)\s*\|\|\s*!t\.parentElement\.contains\(\s*getHighwayCanvas\(\)\s*\)/, + 'fxInit closure must call getHighwayCanvas() per-event for panel scoping', + ); + // Sever: bake in a captured ref → panel isolation breaks on canvas swap. + assert.doesNotMatch( + scoreFxSrc, + /const\s+hc\s*=\s*getHighwayCanvas\(\)[\s\S]*?t\.parentElement\.contains\(\s*hc\s*\)/, + 'fxInit must not capture highwayCanvas into a local (must re-read per event)', + ); +}); + +// ── fxInit gut-audit ──────────────────────────────────────────────────────── + +test('fxInit calls _fxResolvePalette before registering the listener', () => { + assert.match( + scoreFxSrc, + /function\s+fxInit[\s\S]*?_fxResolvePalette\(\)[\s\S]*?window\.addEventListener\('notedetect:fx'/, + 'fxInit must resolve the palette before arming the event listener', + ); +}); + +test('fxInit registers the notedetect:fx listener on window', () => { + assert.match( + scoreFxSrc, + /window\.addEventListener\(\s*'notedetect:fx'\s*,\s*_fxOnFx\s*\)/, + 'fxInit must register _fxOnFx on window for notedetect:fx', + ); +}); + +test('fxInit registers the notedetect:skin skin-change listener via feedBack bus', () => { + assert.match( + scoreFxSrc, + /window\.feedBack\.on\(\s*'notedetect:skin'\s*,\s*_fxOnSkin\s*\)/, + 'fxInit must register _fxOnSkin for skin changes', + ); +}); + +// CLASS-KILLER: no re-entry guard (VERBATIM-PRESERVED from original screen.js) +// The original init block had no guard. Adding one without a corresponding +// double-register test is a silent behavior change. This test asserts the +// absence so any accidental addition turns RED. +test('fxInit has no re-entry guard (verbatim-preserved: original had none)', () => { + assert.doesNotMatch( + scoreFxSrc, + /function\s+fxInit[\s\S]{0,80}if\s*\(\s*_fxOnFx\s*\)\s*return/, + 'fxInit must not have a re-entry guard (verbatim from original; see cut-10 dispatch)', + ); +}); + +// ── fxTeardown gut-audit + class-killers ──────────────────────────────────── + +// CLASS-KILLER (a): sever fxTeardown listener removal → listeners live on. +// If window.removeEventListener call is deleted, this test fails because +// the pattern is gone. Combined with the _fxGen increment test below these +// two together cover the complete teardown contract. +test('fxTeardown removes the notedetect:fx listener (class-killer: sever → RED)', () => { + assert.match( + scoreFxSrc, + /window\.removeEventListener\(\s*'notedetect:fx'\s*,\s*_fxOnFx\s*\)/, + 'fxTeardown must remove the notedetect:fx listener from window', + ); +}); + +test('fxTeardown removes the notedetect:skin skin listener via feedBack bus', () => { + assert.match( + scoreFxSrc, + /window\.feedBack\.off\(\s*'notedetect:skin'\s*,\s*_fxOnSkin\s*\)/, + 'fxTeardown must remove the skin listener to avoid palette updates after teardown', + ); +}); + +test('fxTeardown resets all pop and burst slots to inactive', () => { + assert.match( + scoreFxSrc, + /for\s*\(\s*const\s+p\s+of\s+_fxPops\s*\)\s*p\.active\s*=\s*false/, + 'fxTeardown must deactivate every pop slot', + ); + assert.match( + scoreFxSrc, + /for\s*\(\s*const\s+b\s+of\s+_fxBursts\s*\)\s*b\.active\s*=\s*false/, + 'fxTeardown must deactivate every burst slot', + ); +}); + +test('fxTeardown clears _fxSeen and resets ring/break anchors', () => { + assert.match( + scoreFxSrc, + /_fxSeen\.clear\(\)/, + 'fxTeardown must clear the pop-dedup map', + ); + assert.match( + scoreFxSrc, + /_fxRingMs\s*=\s*_fxBreakMs\s*=\s*-1e9/, + 'fxTeardown must reset ring and break anchors to -1e9', + ); +}); + +// CLASS-KILLER (b): sever _fxGen increment → deferred window-copy fallback +// fires after teardown and can arm state on the next fresh init. If the +// increment line is deleted this test fails. +test('fxTeardown increments _fxGen to invalidate deferred window-copy fallbacks (class-killer: sever → RED)', () => { + assert.match( + scoreFxSrc, + /_fxGen\+\+/, + 'fxTeardown must increment _fxGen so setTimeout callbacks from the prior session bail', + ); +}); + +test('fxTeardown resets _fxElemSeen to a fresh WeakSet', () => { + assert.match( + scoreFxSrc, + /_fxElemSeen\s*=\s*new\s+WeakSet\(\)/, + 'fxTeardown must reset _fxElemSeen so stale details from the prior session are not re-deduplicated', + ); +}); + +// ── _fxHandle gut-audit ───────────────────────────────────────────────────── + +test('_fxHandle deduplicates on reference equality with _fxLastFxDetail', () => { + assert.match( + scoreFxSrc, + /if\s*\(\s*d\s*===\s*_fxLastFxDetail\s*\)\s*return/, + '_fxHandle must bail on duplicate detail reference', + ); +}); + +test('_fxHandle routes milestone → burst, multiplier-up → ring, streakBreak → break', () => { + assert.match( + scoreFxSrc, + /d\.fxType\s*===\s*'milestone'[\s\S]*?_fxSpawnBurst\(\s*nowMs\s*\)/, + 'milestone must spawn a burst', + ); + assert.match( + scoreFxSrc, + /d\.fxType\s*===\s*'multiplier'\s*&&\s*d\.mult\s*>\s*\(\s*d\.prevMult\s*\|\|\s*1\s*\)[\s\S]*?_fxRingMs\s*=\s*nowMs/, + 'multiplier tier-up must arm the ring pulse', + ); + assert.match( + scoreFxSrc, + /d\.fxType\s*===\s*'streakBreak'[\s\S]*?_fxBreakMs\s*=\s*nowMs/, + 'streakBreak must arm the flicker', + ); +}); + +// ── drawScoreFx gut-audit ─────────────────────────────────────────────────── + +test('drawScoreFx returns early when cam or probe is falsy', () => { + assert.match( + scoreFxSrc, + /if\s*\(\s*!cam\s*\|\|\s*!_probe\s*\)\s*return/, + 'drawScoreFx must early-exit when cam or probe is unavailable', + ); +}); + +test('drawScoreFx early-exits when all effects are expired', () => { + assert.match( + scoreFxSrc, + /if\s*\(\s*!anyPop\s*&&\s*!anyBurst\s*&&\s*ringAge\s*>=\s*600\s*&&\s*breakAge\s*>=\s*350\s*\)\s*return/, + 'drawScoreFx must skip canvas work entirely when all effect TTLs are expired', + ); +}); + +test('drawScoreFx TTL-prunes _fxSeen each frame', () => { + assert.match( + scoreFxSrc, + /for\s*\(\s*const\s+\[k\s*,\s*exp\]\s+of\s+_fxSeen\s*\)[\s\S]*?_fxSeen\.delete\(\s*k\s*\)/, + 'drawScoreFx must prune expired pop-dedup keys every frame', + ); +}); + +test('drawScoreFx renders streak-break flicker as a fill-rect wash', () => { + assert.match( + scoreFxSrc, + /breakAge\s*<\s*350[\s\S]*?ctx\.fillRect\(\s*0\s*,\s*0\s*,\s*W\s*,\s*H\s*\)/, + 'streak-break flicker must fill the entire panel', + ); +}); + +test('drawScoreFx computes strike-line center via _probe.project(cam)', () => { + assert.match( + scoreFxSrc, + /_probe\.set\(\s*getCurX\(\)\s*,\s*fretMidY\s*,\s*0\s*\)[\s\S]*?_probe\.project\(\s*cam\s*\)/, + 'strike-line center must be projected from getCurX() via _probe', + ); +}); + +test('drawScoreFx renders multiplier ring-pulse as an expanding arc', () => { + assert.match( + scoreFxSrc, + /ringAge\s*<\s*600[\s\S]*?ctx\.arc\([\s\S]*?Math\.PI\s*\*\s*2\s*\)/, + 'ring-pulse must draw an expanding arc when active', + ); +}); + +test('drawScoreFx renders burst particles with gravity', () => { + assert.match( + scoreFxSrc, + /b\.vy\[j\]\s*\+=\s*0\.08/, + 'burst particles must apply gravity each frame', + ); +}); + +test('drawScoreFx renders "+N" pops that rise and fade over their lifetime', () => { + assert.match( + scoreFxSrc, + /sy2\s*=[\s\S]*?H\s*-\s*t\s*\*\s*30/, + 'pops must rise (subtract t*30) over their lifetime', + ); + assert.match( + scoreFxSrc, + /ctx\.globalAlpha\s*=\s*t\s*<\s*0\.4\s*\?\s*1\s*:\s*1\s*-\s*\(\s*t\s*-\s*0\.4\s*\)\s*\/\s*0\.6/, + 'pops must fade over the back half of their lifetime', + ); +}); + +// ── _fxSpawnPop gut-audit ─────────────────────────────────────────────────── + +test('_fxSpawnPop deduplicates via _fxSeen.has(popKey)', () => { + assert.match( + scoreFxSrc, + /_fxSeen\.has\(\s*popKey\s*\)/, + '_fxSpawnPop must reject duplicate popKeys via _fxSeen', + ); +}); + +test('_fxSpawnPop sets a 4-second expiry in _fxSeen', () => { + assert.match( + scoreFxSrc, + /_fxSeen\.set\(\s*popKey\s*,\s*nowMs\s*\+\s*4000\s*\)/, + '_fxSpawnPop must register the popKey with a 4s TTL', + ); +}); + +test('_fxSpawnPop fills the first inactive slot and returns early (pool-full = drop)', () => { + assert.match( + scoreFxSrc, + /if\s*\(\s*p\.active\s*\)\s*continue[\s\S]*?p\.active\s*=\s*true/, + '_fxSpawnPop must scan for an inactive slot and claim it', + ); +}); + +// ── screen.js wiring ──────────────────────────────────────────────────────── + +test('screen.js imports createScoreFx from src/score-fx.js', () => { + assert.match( + src, + /import\s*\{\s*createScoreFx\s*\}\s*from\s*'\.\/src\/score-fx\.js'/, + 'screen.js must import createScoreFx', + ); +}); + +test('screen.js destroys original K-section state block (no _fxPops let/const in IIFE scope)', () => { + // The state block is now inside the factory. If any line leaked back into + // screen.js this assertion fails. + assert.doesNotMatch( + src, + /const\s+_fxPops\s*=/, + '_fxPops must not be declared in screen.js after extraction', + ); + assert.doesNotMatch( + src, + /const\s+_fxBursts\s*=/, + '_fxBursts must not be declared in screen.js after extraction', + ); +}); + +test('screen.js init callsite replaced with fxInit()', () => { + assert.match( + src, + /fxInit\(\)\s*;/, + 'screen.js init path must call fxInit()', + ); + assert.doesNotMatch( + src, + /window\.addEventListener\(\s*'notedetect:fx'/, + 'screen.js must not directly register notedetect:fx after extraction', + ); +}); + +test('screen.js teardown callsite replaced with fxTeardown()', () => { + assert.match( + src, + /fxTeardown\(\)\s*;/, + 'screen.js teardown path must call fxTeardown()', + ); + // The _fxOnFx removal now lives in fxTeardown — not in screen.js. + assert.doesNotMatch( + src, + /window\.removeEventListener\(\s*'notedetect:fx'/, + 'screen.js must not directly unregister notedetect:fx after extraction', + ); +}); + +// ── Wiring-correspondence guard (extends cut-9 pattern) ───────────────────── +// Structural source-scan: every entry in createScoreFx({...}) must satisfy +// its naming-correspondence class. Kills swaps like (getCam: () => _probe). +// PINNED_RENAMES is empty — all 6 getters are plain convention, sY is shorthand. + +test('createScoreFx({...}) wiring has correct naming correspondence (no param swaps)', () => { + const PINNED_RENAMES = {}; + + const ANCHOR = 'const { fxInit, fxTeardown, fxSpawnPop: _fxSpawnPop, drawScoreFx } = createScoreFx({'; + const callStart = src.indexOf(ANCHOR); + assert.ok(callStart >= 0, 'createScoreFx call must be findable in screen.js'); + const blockStart = callStart + ANCHOR.length - 1; + assert.equal(src[blockStart], '{', 'expected { at computed blockStart'); + let depth = 0, blockEnd = -1; + for (let i = blockStart; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}' && --depth === 0) { blockEnd = i; break; } + } + assert.ok(blockEnd > blockStart, 'createScoreFx argument block must have balanced braces'); + const inner = src.slice(blockStart + 1, blockEnd); + + const rawEntries = []; + let current = '', d = 0; + for (let i = 0; i < inner.length; i++) { + const ch = inner[i]; + if (ch === '{') d++; + else if (ch === '}') d--; + if (ch === ',' && d === 0) { + const t = current.trim(); + if (t) rawEntries.push(t); + current = ''; + } else { + current += ch; + } + } + if (current.trim()) rawEntries.push(current.trim()); + + const entries = rawEntries + .map(e => e.replace(/\/\/[^\n]*/g, '').trim()) + .filter(Boolean); + + assert.ok(entries.length >= 7, `expected at least 7 entries, got ${entries.length}`); + + const violations = []; + for (const entry of entries) { + if (!entry.includes(':')) continue; // shorthand + + const colonIdx = entry.indexOf(':'); + const key = entry.slice(0, colonIdx).trim(); + const value = entry.slice(colonIdx + 1).trim(); + + if (key in PINNED_RENAMES) { + if (value !== PINNED_RENAMES[key]) { + violations.push(`${key}: pinned to '${PINNED_RENAMES[key]}' but got '${value}'`); + } + continue; + } + + if (key.startsWith('get')) { + const expectedStem = key[3].toLowerCase() + key.slice(4); + const m = value.match(/^\(\)\s*=>\s*_?(\w+)$/); + if (!m) { + violations.push(`${key}: getter value '${value}' does not match () => [_]var`); + continue; + } + if (m[1] !== expectedStem) { + violations.push(`${key}: getter body references var stem '${m[1]}' but expected '${expectedStem}'`); + } + continue; + } + + if (key.startsWith('set')) { + const expectedStem = key[3].toLowerCase() + key.slice(4); + const m = value.match(/^\(v\)\s*=>\s*\{\s*_?(\w+)\s*=\s*v\s*;\s*\}$/); + if (!m) { + violations.push(`${key}: setter value '${value}' does not match (v) => { [_]var = v; }`); + continue; + } + if (m[1] !== expectedStem) { + violations.push(`${key}: setter assigns var stem '${m[1]}' but expected '${expectedStem}'`); + } + continue; + } + + violations.push(`${key}: key:value entry not in PINNED_RENAMES and not a get/set arrow`); + } + + assert.deepEqual(violations, [], 'createScoreFx wiring violations found'); +});