From e05d90e9495757c8e00643ed0de4a4fc7fa83994 Mon Sep 17 00:00:00 2001 From: topkoa Date: Sat, 18 Jul 2026 20:30:03 -0400 Subject: [PATCH] feat(highway_3d): wire flash is a lightning strike, not a lingering glow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flash was instant-on with a 0.32 s exponential tail, and a held sustain kept re-feeding it — wires stayed lit for the whole note. The requested feel is a shock: light hits the frets, they jolt, it's over. The flash is now a one-shot pulse triggered on the input's rising edge: a near-instant crack up (RISE 25 ms), a fast fall (FALL 160 ms) shaped (1-u)^2 so it drops hard then eases out, with a 26 Hz flicker biting into the fall (the electric shudder — the crack itself stays clean), then hard zero. A held 'active' verdict keeps the input high continuously, which by construction triggers nothing new: one strike per hit, and the wires go dark while the note rings on. A re-strike after the provider goes silent re-triggers cleanly. Seeking backward or a long stall clears all pulse state, and a pulse whose strike time lands ahead of the playhead after a seek is discarded. The outer-pair bracket rule is unchanged — it now selects across pulses instead of decay tails. Knobs: FRET_WIRE_HIT_RISE / _FALL / _FLICKER_HZ / _FLICKER_DEPTH (replacing FRET_WIRE_HIT_DECAY). Signed-off-by: topkoa --- CHANGELOG.md | 4 +- plugins/highway_3d/screen.js | 73 +++++++++++++++++++++++++++--------- 2 files changed, 58 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3769d89..93de5d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,7 +55,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 survives only as the fallback on anchor-less charts). At most **two wires are ever lit at once**: when overlapping decay tails (fast passages) would light a run of wires, the flash collapses to the outermost pair of the lit span — one bracket, never a picket - fence. The gem's rim joins in: on a confirmed hit the outline flashes in the + fence. The flash itself is a one-shot "lightning strike": a near-instant crack up, a + fast shocked fall with an electric flicker biting into it, then hard dark — a held + sustain triggers exactly one strike and the wires go out while the note rings on. The gem's rim joins in: on a confirmed hit the outline flashes in the string's own colour with the same intensity treatment as the wires, fading with the scorer's alpha. With no scorer attached, nothing changes. diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 390785e..ad3ba0a 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -921,7 +921,13 @@ // The provider already fades its own `alpha` on a struck note; this tail // just keeps the hand-off from popping, and smooths the frame-to-frame // jitter of a held sustain (whose alpha tracks live input level). - const FRET_WIRE_HIT_DECAY = 0.32; + // The flash is a one-shot "lightning strike" pulse, not a lingering glow: + // a near-instant crack up, then a fast shocked fall with an electric + // flicker, then HARD ZERO — a held sustain does not keep the wires lit. + const FRET_WIRE_HIT_RISE = 0.025; // s, the crack — near-instant fade-in + const FRET_WIRE_HIT_FALL = 0.16; // s, the shock dying out; dark after + const FRET_WIRE_HIT_FLICKER_HZ = 26; // shudder rate during the fall + const FRET_WIRE_HIT_FLICKER_DEPTH = 0.35; // how deep the shudder bites (0..1) const S_BASE = 3 * K; const S_GAP = 4 * K; @@ -4784,11 +4790,18 @@ const _scrStringAnticipation = new Array(MAX_RENDER_STRINGS).fill(0); const _scrFretHeat = new Array(NFRETS + 1).fill(0); // Fret-wire hit flash. _fwHitIn is per-frame (cleared with the rest of - // the frame state, written by drawNote when a provider confirms a note); - // _fwHitGlow persists across frames so the flash can decay smoothly - // rather than snapping off the frame the provider goes quiet. + // the frame state, written by drawNote when a provider confirms a note). + // A RISING EDGE on it triggers a one-shot pulse: _fwPulseT0 records the + // strike's chart time, _fwPulseAmp its amplitude, _fwPrevIn detects the + // edge (a held 'active' verdict keeps the input high continuously, so a + // sustain triggers exactly one pulse — the wire goes dark while the + // note rings on). _fwHitGlow holds each wire's evaluated envelope value + // this frame, for the outer-pair selection below. const _fwHitIn = new Float32Array(NFRETS + 1); const _fwHitGlow = new Float32Array(NFRETS + 1); + const _fwPulseT0 = new Float32Array(NFRETS + 1).fill(-Infinity); + const _fwPulseAmp = new Float32Array(NFRETS + 1); + const _fwPrevIn = new Float32Array(NFRETS + 1); // Per-frame chord accumulator. A chord flashes only the OUTERMOST wires // of its shape, but drawNote() sees one chord note at a time and can't // know the span — so hits accumulate here keyed by chord, and the flash @@ -12700,23 +12713,47 @@ } const _fwDt = now - _fwHitPrevTime; - if (!(_fwDt >= 0) || _fwDt > 1) _fwHitGlow.fill(0); // first frame, seek, or long stall - const _fwDecay = (_fwDt > 0 && _fwDt <= 1) - ? Math.exp(-_fwDt / FRET_WIRE_HIT_DECAY) - : 0; + if (!(_fwDt >= 0) || _fwDt > 1) { // first frame, seek, or long stall + _fwHitGlow.fill(0); + _fwPulseT0.fill(-Infinity); + _fwPulseAmp.fill(0); + _fwPrevIn.fill(0); + } _fwHitPrevTime = now; - // Decay EVERY wire's glow state, but flash only the OUTERMOST - // pair of lit wires. Fast passages overlap their decay tails, so - // without this a run of consecutive notes lights a picket fence - // of wires at once; collapsing to the outer pair keeps the whole - // lit span reading as ONE bracket — the same rule chords already - // follow, applied across everything currently glowing. Interior - // wires keep decaying invisibly (the base tier loop re-seeds - // their materials each frame), so the bracket tightens naturally - // as outer tails expire. + // Evaluate every wire's pulse, but flash only the OUTERMOST pair + // of lit wires (one bracket, never a picket fence — the chord + // rule applied across everything currently pulsing). The pulse + // itself is the lightning strike: a rising edge on the input + // triggers it, it cracks up over RISE, falls over FALL with a + // high-rate flicker biting into it (the shock), and is then + // hard-zero — a held sustain keeps the input high but triggers + // nothing new, so the wires go dark while the note rings. + const _fwSpan = FRET_WIRE_HIT_RISE + FRET_WIRE_HIT_FALL; let _fwLo = -1, _fwHi = -1; for (let _f = 0; _f <= NFRETS; _f++) { - const _g = Math.max(_fwHitIn[_f], _fwHitGlow[_f] * _fwDecay); + const _in = _fwHitIn[_f]; + if (_in > 0.004 && _fwPrevIn[_f] <= 0.004) { + _fwPulseT0[_f] = now; // strike + _fwPulseAmp[_f] = _in; + } + _fwPrevIn[_f] = _in; + let _g = 0; + const _pt = now - _fwPulseT0[_f]; + if (_pt >= 0 && _pt < _fwSpan) { + if (_pt < FRET_WIRE_HIT_RISE) { + _g = _pt / FRET_WIRE_HIT_RISE; + } else { + const _u = (_pt - FRET_WIRE_HIT_RISE) / FRET_WIRE_HIT_FALL; + _g = (1 - _u) * (1 - _u); // fast drop, easing out + // Electric shudder during the fall only — the crack + // itself stays clean. + _g *= 1 - FRET_WIRE_HIT_FLICKER_DEPTH + * (0.5 + 0.5 * Math.sin(_pt * 6.2832 * FRET_WIRE_HIT_FLICKER_HZ)); + } + _g *= _fwPulseAmp[_f]; + } else if (_pt < 0) { + _fwPulseT0[_f] = -Infinity; // seek landed before the strike + } _fwHitGlow[_f] = _g; if (_g < 0.004) continue; // below perceptible if (_fwLo < 0) _fwLo = _f;