mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-25 14:21:21 +00:00
refactor(highway): carve the STATEFUL primitives, threading hwState explicitly (R3c) (#916)
fretX, fillTextReadable, _noteState, _paintGemGlow -> static/js/highway-state-primitives.js.
50 call sites rewritten. highway.js 4,105 -> 3,965.
The first slice that changes signatures. Each of these four gains hwState as an explicit
FIRST PARAMETER.
━━━ hwState IS A PARAMETER, NOT AN IMPORT ━━━
createHighway() is a FACTORY. The constitution publishes window.createHighway so a plugin can
build a SECOND highway for its own panel, and highway.js says so itself. Import hwState as a
module singleton and two panels silently share one clock, one render scale, one string
palette — each driving the other. Nothing throws. The picture is just wrong, in a way no test
would catch.
(The exact opposite of the app.js carve, where player-state.js and library-state.js ARE
module singletons — correctly, because there is exactly one app. Same epic, same language,
opposite answer, decided entirely by whether the thing is a factory.)
━━━ THE PLUGIN BUNDLE NEARLY BROKE, SILENTLY ━━━
The renderer bundle hands two of these STRAIGHT TO PLUGINS:
b.fretX = fretX;
b.getNoteState = _noteState; // stable reference
highway_3d calls both EVERY FRAME, with the old arity. Handing out the new 3-arg versions
would have passed `note` where hwState belongs — no throw, no error, just wrong geometry and
wrong judgment state INSIDE A PLUGIN, which no core test would ever see. Green CI, broken 3D
highway.
So hwState is bound ONCE per instance, in the factory, and the bundle hands out those views.
A per-frame arrow would have fixed the arity and reintroduced exactly the per-frame allocation
the bundle's stable-reference contract (feedBack#254) exists to prevent. b.project needs none
of this — project() is pure and its arity never changed.
VERIFIED IN A BROWSER, against the real bundle, on both builds:
fretX arity 3 3 (NOT 4 — the bound view preserves it)
getNoteState arity 2 2
fretX(5,1,800) in 0..800 True True
getNoteState null w/o provider True True
getNoteState honours provider True True
fretX is a stable reference True True
IDENTICAL. Without the bound views fretX would have reported arity 4 and computed garbage.
Also caught on the way: my generated module imported STRING_BRIGHT_FALLBACK, a name
highway-constants.js does not export. ESLint does not flag that — but importing a name a
module does not export is a runtime SyntaxError that kills the WHOLE module. These four need
no constants at all; the import is gone.
TESTS. highway_note_state pins the signature AND the stable-reference contract — it caught the
bundle break. Retargeted at the module and the new arity; both contracts still asserted, and
the "no fresh arrow per frame" rule is now asserted explicitly rather than implied by
`getNoteState: _noteState`.
PERF GATE PASSES AT 1.94ms against its 12ms budget — fretX and _noteState are now CROSS-MODULE
calls, per note, per frame. It costs nothing measurable.
node 1045, pytest 2416, ESLint 0, Codex 0.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1a386c272d
commit
12eb73aee9
@ -41,11 +41,36 @@ import {
|
||||
teachingDegreeLabel,
|
||||
teachingFingerLabel,
|
||||
} from './js/highway-geometry.js';
|
||||
import {
|
||||
_noteState,
|
||||
_paintGemGlow,
|
||||
fillTextReadable,
|
||||
fretX,
|
||||
} from './js/highway-state-primitives.js';
|
||||
|
||||
function createHighway() {
|
||||
// R3c: per-instance mutable state in one object, so extracted renderer/ws
|
||||
// modules can close over it as a factory arg without cross-panel sharing.
|
||||
const hwState = {};
|
||||
|
||||
// ── Stable, hwState-bound views of the carved primitives (R3c) ──────────────
|
||||
//
|
||||
// fretX and _noteState moved to ./js/highway-state-primitives.js and now take hwState as
|
||||
// their first argument — they must, because createHighway() is a FACTORY and a module
|
||||
// cannot import per-instance state without two panels silently sharing it.
|
||||
//
|
||||
// But the RENDERER BUNDLE hands both straight to plugins (b.fretX, b.getNoteState), and
|
||||
// highway_3d calls them EVERY FRAME with the old arity. Handing out the 3-arg versions
|
||||
// would have passed `note` where hwState belongs — no throw, just wrong geometry and wrong
|
||||
// judgment state, inside a plugin, which no core test would ever see.
|
||||
//
|
||||
// So bind hwState ONCE, here, per instance. A per-frame arrow would fix the arity and
|
||||
// reintroduce exactly the per-frame allocation the bundle's stable-reference contract
|
||||
// (feedBack#254) exists to prevent. These are created once and reused forever.
|
||||
//
|
||||
// b.project needs none of this: project() is pure, and its arity never changed.
|
||||
const boundFretX = (fret, scale, w) => fretX(hwState, fret, scale, w);
|
||||
const boundNoteState = (note, chartTime) => _noteState(hwState, note, chartTime);
|
||||
|
||||
// Promise chain for serializing async ws.onmessage handlers —
|
||||
// reset on each connect() so reconnections start a fresh chain.
|
||||
@ -421,14 +446,6 @@ function createHighway() {
|
||||
hwState.displayMaxFret += (targetMax - hwState.displayMaxFret) * rate;
|
||||
}
|
||||
|
||||
function fretX(fret, scale, w) {
|
||||
const hw = w * 0.52 * scale;
|
||||
const margin = hw * 0.06;
|
||||
const usable = hw * 2 - 2 * margin;
|
||||
const t = fret / Math.max(1, hwState.displayMaxFret);
|
||||
return w / 2 - hw + margin + t * usable;
|
||||
}
|
||||
|
||||
/** Map a bend curve [{t, v}] (§6.2.1) to [{x, v}] with x normalized to
|
||||
* 0..1 across the curve's time span (0 when the span is degenerate).
|
||||
* Pure — drives the 2D bend-shape glyph. */
|
||||
@ -460,53 +477,6 @@ function createHighway() {
|
||||
}
|
||||
|
||||
/** Call while lefty mirror transform is active; keeps glyphs readable. */
|
||||
function fillTextReadable(text, x, y) {
|
||||
// ctx may be null when the 2D context was never acquired
|
||||
// (canvas already locked to WebGL). No-op in that case —
|
||||
// alternatives would be throwing, which breaks plugin hooks
|
||||
// that call this after a context-type mismatch.
|
||||
if (!hwState.canvas || !hwState.ctx) return;
|
||||
const W = hwState.canvas.width;
|
||||
if (!hwState._lefty) {
|
||||
hwState.ctx.fillText(text, x, y);
|
||||
return;
|
||||
}
|
||||
hwState.ctx.save();
|
||||
hwState.ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
hwState.ctx.fillText(text, W - x, y);
|
||||
hwState.ctx.restore();
|
||||
}
|
||||
|
||||
// ── Per-note judgment state (feedBack#254) ──────────────────────────
|
||||
// Resolves the registered provider for one chart note. Returns null
|
||||
// when no provider is set, the provider throws, it reports nothing,
|
||||
// or the reported alpha is non-positive. Otherwise a normalized
|
||||
// { state: 'hit'|'active'|'miss', alpha: 0..1, color: string|null }.
|
||||
// 'hit' and 'active' are both "lit" — renderers may treat them the
|
||||
// same; the distinction (struck note vs currently-held sustain) is
|
||||
// there for renderers that want it. The provider owns all timing /
|
||||
// fade — `alpha` is whatever intensity it wants right now.
|
||||
function _noteState(note, chartTime) {
|
||||
if (!hwState._noteStateProvider) return null;
|
||||
let raw;
|
||||
try { raw = hwState._noteStateProvider(note, chartTime); } catch (e) { return null; }
|
||||
if (!raw) return null;
|
||||
const state = typeof raw === 'string' ? raw : raw.state;
|
||||
if (state !== 'hit' && state !== 'active' && state !== 'miss') return null;
|
||||
const alpha = (raw && typeof raw === 'object' && Number.isFinite(raw.alpha))
|
||||
? Math.max(0, Math.min(1, raw.alpha))
|
||||
: 1;
|
||||
if (alpha <= 0) return null;
|
||||
const color = (raw && typeof raw === 'object' && typeof raw.color === 'string') ? raw.color : null;
|
||||
// Pass through the provider's `live` flag: note_detect tags its
|
||||
// ring-tracking 'active' responses with live:true so a renderer can
|
||||
// treat them as authoritative (extinguish on mute, relight on
|
||||
// re-strike) instead of latching them for the whole chart sustain.
|
||||
// Renderers that don't care simply ignore it.
|
||||
const live = (raw && typeof raw === 'object' && raw.live === true);
|
||||
return { state, alpha, color, live };
|
||||
}
|
||||
|
||||
// Stable bundle accessor for the registered provider — see
|
||||
// bundle.getNoteStateProvider below. Defined once per createHighway()
|
||||
// instance (not module scope — _noteStateProvider is per-instance),
|
||||
@ -514,97 +484,6 @@ function createHighway() {
|
||||
// (matches getNoteState: _noteState's stable-reference pattern).
|
||||
function _getNoteStateProvider() { return hwState._noteStateProvider; }
|
||||
|
||||
// Paints the judgment effect on top of an already-drawn gem at
|
||||
// (cx,cy) with half-extent `r`. `ns` is the normalized state from
|
||||
// _noteState (or null → no-op). A miss → faint red wash. A correct
|
||||
// hit / held sustain → a "sizzle": throbbing additive halo + a
|
||||
// flickering white-hot core + crackling spark lines re-randomised
|
||||
// each frame + (for a fresh struck note that's fading) an expanding
|
||||
// shockwave ring. Intensity scales with `ns.alpha`, so a struck
|
||||
// note flares and dies while a held sustain crackles continuously.
|
||||
// Caller draws the gem normally first, then calls this BEFORE any
|
||||
// glyph so a readable fret number can land on top.
|
||||
function _paintGemGlow(cx, cy, r, stringIdx, ns) {
|
||||
if (!ns || !hwState.ctx) return;
|
||||
hwState.ctx.save();
|
||||
if (ns.state === 'miss') {
|
||||
hwState.ctx.globalAlpha = 0.4 * ns.alpha;
|
||||
hwState.ctx.fillStyle = '#ff2828';
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.arc(cx, cy, r * 1.05, 0, Math.PI * 2);
|
||||
hwState.ctx.fill();
|
||||
hwState.ctx.restore();
|
||||
return;
|
||||
}
|
||||
const col = ns.color || hwState.STRING_BRIGHT[stringIdx] || '#ffffff';
|
||||
const a = ns.alpha;
|
||||
const nowMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
|
||||
hwState.ctx.lineCap = 'round';
|
||||
|
||||
// Expanding shockwave — only on a fresh struck-and-fading hit
|
||||
// (alpha decays 1→0). 'active' (held sustain, alpha pinned 1) skips it.
|
||||
if (ns.state === 'hit' && a < 1) {
|
||||
const prog = 1 - a; // 0 at strike → 1 at fade-out
|
||||
hwState.ctx.globalCompositeOperation = 'lighter';
|
||||
hwState.ctx.globalAlpha = a * 0.85;
|
||||
hwState.ctx.strokeStyle = col;
|
||||
hwState.ctx.lineWidth = Math.max(1.5, r * 0.26 * a);
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.arc(cx, cy, r * (1.0 + prog * 2.7), 0, Math.PI * 2);
|
||||
hwState.ctx.stroke();
|
||||
}
|
||||
|
||||
// Throbbing halo (≈9 Hz wobble).
|
||||
const pulse = 0.8 + 0.2 * Math.sin(nowMs / 18);
|
||||
const haloR = r * 2.0 * pulse;
|
||||
hwState.ctx.globalCompositeOperation = 'lighter';
|
||||
hwState.ctx.globalAlpha = a;
|
||||
const g = hwState.ctx.createRadialGradient(cx, cy, 0, cx, cy, haloR);
|
||||
g.addColorStop(0, '#ffffff');
|
||||
g.addColorStop(0.30, col);
|
||||
g.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
hwState.ctx.fillStyle = g;
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.arc(cx, cy, haloR, 0, Math.PI * 2);
|
||||
hwState.ctx.fill();
|
||||
|
||||
// Crackle — short bright spark lines flicking out from the gem,
|
||||
// re-randomised every frame so it shimmers.
|
||||
const sparkCount = 6;
|
||||
for (let i = 0; i < sparkCount; i++) {
|
||||
if (Math.random() > 0.55 * a + 0.2) continue; // intermittent
|
||||
const ang = Math.random() * Math.PI * 2;
|
||||
const inR = r * 0.45;
|
||||
const len = r * (0.7 + Math.random() * 1.6) * (0.5 + 0.5 * a);
|
||||
hwState.ctx.globalAlpha = a * (0.45 + Math.random() * 0.55);
|
||||
hwState.ctx.strokeStyle = Math.random() < 0.5 ? '#ffffff' : col;
|
||||
hwState.ctx.lineWidth = Math.max(1, r * (0.08 + Math.random() * 0.08));
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.moveTo(cx + Math.cos(ang) * inR, cy + Math.sin(ang) * inR);
|
||||
hwState.ctx.lineTo(cx + Math.cos(ang) * (inR + len), cy + Math.sin(ang) * (inR + len));
|
||||
hwState.ctx.stroke();
|
||||
}
|
||||
|
||||
// Flickering white-hot core.
|
||||
hwState.ctx.globalCompositeOperation = 'lighter';
|
||||
hwState.ctx.globalAlpha = a * (0.55 + Math.random() * 0.45);
|
||||
hwState.ctx.fillStyle = '#ffffff';
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.arc(cx, cy, r * (0.30 + Math.random() * 0.14), 0, Math.PI * 2);
|
||||
hwState.ctx.fill();
|
||||
|
||||
// Crisp bright rim.
|
||||
hwState.ctx.globalCompositeOperation = 'source-over';
|
||||
hwState.ctx.globalAlpha = a;
|
||||
hwState.ctx.strokeStyle = col;
|
||||
hwState.ctx.lineWidth = Math.max(2, r * 0.2);
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.arc(cx, cy, r * 0.95, 0, Math.PI * 2);
|
||||
hwState.ctx.stroke();
|
||||
|
||||
hwState.ctx.restore();
|
||||
}
|
||||
|
||||
// ── Drawing ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// feedBack#36 — swappable renderers.
|
||||
@ -736,7 +615,7 @@ function createHighway() {
|
||||
// that need lefty-aware text should check `bundle.lefty` and
|
||||
// apply the mirror transform themselves on their own context.
|
||||
b.project = project;
|
||||
b.fretX = fretX;
|
||||
b.fretX = boundFretX; // stable, hwState-bound — see the factory head
|
||||
// Windowed-iteration helpers (stable references): lower-bound
|
||||
// binary searches so custom viz don't full-scan chart arrays per
|
||||
// frame. lowerBoundT keys on `.t` (notes / chords); lowerBoundTime
|
||||
@ -751,7 +630,7 @@ function createHighway() {
|
||||
// on an overlay ring. Returns null when no provider is set
|
||||
// or it reports nothing for this note; otherwise
|
||||
// { state: 'hit'|'active'|'miss', alpha: 0..1, color: string|null }.
|
||||
b.getNoteState = _noteState; // stable reference
|
||||
b.getNoteState = boundNoteState; // stable, hwState-bound — see the factory head
|
||||
// Lets custom renderers (e.g. highway_3d) tell "is a provider
|
||||
// attached" apart from "no provider, getNoteState always
|
||||
// returns null" — `getNoteState` always exists on the bundle
|
||||
@ -1439,7 +1318,7 @@ function createHighway() {
|
||||
const t = (i / 40) * VISIBLE_SECONDS;
|
||||
const p = project(t);
|
||||
if (!p) continue;
|
||||
const x = fretX(fret, p.scale, W);
|
||||
const x = fretX(hwState, fret, p.scale, W);
|
||||
if (i === 0) hwState.ctx.moveTo(x, p.y * H);
|
||||
else hwState.ctx.lineTo(x, p.y * H);
|
||||
}
|
||||
@ -1565,14 +1444,14 @@ function createHighway() {
|
||||
hwState.ctx.fill();
|
||||
// Judgment glow (feedBack#254) — central halo on the bar.
|
||||
// _paintGemGlow takes a half-extent; barH is the full bar height.
|
||||
_paintGemGlow(W/2, y, barH * 0.5, string, ns);
|
||||
_paintGemGlow(hwState, W/2, y, barH * 0.5, string, ns);
|
||||
// "0" label
|
||||
const fontSize = Math.max(8, sz * 0.5) | 0;
|
||||
hwState.ctx.fillStyle = '#fff';
|
||||
hwState.ctx.font = `bold ${fontSize}px sans-serif`;
|
||||
hwState.ctx.textAlign = 'center';
|
||||
hwState.ctx.textBaseline = 'middle';
|
||||
fillTextReadable('0', W/2, y);
|
||||
fillTextReadable(hwState, '0', W/2, y);
|
||||
|
||||
// Technique labels on open strings — PM, H/P/T, tremolo, and
|
||||
// accent markers are all meaningful on fret 0. Bend and slide
|
||||
@ -1588,7 +1467,7 @@ function createHighway() {
|
||||
hwState.ctx.font = `bold ${Math.max(9, sz * 0.3) | 0}px sans-serif`;
|
||||
hwState.ctx.textAlign = 'center';
|
||||
hwState.ctx.textBaseline = 'bottom';
|
||||
fillTextReadable(label, W/2, y - barH/2 - 4);
|
||||
fillTextReadable(hwState, label, W/2, y - barH/2 - 4);
|
||||
}
|
||||
// PM below
|
||||
if (palmMute) {
|
||||
@ -1596,7 +1475,7 @@ function createHighway() {
|
||||
hwState.ctx.font = `bold ${Math.max(8, sz * 0.25) | 0}px sans-serif`;
|
||||
hwState.ctx.textAlign = 'center';
|
||||
hwState.ctx.textBaseline = 'top';
|
||||
fillTextReadable('PM', W/2, y + barH/2 + 2);
|
||||
fillTextReadable(hwState, 'PM', W/2, y + barH/2 + 2);
|
||||
}
|
||||
// Tremolo (wavy line above)
|
||||
if (tremolo) {
|
||||
@ -1655,7 +1534,7 @@ function createHighway() {
|
||||
hwState.ctx.font = `bold ${Math.max(8, sz * 0.25) | 0}px sans-serif`;
|
||||
hwState.ctx.textAlign = 'center';
|
||||
hwState.ctx.textBaseline = 'top';
|
||||
fillTextReadable('PH', x, y + dh + 2);
|
||||
fillTextReadable(hwState, 'PH', x, y + dh + 2);
|
||||
}
|
||||
} else {
|
||||
// Glow
|
||||
@ -1671,7 +1550,7 @@ function createHighway() {
|
||||
// Judgment glow (feedBack#254) — additive halo for a correct
|
||||
// hit / held sustain, faint red wash for a miss. Drawn before
|
||||
// the fret number so the number stays legible on top.
|
||||
_paintGemGlow(x, y, isHarmonic ? half * 1.2 : half, string, ns);
|
||||
_paintGemGlow(hwState, x, y, isHarmonic ? half * 1.2 : half, string, ns);
|
||||
|
||||
// Fret number
|
||||
const fontSize = Math.max(10, sz * 0.5) | 0;
|
||||
@ -1679,7 +1558,7 @@ function createHighway() {
|
||||
hwState.ctx.font = `bold ${fontSize}px sans-serif`;
|
||||
hwState.ctx.textAlign = 'center';
|
||||
hwState.ctx.textBaseline = 'middle';
|
||||
fillTextReadable(String(fret), x, y);
|
||||
fillTextReadable(hwState, String(fret), x, y);
|
||||
|
||||
// Bend notation
|
||||
if (bend && bend > 0 && sz >= 12) {
|
||||
@ -1748,7 +1627,7 @@ function createHighway() {
|
||||
hwState.ctx.font = `bold ${Math.max(9, sz * 0.28) | 0}px sans-serif`;
|
||||
hwState.ctx.textAlign = 'center';
|
||||
hwState.ctx.textBaseline = 'bottom';
|
||||
fillTextReadable(label, x, labelTopY - 2);
|
||||
fillTextReadable(hwState, label, x, labelTopY - 2);
|
||||
}
|
||||
|
||||
if (sz < 14) return; // Skip small technique labels
|
||||
@ -1764,7 +1643,7 @@ function createHighway() {
|
||||
hwState.ctx.font = `bold ${Math.max(8, sz * 0.26) | 0}px sans-serif`;
|
||||
hwState.ctx.textAlign = 'left';
|
||||
hwState.ctx.textBaseline = 'middle';
|
||||
fillTextReadable(fgLabel, x + half + 2, y + half * 0.5);
|
||||
fillTextReadable(hwState, fgLabel, x + half + 2, y + half * 0.5);
|
||||
}
|
||||
if (hwState._showTeachingMarks) {
|
||||
const sdLabel = teachingDegreeLabel(opts?.sd);
|
||||
@ -1773,7 +1652,7 @@ function createHighway() {
|
||||
hwState.ctx.font = `bold ${Math.max(8, sz * 0.26) | 0}px sans-serif`;
|
||||
hwState.ctx.textAlign = 'right';
|
||||
hwState.ctx.textBaseline = 'middle';
|
||||
fillTextReadable(sdLabel, x - half - 2, y + half * 0.5);
|
||||
fillTextReadable(hwState, sdLabel, x - half - 2, y + half * 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1810,7 +1689,7 @@ function createHighway() {
|
||||
hwState.ctx.font = `bold ${Math.max(9, sz * 0.3) | 0}px sans-serif`;
|
||||
hwState.ctx.textAlign = 'center';
|
||||
hwState.ctx.textBaseline = 'bottom';
|
||||
fillTextReadable(label, x, ly);
|
||||
fillTextReadable(hwState, label, x, ly);
|
||||
}
|
||||
|
||||
// Palm mute (PM below note)
|
||||
@ -1819,7 +1698,7 @@ function createHighway() {
|
||||
hwState.ctx.font = `bold ${Math.max(8, sz * 0.25) | 0}px sans-serif`;
|
||||
hwState.ctx.textAlign = 'center';
|
||||
hwState.ctx.textBaseline = 'top';
|
||||
fillTextReadable('PM', x, y + half + 2);
|
||||
fillTextReadable(hwState, 'PM', x, y + half + 2);
|
||||
}
|
||||
|
||||
// Tremolo (wavy line above)
|
||||
@ -1868,8 +1747,8 @@ function createHighway() {
|
||||
const p0 = project(t0), p1 = project(t1);
|
||||
if (!p0 || !p1) continue;
|
||||
|
||||
const x0 = fretX(n.f, p0.scale, W);
|
||||
const x1 = fretX(n.f, p1.scale, W);
|
||||
const x0 = fretX(hwState, n.f, p0.scale, W);
|
||||
const x1 = fretX(hwState, n.f, p1.scale, W);
|
||||
const sw0 = Math.max(2, 6 * p0.scale);
|
||||
const sw1 = Math.max(2, 6 * p1.scale);
|
||||
|
||||
@ -1880,7 +1759,7 @@ function createHighway() {
|
||||
// (the gem / overlay marks the miss; a red trail would be
|
||||
// noisy). Skip the lookup entirely when no provider is set —
|
||||
// zero cost in the hot loop for the common case.
|
||||
const ns = hwState._noteStateProvider ? _noteState(n, n.t) : null;
|
||||
const ns = hwState._noteStateProvider ? _noteState(hwState, n, n.t) : null;
|
||||
const litTrail = !!(ns && ns.state !== 'miss');
|
||||
const y0 = p0.y * H, y1 = p1.y * H;
|
||||
if (litTrail) {
|
||||
@ -1981,8 +1860,8 @@ function createHighway() {
|
||||
}
|
||||
if (!p) continue;
|
||||
|
||||
const x = fretX(n.f, p.scale, W);
|
||||
drawNote(W, H, x, p.y * H, p.scale, n.s, n.f, n, hwState._noteStateProvider ? _noteState(n, n.t) : null);
|
||||
const x = fretX(hwState, n.f, p.scale, W);
|
||||
drawNote(W, H, x, p.y * H, p.scale, n.s, n.f, n, hwState._noteStateProvider ? _noteState(hwState, n, n.t) : null);
|
||||
drawnNotes.push({
|
||||
t: n.t, s: n.s, f: n.f, bn: n.bn || 0, x, y: p.y * H, scale: p.scale,
|
||||
ch: Number.isInteger(n.ch) ? n.ch : -1,
|
||||
@ -2095,7 +1974,7 @@ function createHighway() {
|
||||
hwState.ctx.textBaseline = 'middle';
|
||||
const cpX = (x1 + 2 * midX + x2) / 4;
|
||||
const cpY = (y1 + 2 * midY + y2) / 4;
|
||||
fillTextReadable('U', cpX + sz * 0.3, cpY);
|
||||
fillTextReadable(hwState, 'U', cpX + sz * 0.3, cpY);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2153,7 +2032,7 @@ function createHighway() {
|
||||
if (hasNonZero) {
|
||||
xMin = Infinity; xMax = -Infinity;
|
||||
for (let k = 0; k < nonZeroFrets.length; k++) {
|
||||
const x = fretX(nonZeroFrets[k], p.scale, W);
|
||||
const x = fretX(hwState, nonZeroFrets[k], p.scale, W);
|
||||
if (x < xMin) xMin = x;
|
||||
if (x > xMax) xMax = x;
|
||||
}
|
||||
@ -2201,12 +2080,12 @@ function createHighway() {
|
||||
// Bracket bar above the notes.
|
||||
if (hasNonZero || sorted.length >= 2) {
|
||||
const positions = (hasNonZero ? nonZeroNotes : sorted).map((cn, j) => ({
|
||||
x: fretX(cn.f, p.scale, W),
|
||||
x: fretX(hwState, cn.f, p.scale, W),
|
||||
y: p.y * H - actualTotalH / 2 + j * actualSpread,
|
||||
}));
|
||||
const barY = positions[0].y - sz * 0.7;
|
||||
const barLeft = hasNonZero ? xMin : fretX(frameLeftFret, p.scale, W);
|
||||
const barRight = hasNonZero ? xMax : fretX(frameRightFret, p.scale, W);
|
||||
const barLeft = hasNonZero ? xMin : fretX(hwState, frameLeftFret, p.scale, W);
|
||||
const barRight = hasNonZero ? xMax : fretX(hwState, frameRightFret, p.scale, W);
|
||||
|
||||
hwState.ctx.fillStyle = REPEAT_BOX_BAR;
|
||||
hwState.ctx.lineWidth = Math.max(3, sz / 4);
|
||||
@ -2225,13 +2104,13 @@ function createHighway() {
|
||||
const labelX = hasNonZero
|
||||
? (xMin + xMax) / 2
|
||||
: (sorted.length >= 2
|
||||
? (fretX(frameLeftFret, p.scale, W) + fretX(frameRightFret, p.scale, W)) / 2
|
||||
: fretX(sorted[0].f, p.scale, W));
|
||||
? (fretX(hwState, frameLeftFret, p.scale, W) + fretX(hwState, frameRightFret, p.scale, W)) / 2
|
||||
: fretX(hwState, sorted[0].f, p.scale, W));
|
||||
hwState.ctx.fillStyle = '#fff';
|
||||
hwState.ctx.font = `bold ${Math.max(14, sz * 0.45) | 0}px sans-serif`;
|
||||
hwState.ctx.textAlign = 'center';
|
||||
hwState.ctx.textBaseline = 'bottom';
|
||||
fillTextReadable(tmpl.name, labelX, labelY);
|
||||
fillTextReadable(hwState, tmpl.name, labelX, labelY);
|
||||
}
|
||||
|
||||
// Harmony annotations (§6.3.1 / §6.6) — the chord's function
|
||||
@ -2246,8 +2125,8 @@ function createHighway() {
|
||||
const hx = hasNonZero
|
||||
? (xMin + xMax) / 2
|
||||
: (sorted.length >= 2
|
||||
? (fretX(frameLeftFret, p.scale, W) + fretX(frameRightFret, p.scale, W)) / 2
|
||||
: fretX(sorted[0].f, p.scale, W));
|
||||
? (fretX(hwState, frameLeftFret, p.scale, W) + fretX(hwState, frameRightFret, p.scale, W)) / 2
|
||||
: fretX(hwState, sorted[0].f, p.scale, W));
|
||||
// Baseline = just above where the chord name sits.
|
||||
const nameY = hasNonZero
|
||||
? (p.y * H - actualTotalH / 2 - sz * 0.7 - sz * 0.4)
|
||||
@ -2258,22 +2137,22 @@ function createHighway() {
|
||||
let stackY = nameY - sz * 0.5;
|
||||
if (rn) {
|
||||
hwState.ctx.fillStyle = '#ffcc66'; // matches the sd teaching color
|
||||
fillTextReadable(rn, hx, stackY);
|
||||
fillTextReadable(hwState, rn, hx, stackY);
|
||||
stackY -= sz * 0.45;
|
||||
}
|
||||
if (voicing) {
|
||||
hwState.ctx.fillStyle = '#7fd1ff'; // matches the fg teaching color
|
||||
fillTextReadable(voicing, hx, stackY);
|
||||
fillTextReadable(hwState, voicing, hx, stackY);
|
||||
stackY -= sz * 0.45;
|
||||
}
|
||||
if (caged) {
|
||||
hwState.ctx.fillStyle = '#a0ffa0'; // CAGED shape teaching color
|
||||
fillTextReadable(caged, hx, stackY);
|
||||
fillTextReadable(hwState, caged, hx, stackY);
|
||||
stackY -= sz * 0.45;
|
||||
}
|
||||
if (guideTones) {
|
||||
hwState.ctx.fillStyle = '#d0a0ff'; // guide-tone teaching color
|
||||
fillTextReadable(guideTones, hx, stackY);
|
||||
fillTextReadable(hwState, guideTones, hx, stackY);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2287,11 +2166,11 @@ function createHighway() {
|
||||
|
||||
for (let j = 0; j < sorted.length; j++) {
|
||||
const cn = sorted[j];
|
||||
const x = fretX(cn.f, p.scale, W);
|
||||
const x = fretX(hwState, cn.f, p.scale, W);
|
||||
const ny = p.y * H - actualTotalH / 2 + j * actualSpread;
|
||||
// feedBack#254 — per-string judgment, keyed by the
|
||||
// chord's chart time (matches how note_detect stores it).
|
||||
const cnNs = hwState._noteStateProvider ? _noteState(cn, ch.t) : null;
|
||||
const cnNs = hwState._noteStateProvider ? _noteState(hwState, cn, ch.t) : null;
|
||||
|
||||
// Open-string-in-chord wide bar — only when the note has no
|
||||
// technique flags. Otherwise fall back to drawNote so PM /
|
||||
@ -2302,21 +2181,21 @@ function createHighway() {
|
||||
const color = litBar ? (cnNs.color || hwState.STRING_BRIGHT[cn.s] || hwState.STRING_COLORS[cn.s] || '#888') : (hwState.STRING_COLORS[cn.s] || '#888');
|
||||
const dark = litBar ? (hwState.STRING_COLORS[cn.s] || '#666') : (hwState.STRING_DIM[cn.s] || '#222');
|
||||
const barH = sz;
|
||||
const barLeft = fretX(frameLeftFret, p.scale, W);
|
||||
const barRight = fretX(frameRightFret, p.scale, W);
|
||||
const barLeft = fretX(hwState, frameLeftFret, p.scale, W);
|
||||
const barRight = fretX(hwState, frameRightFret, p.scale, W);
|
||||
hwState.ctx.fillStyle = dark;
|
||||
roundRect(hwState.ctx, barLeft - 1, ny - barH / 2 - 1, barRight - barLeft + 2, barH + 2, 3);
|
||||
hwState.ctx.fill();
|
||||
hwState.ctx.fillStyle = color;
|
||||
roundRect(hwState.ctx, barLeft, ny - barH / 2, barRight - barLeft, barH, 2);
|
||||
hwState.ctx.fill();
|
||||
_paintGemGlow((barLeft + barRight) / 2, ny, barH * 0.5, cn.s, cnNs);
|
||||
_paintGemGlow(hwState, (barLeft + barRight) / 2, ny, barH * 0.5, cn.s, cnNs);
|
||||
const fontSize = Math.max(8, sz * 0.5) | 0;
|
||||
hwState.ctx.fillStyle = '#fff';
|
||||
hwState.ctx.font = `bold ${fontSize}px sans-serif`;
|
||||
hwState.ctx.textAlign = 'center';
|
||||
hwState.ctx.textBaseline = 'middle';
|
||||
fillTextReadable('0', (barLeft + barRight) / 2, ny);
|
||||
fillTextReadable(hwState, '0', (barLeft + barRight) / 2, ny);
|
||||
} else {
|
||||
drawNote(W, H, x, ny, p.scale, cn.s, cn.f, { ...cn, chord: true }, cnNs);
|
||||
}
|
||||
@ -2364,7 +2243,7 @@ function createHighway() {
|
||||
hwState.ctx.textBaseline = 'middle';
|
||||
const cpX = (x1 + 2 * midX + x2) / 4;
|
||||
const cpY = (y1 + 2 * midY + y2) / 4;
|
||||
fillTextReadable('U', cpX + sz * 0.3, cpY);
|
||||
fillTextReadable(hwState, 'U', cpX + sz * 0.3, cpY);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2383,10 +2262,10 @@ function createHighway() {
|
||||
|
||||
for (let fret = lo; fret <= hi; fret++) {
|
||||
if (fret < 0) continue;
|
||||
const x = fretX(fret, 1.0, W);
|
||||
const x = fretX(hwState, fret, 1.0, W);
|
||||
const inAnchor = fret >= anchor.fret && fret <= anchor.fret + anchor.width;
|
||||
hwState.ctx.fillStyle = inAnchor ? '#e8c040' : '#8a6830';
|
||||
fillTextReadable(String(fret), x, y);
|
||||
fillTextReadable(hwState, String(fret), x, y);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2721,8 +2600,8 @@ function createHighway() {
|
||||
const yCenter = p.y * H;
|
||||
const boxTop = yCenter - actualTotalH / 2 - sz * 0.5;
|
||||
const boxBottom = boxTop + Math.max(sz, actualTotalH + sz);
|
||||
const boxX = fretX(baseFret, p.scale, W);
|
||||
const boxW = fretX(baseFret + CHORD_FRAME_FRETS, p.scale, W) - boxX;
|
||||
const boxX = fretX(hwState, baseFret, p.scale, W);
|
||||
const boxW = fretX(hwState, baseFret + CHORD_FRAME_FRETS, p.scale, W) - boxX;
|
||||
return { boxX, boxW, boxTop, boxH: boxBottom - boxTop };
|
||||
}
|
||||
|
||||
@ -2789,13 +2668,13 @@ function createHighway() {
|
||||
for (const cn of hwState._chordFretLineNotes) {
|
||||
const yi = hwState._inverted ? 5 - cn.s : cn.s;
|
||||
const syl = strTop + (yi / 5) * (strBot - strTop);
|
||||
const fretXPos = fretX(cn.f, 1, W);
|
||||
const fretXPos = fretX(hwState, cn.f, 1, W);
|
||||
hwState.ctx.fillStyle = hwState.STRING_COLORS[cn.s] || '#888';
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.arc(fretXPos, syl, noteSize / 2, 0, Math.PI * 2);
|
||||
hwState.ctx.fill();
|
||||
hwState.ctx.fillStyle = '#fff';
|
||||
fillTextReadable(String(cn.f), fretXPos, syl);
|
||||
fillTextReadable(hwState, String(cn.f), fretXPos, syl);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3896,7 +3775,7 @@ function createHighway() {
|
||||
}
|
||||
},
|
||||
/** Resolve the registered provider for one note (normalized). */
|
||||
getNoteState(note, chartTime) { return _noteState(note, chartTime); },
|
||||
getNoteState(note, chartTime) { return _noteState(hwState, note, chartTime); },
|
||||
/**
|
||||
* Fire all registered draw hooks on the given 2D context.
|
||||
* Custom renderers (e.g. the 3D highway) that maintain their own
|
||||
@ -3910,10 +3789,10 @@ function createHighway() {
|
||||
}
|
||||
},
|
||||
project(tOffset) { return project(tOffset); },
|
||||
fretX(fret, scale, w) { return fretX(fret, scale, w); },
|
||||
fretX(fret, scale, w) { return fretX(hwState, fret, scale, w); },
|
||||
|
||||
/** Use when drawing text inside the lefty mirror; noop when not lefty. */
|
||||
fillTextUnmirrored(text, x, y) { fillTextReadable(text, x, y); },
|
||||
fillTextUnmirrored(text, x, y) { fillTextReadable(hwState, text, x, y); },
|
||||
|
||||
toggleLyrics() {
|
||||
hwState.showLyrics = !hwState.showLyrics;
|
||||
|
||||
170
static/js/highway-state-primitives.js
Normal file
170
static/js/highway-state-primitives.js
Normal file
@ -0,0 +1,170 @@
|
||||
// highway.js's STATEFUL primitives: the four shared helpers that need per-instance state.
|
||||
//
|
||||
// ━━━ hwState IS A PARAMETER, NOT AN IMPORT. THIS IS THE WHOLE DESIGN. ━━━
|
||||
//
|
||||
// createHighway() is a FACTORY. The constitution publishes window.createHighway so a plugin can
|
||||
// build a SECOND highway for its own panel, and highway.js says so itself:
|
||||
//
|
||||
// // R3c: per-instance mutable state in one object, so extracted renderer/ws
|
||||
// // modules can close over it as a factory arg without cross-panel sharing.
|
||||
//
|
||||
// Import hwState as a module singleton and the two panels silently share one clock, one render
|
||||
// scale, one string palette — each driving the other. Nothing would throw. The picture would
|
||||
// just be wrong, in a way no test would catch.
|
||||
//
|
||||
// So every function here takes hwState as its FIRST ARGUMENT. It reads a little worse at the
|
||||
// call site and it is the only correct shape.
|
||||
//
|
||||
// (This is the exact opposite of the app.js carve, where player-state.js and library-state.js
|
||||
// ARE module singletons — correctly, because there is exactly one app. Same epic, same
|
||||
// language, opposite answer, decided entirely by whether the thing is a factory.)
|
||||
//
|
||||
// The PURE primitives — project, roundRect, and the label helpers — need none of this and live
|
||||
// in ./highway-geometry.js.
|
||||
// No imports. These four need nothing but the hwState they are handed and their arguments.
|
||||
|
||||
export function fretX(hwState, fret, scale, w) {
|
||||
const hw = w * 0.52 * scale;
|
||||
const margin = hw * 0.06;
|
||||
const usable = hw * 2 - 2 * margin;
|
||||
const t = fret / Math.max(1, hwState.displayMaxFret);
|
||||
return w / 2 - hw + margin + t * usable;
|
||||
}
|
||||
|
||||
export function fillTextReadable(hwState, text, x, y) {
|
||||
// ctx may be null when the 2D context was never acquired
|
||||
// (canvas already locked to WebGL). No-op in that case —
|
||||
// alternatives would be throwing, which breaks plugin hooks
|
||||
// that call this after a context-type mismatch.
|
||||
if (!hwState.canvas || !hwState.ctx) return;
|
||||
const W = hwState.canvas.width;
|
||||
if (!hwState._lefty) {
|
||||
hwState.ctx.fillText(text, x, y);
|
||||
return;
|
||||
}
|
||||
hwState.ctx.save();
|
||||
hwState.ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
hwState.ctx.fillText(text, W - x, y);
|
||||
hwState.ctx.restore();
|
||||
}
|
||||
|
||||
// ── Per-note judgment state (feedBack#254) ──────────────────────────
|
||||
// Resolves the registered provider for one chart note. Returns null
|
||||
// when no provider is set, the provider throws, it reports nothing,
|
||||
// or the reported alpha is non-positive. Otherwise a normalized
|
||||
// { state: 'hit'|'active'|'miss', alpha: 0..1, color: string|null }.
|
||||
// 'hit' and 'active' are both "lit" — renderers may treat them the
|
||||
// same; the distinction (struck note vs currently-held sustain) is
|
||||
// there for renderers that want it. The provider owns all timing /
|
||||
// fade — `alpha` is whatever intensity it wants right now.
|
||||
export function _noteState(hwState, note, chartTime) {
|
||||
if (!hwState._noteStateProvider) return null;
|
||||
let raw;
|
||||
try { raw = hwState._noteStateProvider(note, chartTime); } catch (e) { return null; }
|
||||
if (!raw) return null;
|
||||
const state = typeof raw === 'string' ? raw : raw.state;
|
||||
if (state !== 'hit' && state !== 'active' && state !== 'miss') return null;
|
||||
const alpha = (raw && typeof raw === 'object' && Number.isFinite(raw.alpha))
|
||||
? Math.max(0, Math.min(1, raw.alpha))
|
||||
: 1;
|
||||
if (alpha <= 0) return null;
|
||||
const color = (raw && typeof raw === 'object' && typeof raw.color === 'string') ? raw.color : null;
|
||||
// Pass through the provider's `live` flag: note_detect tags its
|
||||
// ring-tracking 'active' responses with live:true so a renderer can
|
||||
// treat them as authoritative (extinguish on mute, relight on
|
||||
// re-strike) instead of latching them for the whole chart sustain.
|
||||
// Renderers that don't care simply ignore it.
|
||||
const live = (raw && typeof raw === 'object' && raw.live === true);
|
||||
return { state, alpha, color, live };
|
||||
}
|
||||
|
||||
// Paints the judgment effect on top of an already-drawn gem at
|
||||
// (cx,cy) with half-extent `r`. `ns` is the normalized state from
|
||||
// _noteState (or null → no-op). A miss → faint red wash. A correct
|
||||
// hit / held sustain → a "sizzle": throbbing additive halo + a
|
||||
// flickering white-hot core + crackling spark lines re-randomised
|
||||
// each frame + (for a fresh struck note that's fading) an expanding
|
||||
// shockwave ring. Intensity scales with `ns.alpha`, so a struck
|
||||
// note flares and dies while a held sustain crackles continuously.
|
||||
// Caller draws the gem normally first, then calls this BEFORE any
|
||||
// glyph so a readable fret number can land on top.
|
||||
export function _paintGemGlow(hwState, cx, cy, r, stringIdx, ns) {
|
||||
if (!ns || !hwState.ctx) return;
|
||||
hwState.ctx.save();
|
||||
if (ns.state === 'miss') {
|
||||
hwState.ctx.globalAlpha = 0.4 * ns.alpha;
|
||||
hwState.ctx.fillStyle = '#ff2828';
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.arc(cx, cy, r * 1.05, 0, Math.PI * 2);
|
||||
hwState.ctx.fill();
|
||||
hwState.ctx.restore();
|
||||
return;
|
||||
}
|
||||
const col = ns.color || hwState.STRING_BRIGHT[stringIdx] || '#ffffff';
|
||||
const a = ns.alpha;
|
||||
const nowMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
|
||||
hwState.ctx.lineCap = 'round';
|
||||
|
||||
// Expanding shockwave — only on a fresh struck-and-fading hit
|
||||
// (alpha decays 1→0). 'active' (held sustain, alpha pinned 1) skips it.
|
||||
if (ns.state === 'hit' && a < 1) {
|
||||
const prog = 1 - a; // 0 at strike → 1 at fade-out
|
||||
hwState.ctx.globalCompositeOperation = 'lighter';
|
||||
hwState.ctx.globalAlpha = a * 0.85;
|
||||
hwState.ctx.strokeStyle = col;
|
||||
hwState.ctx.lineWidth = Math.max(1.5, r * 0.26 * a);
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.arc(cx, cy, r * (1.0 + prog * 2.7), 0, Math.PI * 2);
|
||||
hwState.ctx.stroke();
|
||||
}
|
||||
|
||||
// Throbbing halo (≈9 Hz wobble).
|
||||
const pulse = 0.8 + 0.2 * Math.sin(nowMs / 18);
|
||||
const haloR = r * 2.0 * pulse;
|
||||
hwState.ctx.globalCompositeOperation = 'lighter';
|
||||
hwState.ctx.globalAlpha = a;
|
||||
const g = hwState.ctx.createRadialGradient(cx, cy, 0, cx, cy, haloR);
|
||||
g.addColorStop(0, '#ffffff');
|
||||
g.addColorStop(0.30, col);
|
||||
g.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
hwState.ctx.fillStyle = g;
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.arc(cx, cy, haloR, 0, Math.PI * 2);
|
||||
hwState.ctx.fill();
|
||||
|
||||
// Crackle — short bright spark lines flicking out from the gem,
|
||||
// re-randomised every frame so it shimmers.
|
||||
const sparkCount = 6;
|
||||
for (let i = 0; i < sparkCount; i++) {
|
||||
if (Math.random() > 0.55 * a + 0.2) continue; // intermittent
|
||||
const ang = Math.random() * Math.PI * 2;
|
||||
const inR = r * 0.45;
|
||||
const len = r * (0.7 + Math.random() * 1.6) * (0.5 + 0.5 * a);
|
||||
hwState.ctx.globalAlpha = a * (0.45 + Math.random() * 0.55);
|
||||
hwState.ctx.strokeStyle = Math.random() < 0.5 ? '#ffffff' : col;
|
||||
hwState.ctx.lineWidth = Math.max(1, r * (0.08 + Math.random() * 0.08));
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.moveTo(cx + Math.cos(ang) * inR, cy + Math.sin(ang) * inR);
|
||||
hwState.ctx.lineTo(cx + Math.cos(ang) * (inR + len), cy + Math.sin(ang) * (inR + len));
|
||||
hwState.ctx.stroke();
|
||||
}
|
||||
|
||||
// Flickering white-hot core.
|
||||
hwState.ctx.globalCompositeOperation = 'lighter';
|
||||
hwState.ctx.globalAlpha = a * (0.55 + Math.random() * 0.45);
|
||||
hwState.ctx.fillStyle = '#ffffff';
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.arc(cx, cy, r * (0.30 + Math.random() * 0.14), 0, Math.PI * 2);
|
||||
hwState.ctx.fill();
|
||||
|
||||
// Crisp bright rim.
|
||||
hwState.ctx.globalCompositeOperation = 'source-over';
|
||||
hwState.ctx.globalAlpha = a;
|
||||
hwState.ctx.strokeStyle = col;
|
||||
hwState.ctx.lineWidth = Math.max(2, r * 0.2);
|
||||
hwState.ctx.beginPath();
|
||||
hwState.ctx.arc(cx, cy, r * 0.95, 0, Math.PI * 2);
|
||||
hwState.ctx.stroke();
|
||||
|
||||
hwState.ctx.restore();
|
||||
}
|
||||
@ -12,6 +12,10 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
// R3c: _noteState moved to static/js/highway-state-primitives.js and gained an explicit
|
||||
// hwState first parameter — it has to, because createHighway() is a factory and a module
|
||||
// cannot import per-instance state without two panels sharing it.
|
||||
const primitivesJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-state-primitives.js');
|
||||
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
|
||||
// Brace-balanced extraction (same helper shape as highway_visibility.test.js).
|
||||
@ -51,7 +55,15 @@ test('_makeBundle exposes getNoteState (stable reference, no per-frame alloc)',
|
||||
const fn = extractBlock(src, 'function _makeBundle()');
|
||||
// The bundle field must point straight at _noteState — not a fresh
|
||||
// arrow each frame (the per-frame allocation the review flagged).
|
||||
assert.match(fn, /getNoteState\s*[:=]\s*_noteState\b/, 'bundle.getNoteState must be the stable _noteState reference');
|
||||
// R3c: _noteState now takes hwState first, so the bundle hands out a per-INSTANCE bound
|
||||
// view created ONCE in the factory (boundNoteState) rather than the raw function. The
|
||||
// contract that matters is unchanged and still asserted: ONE stable reference, never a
|
||||
// fresh arrow per frame (feedBack#254). Assert it is a bare identifier, not an inline
|
||||
// function expression.
|
||||
assert.match(fn, /getNoteState\s*[:=]\s*(?:boundNoteState|_noteState)\b/,
|
||||
'bundle.getNoteState must be a stable reference (a name), not a per-frame arrow');
|
||||
assert.doesNotMatch(fn, /getNoteState\s*[:=]\s*(?:\(|function)/,
|
||||
'bundle.getNoteState must NOT be a fresh function per frame');
|
||||
});
|
||||
|
||||
test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#254)', () => {
|
||||
@ -79,7 +91,7 @@ test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#2
|
||||
|
||||
test('_noteState normalizes provider output as documented', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _noteState(note, chartTime)');
|
||||
const fn = extractBlock(fs.readFileSync(primitivesJs, 'utf8'), 'function _noteState(hwState, note, chartTime)');
|
||||
assert.match(fn, /if\s*\(\s*!hwState\._noteStateProvider\s*\)\s*return\s+null/, 'must short-circuit when no provider is registered');
|
||||
assert.match(fn, /try\s*\{[\s\S]*_noteStateProvider\s*\([\s\S]*catch[\s\S]*return\s+null/, 'must call the provider inside try/catch and return null on throw');
|
||||
assert.match(fn, /state\s*!==\s*['"]hit['"]\s*&&\s*state\s*!==\s*['"]active['"]\s*&&\s*state\s*!==\s*['"]miss['"]/, 'must reject states other than hit/active/miss');
|
||||
@ -94,8 +106,15 @@ test('default 2D renderer threads note state into drawNote / drawSustains / chor
|
||||
// drawNote takes the trailing `ns` param.
|
||||
assert.match(src, /function\s+drawNote\(\s*W\s*,\s*H\s*,\s*x\s*,\s*y\s*,\s*scale\s*,\s*string\s*,\s*fret\s*,\s*opts\s*,\s*ns\s*\)/, 'drawNote must accept the trailing ns param');
|
||||
// drawNotes / drawSustains / drawChords gate the lookup on the provider.
|
||||
assert.match(src, /_noteStateProvider\s*\?\s*_noteState\(\s*n\s*,\s*n\.t\s*\)\s*:\s*null/, 'visible-note paths must skip the lookup when no provider is set');
|
||||
assert.match(src, /_noteStateProvider\s*\?\s*_noteState\(\s*cn\s*,\s*ch\.t\s*\)\s*:\s*null/, 'chord-note path must key the lookup by the chord time and gate on the provider');
|
||||
// R3c: _noteState gained an explicit hwState first arg (it lives in a module now, and
|
||||
// createHighway is a factory). The CONTRACT here is unchanged and still the point: skip
|
||||
// the lookup entirely when no provider is set — a per-visible-note call on every frame.
|
||||
assert.match(src, /_noteStateProvider\s*\?\s*_noteState\(\s*hwState\s*,\s*n\s*,\s*n\.t\s*\)\s*:\s*null/,
|
||||
'visible-note paths must skip the lookup when no provider is set');
|
||||
// Same, for the chord path: keyed by the CHORD's time (ch.t), not the note's, and still
|
||||
// gated on the provider. Only the hwState arg is new.
|
||||
assert.match(src, /_noteStateProvider\s*\?\s*_noteState\(\s*hwState\s*,\s*cn\s*,\s*ch\.t\s*\)\s*:\s*null/,
|
||||
'chord-note path must key the lookup by the chord time and gate on the provider');
|
||||
});
|
||||
|
||||
test('3D highway captures bundle.getNoteState and overrides legacy hit/miss with the provider verdict', () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user