mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-13 02:48:31 +00:00
h3d-carve-10: extract K-section (score FX) into src/score-fx.js
VERBATIM-MOVE of the score-FX block from screen.js into a new
createScoreFx() factory. 7 DI-rewires only (getNdFrameNowMs,
getCam, getProbe, getNStr, getCurX, getHighwayCanvas × closure,
sY shorthand). No logic changes.
Four regions in screen.js replaced with factory callsites:
- State block (old lines 3619–3720): createScoreFx({…}) destructure
- drawScoreFx body (old 12564–12672): tombstone comment
- Init block (old 5839–5862): fxInit() callsite
- Teardown lines (old 12796/12814–12820): fxTeardown() callsite;
_fxOnSkin removal moved into fxTeardown(), feedBack block
restructured to contain only non-K-section listeners.
Tests (highway_3d_score_fx.test.js, 37 new, all green):
- Gut-audit: all 4 exports + _fxHandle + _fxSpawnPop paths
- Class-killers: fxTeardown listener removal, _fxGen increment
- Verbatim-declaration: no re-entry guard in fxInit (per dispatch)
- Wiring-correspondence guard: createScoreFx({…}) naming (cut-9
pattern, empty PINNED_RENAMES)
Pre-existing failures: #46 (legacy analyser), #628 (nut-labels) —
both pre-date this cut, confirmed against 4a45ed8 baseline.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
f401127c20
commit
4b2e4172c5
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "highway_3d",
|
"id": "highway_3d",
|
||||||
"name": "3D Highway",
|
"name": "3D Highway",
|
||||||
"version": "3.45.0",
|
"version": "3.46.0",
|
||||||
"type": "visualization",
|
"type": "visualization",
|
||||||
"scriptType": "module",
|
"scriptType": "module",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
|
|||||||
+16
-261
@@ -15,6 +15,7 @@ import { createMaterialBuilders } from './src/materials.js'; // h3d-carve-6
|
|||||||
import { createOverlay } from './src/overlay.js'; // h3d-carve-7
|
import { createOverlay } from './src/overlay.js'; // h3d-carve-7
|
||||||
import { createFx } from './src/fx.js'; // h3d-carve-8
|
import { createFx } from './src/fx.js'; // h3d-carve-8
|
||||||
import { createCamera } from './src/camera.js'; // h3d-carve-9
|
import { createCamera } from './src/camera.js'; // h3d-carve-9
|
||||||
|
import { createScoreFx } from './src/score-fx.js'; // h3d-carve-10
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'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).
|
// silent — see the live-latch handling in the per-gem loop below).
|
||||||
let _susVerdictLatch = new Map();
|
let _susVerdictLatch = new Map();
|
||||||
|
|
||||||
// ── Score FX (notedetect game-scoring layer, notedetect ≥1.13) ──
|
/* ── h3d-carve-10: K-section (score FX) → src/score-fx.js ──────── */
|
||||||
// Two channels: (1) per-note "+N" score pops, sourced from the
|
const { fxInit, fxTeardown, fxSpawnPop: _fxSpawnPop, drawScoreFx } = createScoreFx({
|
||||||
// note-state provider's new { points, mult, popKey } fields at the
|
getHighwayCanvas: () => highwayCanvas,
|
||||||
// moment a gem's verdict lands; (2) session-level bursts/pulses from
|
getNdFrameNowMs: () => _ndFrameNowMs,
|
||||||
// the new `notedetect:fx` event (streak milestones, multiplier tier
|
getCam: () => cam,
|
||||||
// changes, streak breaks). Everything renders on the 2D overlay
|
getProbe: () => _probe,
|
||||||
// canvas (same layer as drawNotedetectLabels) — no Three.js objects,
|
getNStr: () => nStr,
|
||||||
// no txtMat() cache entries, nothing to dispose. Pools are fixed-
|
getCurX: () => curX,
|
||||||
// size slot arrays created once per factory instance; when all slots
|
sY,
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Object pools
|
// Object pools
|
||||||
let pNote, pSus, pLbl, pBeat, pSec;
|
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);
|
window.feedBack.on('note:miss', _ndOnBusMiss);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score FX (notedetect ≥1.13). notedetect dispatches each fx
|
/* ── h3d-carve-10: score-FX init → fxInit() in src/score-fx.js */
|
||||||
// detail object twice in the same task: first explicitly on
|
fxInit();
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -12556,120 +12431,7 @@ import { createCamera } from './src/camera.js'; // h3d-carve-9
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score FX overlay pass — "+N" pops rising off their gems, milestone
|
/* ── h3d-carve-10: drawScoreFx → src/score-fx.js ────────────────── */
|
||||||
// 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-9: W-section (camera lerp) → src/camera.js ─────── */
|
/* ── h3d-carve-9: W-section (camera lerp) → src/camera.js ─────── */
|
||||||
const { effectiveVfov, camUpdate } = createCamera({
|
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)).
|
// that next init() may reuse (drawNote keys on (s, f, t)).
|
||||||
if (_ndOnHit) { window.removeEventListener('notedetect:hit', _ndOnHit); _ndOnHit = null; }
|
if (_ndOnHit) { window.removeEventListener('notedetect:hit', _ndOnHit); _ndOnHit = null; }
|
||||||
if (_ndOnMiss) { window.removeEventListener('notedetect:miss', _ndOnMiss); _ndOnMiss = 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 (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 (_ndOnBusHit) window.feedBack.off('note:hit', _ndOnBusHit);
|
||||||
if (_ndOnBusMiss) window.feedBack.off('note:miss', _ndOnBusMiss);
|
if (_ndOnBusMiss) window.feedBack.off('note:miss', _ndOnBusMiss);
|
||||||
if (_visibilityHandler) {
|
if (_visibilityHandler) {
|
||||||
@@ -12811,13 +12573,6 @@ import { createCamera } from './src/camera.js'; // h3d-carve-9
|
|||||||
_ndHitMarks = [];
|
_ndHitMarks = [];
|
||||||
_ndMissMarks = [];
|
_ndMissMarks = [];
|
||||||
_ndLabels = [];
|
_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();
|
_chordVerdicts = new Map();
|
||||||
if (bcCtrl) { try { bcCtrl.destroy(); } catch (e) {} bcCtrl = null; }
|
if (bcCtrl) { try { bcCtrl.destroy(); } catch (e) {} bcCtrl = null; }
|
||||||
_bgUnmountStyle();
|
_bgUnmountStyle();
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user