mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-26 23:01:22 +00:00
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>
171 lines
7.9 KiB
JavaScript
171 lines
7.9 KiB
JavaScript
// 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();
|
|
}
|