diff --git a/plugins/highway_3d/plugin.json b/plugins/highway_3d/plugin.json index d838313..324bcb5 100644 --- a/plugins/highway_3d/plugin.json +++ b/plugins/highway_3d/plugin.json @@ -1,13 +1,20 @@ { - "id": "highway_3d", - "name": "3D Highway", - "version": "3.41.0", - "type": "visualization", - "scriptType": "module", - "bundled": true, - "script": "screen.js", - "styles": "assets/plugin.css", - "settings": { "html": "settings.html", "category": "graphics", "server_files": ["plugin_uploads/highway_3d/current.mp4", "plugin_uploads/highway_3d/current.webm"] }, - "routes": "routes.py", - "tour": "tour.json" + "id": "highway_3d", + "name": "3D Highway", + "version": "3.42.0", + "type": "visualization", + "scriptType": "module", + "bundled": true, + "script": "screen.js", + "styles": "assets/plugin.css", + "settings": { + "html": "settings.html", + "category": "graphics", + "server_files": [ + "plugin_uploads/highway_3d/current.mp4", + "plugin_uploads/highway_3d/current.webm" + ] + }, + "routes": "routes.py", + "tour": "tour.json" } diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 3c13714..efdaa4f 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -1,15301 +1,14705 @@ -// 3D Highway visualization plugin — Three.js note highway. -// Visual layer from joel's prototype (vibrant palette, glowing strings, -// fret heat, dynamic lane, chord frame-boxes, per-note connector labels, -// board projection, outline+core note meshes) adapted into the -// feedBackViz setRenderer contract (feedBack#36) so it works in the -// main player and per-panel in splitscreen without any architectural -// changes. - -import { geoFretX, dZ, slideTrailEnd, camBaseDistU, camLowFretPullbackU, computeBPM, _makeGaussTex, RENDER_ORDER_LAYER_STACK, RENDER_ORDER_LAYER_INDEX, RENDER_ORDER_AT_Z_ZERO, RENDER_ORDER_FAR_CLAMP, renderOrderForLayerAtZ, _noteKey, lowerBoundT, hwyFirstRelevantFrettedTime, geoFretMid } from './src/geometry.js'; // h3d-carve-1b -import { loadThree, T } from './src/three-loader.js'; // h3d-carve-2 -import { _h3dHexToInt, _clampByteI, _darkenInt, _lightenInt, resolveStringCount as _resolveStringCountBase, _NOTE_NAMES_SHARP, _BASE_OPEN_MIDI_BASS4, _BASE_OPEN_MIDI_BASS5, _BASE_OPEN_MIDI_GUITAR6, _BASE_OPEN_MIDI_GUITAR7, _BASE_OPEN_MIDI_GUITAR8, _baseOpenStringMidis, _midiToPitchLabel, _openStringPitchLabelsForTuning as _openStringPitchLabelsForTuningBase, _ssActive, _ssIsCanvasFocused } from './src/utils.js'; // h3d-carve-3 -import { _bcIsDesktop, _bcCreateController, _bcLoadSettings, _bcFfIdx } from './src/bc-panel.js'; // h3d-carve-4 -import { createBgControl } from './src/bg-control.js'; // h3d-carve-5 - -(function () { - 'use strict'; - - /* ====================================================================== - * Constants - * ====================================================================== */ - - // Three.js is vendored under static/vendor/three/ in core (pinned r170 — - // see static/vendor/three/VERSION). The bundled plugin loads from the - // same origin to avoid the first-launch CDN round-trip and to pin the - // version against breakages from upstream Three.js drift. - // THREE_URL / THREE_CDN — h3d-carve-4: dead code (three-loader.js owns its own URLs). Tombstoned. - - // ── B-section: Butterchurn control panel — h3d-carve-4 ────────────── - // Moved to src/bc-panel.js. Imports: _bcIsDesktop, _bcCreateController, _bcLoadSettings, _bcFfIdx. - // window.h3dBcApplySettings assigned at bc-panel.js module scope (R5). - // ──────────────────────────────────────────────────────────────────── - // TOMBSTONE: BC_VENDOR, BC_FRAME, BC_WORKLET, _bcMeters, BC_BTN — h3d-carve-4: moved to src/bc-panel.js. - // TOMBSTONE: _bcLoading — h3d-carve-4: moved to src/bc-panel.js. - // TOMBSTONE: _bcLoadLib() — h3d-carve-4: moved to src/bc-panel.js. - // TOMBSTONE: _bcResolve, _bcPresets, _bcIsDesktop, _bcFfIdx, _bcReleaseCanvasGL — h3d-carve-4: moved to src/bc-panel.js. - - // TOMBSTONE: _bcGuitarFeed — h3d-carve-4: moved to src/bc-panel.js. - // TOMBSTONE: BC_LS, BC_DEFAULTS, _bcSettings, _bcLoadSettings, _bcSaveSettings — h3d-carve-4: moved to src/bc-panel.js. - // TOMBSTONE: _bcControllers, _bcApplyAll — h3d-carve-4: moved to src/bc-panel.js. - // TOMBSTONE: window.h3dBcApplySettings — h3d-carve-4: assigned at src/bc-panel.js module scope (R5; 1 beyond-subst). - - // TOMBSTONE: BC_DEFAULT_FAVORITES, BC_DEFAULT_BANS, _bcFavorites, _bcBanned, _bcListsLoaded — h3d-carve-4: moved to src/bc-panel.js. - // TOMBSTONE: _bcLoadLists, _bcSaveLists, _bcRestoreDefaults — h3d-carve-4: moved to src/bc-panel.js. - // TOMBSTONE: _bcPrimary — h3d-carve-4: moved to src/bc-panel.js. - // TOMBSTONE: _bcPane, _bcListEl, _bcFilterEl, _bcPaneOpen, _bcCollapsed — h3d-carve-4: moved to src/bc-panel.js. - // TOMBSTONE: _bcStatusMark, _bcSetHold, _bcLayout, _bcSetPane — h3d-carve-4: moved to src/bc-panel.js. - // TOMBSTONE: _bcRenderList, _bcUpdatePanelPreset — h3d-carve-4: moved to src/bc-panel.js. - - // TOMBSTONE: _bcPanel, _bcPanelKeyBound — h3d-carve-4: moved to src/bc-panel.js. - // TOMBSTONE: _bcEnsurePanel, _bcCreateController — h3d-carve-4: moved to src/bc-panel.js (exported as _bcIsDesktop, _bcCreateController). - - // Selectable per-string color palettes (issue #10). Each palette has - // 8 entries to match MAX_RENDER_STRINGS so 6/7/8-string arrangements - // all index safely. Default is the canonical chart-format classic - // mapping (low E=red, A=yellow, D=blue, G=orange, B=green, - // high E=purple); Neon pushes saturation harder; Pastel desaturates - // for long-session comfort; Colorblind (high contrast) is derived from - // the chart format's built-in colorblind-mode palette, but this preset - // intentionally keeps some entries tuned for feedBack rather than - // reproducing every original hex value verbatim. The chart-format base - // values came from community reverse-engineering of the original chart - // files; do not treat the tuned values below as the exact original - // palette. - // In feedBack's index convention s=0 is the low E (thickest) and - // s=5 is the high E (thinnest), matching the chart format's native string - // indexing. Per-index ordering is preserved across all palettes so - // switching between them never reassigns a string to a different - // colour family. Indices 6/7 are supplementary slots used for - // 7/8-string arrangements. - // NOTE: settings.html mirrors these arrays in its hydration script - // for the palette-preview swatches — keep them in sync. - const PALETTES = { - default: [ - 0xe61f26, 0xecd234, 0x1096e6, 0xf18313, - 0x3fc413, 0xb518d9, 0xff6bd5, 0x6bffe6, - ], - neon: [ - 0xff0030, 0xffe800, 0x0080ff, 0xff8030, - 0x40ff50, 0xb050ff, 0xff40d0, 0x40ffd0, - ], - pastel: [ - 0xe89aa0, 0xefdf90, 0x9adfee, 0xefb898, - 0xa6e0a8, 0xc4a6e0, 0xe0a6c8, 0xa6e0d8, - ], - colorblind_hc: [ - 0xa42424, 0xa3f300, 0x19abfc, 0xda7e41, - 0x30d0a0, 0x7648a7, 0xff6bd5, 0x6bffe6, - ], - }; - const PALETTE_IDS = Object.keys(PALETTES); - // User-defined per-string colors (core "Highway String Colors" theming). - // Persisted as a JSON hex array under the bg setting key 'customColors'; - // when the active palette id is 'custom' the renderer resolves this into - // numeric hex, falling back to the default palette per missing index. - // Mutated in place by _resolveCustomPalette so the reference stays stable. - let _customPalette = PALETTES.default.slice(); - // _h3dHexToInt, _clampByteI, _darkenInt, _lightenInt — moved to src/utils.js (h3d-carve-3). - // Default per-string gem gradient stops [topHighlight, bottomShade] — - // sampled from the original colour PNGs. Used verbatim for the built-in - // palettes (and for unchanged slots of a custom palette) so the stock look - // is byte-for-byte preserved; custom slots derive their stops from the - // chosen base color via _lightenInt/_darkenInt. Strings 6/7 have no entry - // and fall back to flat gNote. - const DEFAULT_GEM_GRADIENTS = [ - [0xec0816, 0xbd0400], // 0 red - [0xefd20b, 0xceaa00], // 1 yellow - [0x0b93e9, 0x0e69b2], // 2 blue - [0xf77b0b, 0xdb5808], // 3 orange - [0x37c40b, 0x139305], // 4 green - [0xaf10db, 0x8907af], // 5 violet - ]; - // Default palette at module scope so out-of-IIFE consumers (e.g. the - // out-of-range warning's reference to "palette size") still have a - // canonical length to compare against. - const S_COL = PALETTES.default; - - const SCALE = 2.25; - const K = SCALE / 300; - // Horizontal stretch factor for fret X positions. Increasing this widens - // the lane (frets, board plane, strings, notes, lane strip) without - // affecting K-based vertical dimensions (string gap, note height, camera). - const FRET_SCALE = SCALE * 1.1; - - const NFRETS = 24; - const NSTR = 6; - /** - * Pure 12-semitone spacing compresses toward the bridge; multiply each - * segment **above** this fret by the factor so high positions stay - * slightly more playable/readable in 3D. - */ - const FRET_SPACING_STRETCH_ABOVE12 = 1.1; - const FRET_SPACING_ANCHOR_F = 12; - // Per-string materials and projection meshes are built via S_COL.map(), - // so the renderer can only address strings 0..S_COL.length-1. Using a - // higher count would index undefined into mGlow/mStr/mSus/projMeshArr. - // Extend S_COL above to support more strings. - const MAX_RENDER_STRINGS = S_COL.length; - - // resolveStringCount — moved to src/utils.js (h3d-carve-3). - const resolveStringCount = bundle => _resolveStringCountBase(bundle, MAX_RENDER_STRINGS); // h3d-carve-3: delegator (1 beyond-subst; passes authoritative S_COL.length ceiling) - - // _NOTE_NAMES_SHARP, _BASE_OPEN_MIDI_BASS4/5, _BASE_OPEN_MIDI_GUITAR6/7/8 — moved to src/utils.js (h3d-carve-3). - - // _baseOpenStringMidis, _midiToPitchLabel, _openStringPitchLabelsForTuning — moved to src/utils.js (h3d-carve-3). - const _openStringPitchLabelsForTuning = (bundle, songInfo, n) => _openStringPitchLabelsForTuningBase(bundle, songInfo, n, MAX_RENDER_STRINGS); // h3d-carve-3: delegator (1 beyond-subst) - - const STR_THICK = 0.25 * K; - - // Fret wires — bowed metal tubes (backported from highway_babylon's - // "hit-zone fret bars"). All frets share one bowed TubeGeometry whose - // middle (the middle strings) pushes away from the camera so the row of - // frets reads as wrapping a cylindrical neck — chart-format depth cue. - // Negative Z = away from camera (into the highway). All tunable. - const FRET_BOW_DZ = -1.2 * K; // middle-of-span Z offset - const FRET_TUBE_RADIUS = STR_THICK * 0.75; // slightly thicker than a string - const FRET_TUBE_SEG = 12; // tubular segments along the curve - // Radial segments (cross-section). 8 rather than 6: at FRET_TUBE_RADIUS the - // hexagonal facets of a 6-segment tube are visible along the top highlight. - // One shared geometry for all frets, so the extra segments are ~free. - const FRET_TUBE_RADIAL = 8; - // metalness kept moderate, NOT ~1.0: MeshStandardMaterial is PBR and the - // scene has no envMap, so a full-metal fret would reflect black and render - // dark (the nut/headstock use metalness 0.02 for the same reason). At ~0.4 - // the lit albedo body survives while the directional light still throws a - // glossy specular streak across the rounded tube. The dim emissive floor - // keeps frets from going muddy far down the (fogged) neck. - const FRET_METALNESS = 0.4; // lit steel / brass when gold - const FRET_ROUGHNESS = 0.3; - const FRET_EMISSIVE = 0x12141a; // cool dim floor, never fully black - - // Fret-wire tiers. Wires inside the active anchor lane (the frets the player - // is actually reading) sit bright; everything outside recedes. Kept far - // apart on purpose — a narrow gap reads as noise rather than as a focus cue. - const FRET_WIRE_ACTIVE_HEX = 0xD8A636; // gold; numeric twin of FRET_LABEL_GOLD_HEX - const FRET_WIRE_ACTIVE_OP = 0.9; - const FRET_WIRE_IDLE_HEX = 0x4A4A60; - const FRET_WIRE_IDLE_OP = 0.28; - - // Hit flash: when a scorer (feedBack#254) confirms a note, the two wires - // bracketing its fret (f-1 and f) flash bright. Emissive is boosted as well - // as albedo — a MeshStandard fret with no envMap barely brightens from - // albedo alone, so without the emissive lift the "flash" reads as a shrug. - const FRET_WIRE_HIT_HEX = 0xFFFFFF; // blown out to white at full flash - const FRET_WIRE_HIT_EMISSIVE = 0xFFE9B0; // hot warm-white glow - const FRET_WIRE_HIT_OP = 1.0; - // Emissive multiplier at full flash (baseline is 1). Pushing emissive past - // 1.0 is what actually makes the wire read as a light source rather than a - // brightly-lit object — the color alone saturates and stops there. - const FRET_WIRE_HIT_INTENSITY = 4.2; - // Seconds for a flash to fall to ~1/e once the provider stops reporting. - // 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; - - const S_BASE = 3 * K; - const S_GAP = 4 * K; - - const AHEAD = 3.0; - const BEHIND = 0.5; - // How long a note/chord-frame stays renderable past the hit line while a - // note-state provider (feedBack#254) is attached. The provider's - // hit/miss verdict is asynchronous — the engine-side verifier reports it - // ~0.35-0.5 s after the line — so the default ~50 ms note linger / - // ~0.48 s chord linger lapses before the tint can apply. Drives both - // the outer-loop cull (ndVerdictT0) and the smart drawNote cull below. - const NOTEDETECT_GEM_VERDICT_WINDOW = 0.75; - // chDt threshold past the hit line at which the chord-frame scan - // gives up on an arpeggio-style frame whose constituents never come - // in. Must be < NOTEDETECT_GEM_VERDICT_WINDOW (the rim's draw life - // in detect mode); placing it at 0.55 s leaves ~0.2 s of the visible - // window for the latch to fire and skip subsequent scans. - const _ND_UNMATCHED_LATCH_AFTER = 0.55; - // Sample approach offsets dt in [0, AHEAD] into strips. Lane quads use - // z = dZ(dt) + TS*BEHIND = TS*(BEHIND - dt), while notes use z = dZ(n.t-now). - // So note hit line (z=0) aligns with dt=BEHIND, not dt=0. Chart time at - // lane parameter dt is now + dt - BEHIND (same z as a note at that time). - // Each strip’s uses that chart time so the blue lane doesn’t - // switch ~BEHIND seconds before the XML . - const HWY_LANE_TIME_SLICES = 96; - /** Odd columns (1st/3rd/…) darker teal; even columns brighter blue. */ - const HWY_LANE_STRIPE_ODD_HEX = 0x103B5C; - const HWY_LANE_STRIPE_EVEN_HEX = 0x08283C; - /** Lane quad alpha: base + highwayIntensity * scale (readable on dark floor). */ - const HWY_LANE_STRIPE_OP_BASE = 1.0; - const HWY_LANE_STRIPE_OP_INT = 0; - /** Venue mode: slight near-lane contrast boost (visual only). */ - const VENUE_LANE_OP_BOOST = 1.1; - /** Venue mode: gem emissive pop (~12%, visual only). */ - const VENUE_GEM_EMISSIVE_MUL = 1.12; - /** Venue steady-state haze coefficient — kept low for raster bg plate. */ - const VENUE_HAZE_STEADY = 0.008; - /** Venue backdrop pushed slightly farther for parallax depth. */ - const VENUE_BACKDROP_DISTANCE_MUL = 1.06; - /** Note travel speed. */ - const TS = 230 * K; - - // RENDER_ORDER_LAYER_STACK, RENDER_ORDER_LAYER_INDEX, RENDER_ORDER_AT_Z_ZERO, - // RENDER_ORDER_FAR_CLAMP, renderOrderForLayerAtZ — moved to src/geometry.js (h3d-carve-1b). - - /** Match `nextNoteByString` onset to this note (float + chart rounding; avoids ghost / glow flicker). */ - const NEXT_ON_STRING_T_EPS = 0.06; - /** Fixed pre-impact ramp window for lead-note board ghosts (Primary + Upcoming slots). */ - const GHOST_UPCOMING_WIN = 0.6; - /** Ghost starts at this fraction of full size/brightness and grows to 1.0 as it approaches. */ - const PROJ_GROW_MIN = 0.45; - /** - * 3D highway post-strum tail — chord frame + ghost fret digit share the same - * hold and fade so timing stays consistent. - */ - const CHORD_HWY_LINGER_S = 0.75; - /** Linear fade at end of `CHORD_HWY_LINGER_S` (applies to chord UI and board ghost numbers). */ - const CHORD_HWY_FADE_S = 0.32; - const GHOST_HOLD_AFTER_ONSET = CHORD_HWY_LINGER_S; - const GHOST_FRET_LBL_FADE_S = CHORD_HWY_FADE_S; - /** Purple lane rails: extend past last matched chord/note so Z reaches frame end. */ - const ARP_HWY_RAIL_END_TAIL_S = 0.38; - /** Keep 0 — chord/note-based ``shapeLo`` already aligns to the visible frame. */ - const ARP_HWY_RAIL_START_LEAD_S = 0; - /** Drives emissive (`mGlow` / accent fill) for notes with `.ac`; matches drawNote `linger` cutoff (0.05). */ - const ACCENT_NOTE_STR_GLOW = 3.55; - const ACCENT_NOTE_LINGER_EPS = 0.05; - /** Extra emissive layered on accent-only body material (`mAccentCore`), after `strGlow * glowMul`. */ - const ACCENT_NOTE_FILL_BOOST = 2.55; - /** Accent rim draws brighter than normal string-coloured outlines (`mStrHitOutline`). */ - const ACCENT_RIM_BASE_EMISSIVE = 3.45; - /** Outline / core scale bump vs normal gems (accent reads slightly larger). */ - const ACCENT_RIM_XY_SCALE_MUL = 1.09; - const ACCENT_RIM_Z_SCALE_MUL = 1.06; - // Soft neon-style outer bloom (AdditiveBlending) — layered shells behind outline/core. - const ACCENT_HALO_OP_NEAR = 0.68; - const ACCENT_HALO_OP_MID = 0.42; - const ACCENT_HALO_OP_FAR = 0.24; - const ACCENT_HALO_XY_INNER = 1.36; - const ACCENT_HALO_XY_MID = 1.82; - const ACCENT_HALO_XY_OUTER = 2.32; - const ACCENT_HALO_Z_INNER = 1.05; - const ACCENT_HALO_Z_MID = 1.12; - const ACCENT_HALO_Z_OUTER = 1.22; - - /** - * Post-hit tail fade shared by ghost fret digits and 3D chord UI: full - * opacity until (holdS − fadeS) after onset, then linear fade over fadeS; - * canceled when `nextSoon` — for ghosts: next note within `fadeS` of `now`; - * for chord frame: next chord onset lies in chart time [hold − fade, hold] - * after the current chord (so fade does not run into a same-window handoff). - * @param {number} dt chart time minus now (negative once struck) - * @param {number} fadeS linear fade duration (default: GHOST_FRET_LBL_FADE_S) - */ - function hwyPostHitTailFadeMul(dt, holdS, nextSoon, fadeS = GHOST_FRET_LBL_FADE_S) { - if (nextSoon || dt >= 0) return 1; - const gone = -dt; - if (gone >= holdS) return 0; - const fS = Math.min(Math.max(fadeS, 1e-6), holdS); - const fadeStartT = Math.max(0, holdS - fS); - if (gone < fadeStartT) return 1; - return Math.max(0, 1 - (gone - fadeStartT) / fS); - } - - // Shorter, flatter notes (joel style) - const NW = 5 * K, NH = 3 * K, ND = 0.25 * K; - // Sustain-trail X offset for fretted notes. Module-scoped + frozen - // so the hot path's `offsets.length` loop sees a stable singleton - // reference. The standalone-open-string path builds a fresh pair - // each call because its offset magnitude depends on the per-note - // `openWScale` (set in drawNote at line 7367 from the open-string - // body's lane width), so a module-scoped constant can't capture - // it; the allocation is the same one the prior code did via - // `const baseOff = NW * 3 * openWScale` plus the inline `[-, +]` - // literal in the chord-member branch — just consolidated. - const SINGLE_SUS_OFFSETS = Object.freeze([0]); - const BEND_HALFSTEP_WORLD_Y = S_GAP * 0.8; - const VIBRATO_HALF_WAVE_S = 0.08; - // Bend ribbon envelope: fraction of the sustain spent ramping up to - // the bent pitch, and releasing back down (rest is the held plateau). - const BEND_ENV_RISE_FRAC = 0.35; - const BEND_ENV_RELEASE_FRAC = 0.30; - const TREMOLO_BUMP_S = 0.06; - - /** Longitudinal samples for sustain-technique prism (indexed BufferGeometry). */ - const SLIDE_RIBBON_SAMPLES = 96; - /** Pre-built index buffer: `SLIDE_RIBBON_SAMPLES` × 8 tris × 3 verts. */ - const SLIDE_RIBBON_INDICES = (() => { - const S = SLIDE_RIBBON_SAMPLES; - const idx = new Uint16Array(S * 24); - let o = 0; - for (let k = 0; k < S; k++) { - const b = k * 4; - const nx = (k + 1) * 4; - // Bottom (-Y outward) - idx[o++] = b; idx[o++] = b + 1; idx[o++] = nx + 1; - idx[o++] = b; idx[o++] = nx + 1; idx[o++] = nx; - // Top (+Y outward) - idx[o++] = b + 3; idx[o++] = nx + 3; idx[o++] = nx + 2; - idx[o++] = b + 3; idx[o++] = nx + 2; idx[o++] = b + 2; - // Left (-X outward) - idx[o++] = b; idx[o++] = nx; idx[o++] = nx + 3; - idx[o++] = b; idx[o++] = nx + 3; idx[o++] = b + 3; - // Right (+X outward) - idx[o++] = b + 1; idx[o++] = b + 2; idx[o++] = nx + 2; - idx[o++] = b + 1; idx[o++] = nx + 2; idx[o++] = nx + 1; - } - return idx; - })(); - // Three r170's setIndex() only wraps plain Arrays into Uint16BufferAttribute; - // typed-array input gets assigned raw onto .index, which trips WebGL's - // byteLength check. Convert once at module init so each pooled geometry - // reuses the same Array reference instead of allocating per mesh. - const SLIDE_RIBBON_INDICES_ARR = Array.from(SLIDE_RIBBON_INDICES); - const N_RAD = 1.5 * K; - const SW = 2 * K, SH = 1.5 * K; - - const CAM_H_BASE = 190 * K; - const CAM_DIST_BASE = 240 * K; - const REF_ASPECT = 16 / 9; - const FOCUS_D = 600 * K; - const CAM_LERP_BASE = 0.02; - - // Base vertical field of view (deg). THREE's PerspectiveCamera fov is the - // VERTICAL angle; horizontal follows from the aspect ratio. At a normal - // ~16:9 pane this gives a ~102° horizontal cone. On an ultra-wide pane - // (top/bottom 2-player split → full-width/half-height → ~32:9) that - // horizontal cone balloons past 130° and squeezes the fixed-width neck into - // a central sliver. The optional horizontal-FOV-hold path below counters - // that by lowering the effective vertical fov as the pane widens. - const BASE_VFOV = 70; - // Horizontal-FOV-hold ("Hor+") defaults. At/under HORPLUS_START_ASPECT the - // effective vertical fov equals BASE_VFOV (exact no-op); past it the - // vertical fov drops to keep the horizontal cone ~constant so the neck - // fills a wide pane. HORPLUS_MIN_VFOV floors the result on pathological - // aspects. Engaged only via the window.__h3dAspectTune bridge (default off). - const HORPLUS_START_ASPECT = 16 / 9; - const HORPLUS_MIN_VFOV = 28; - - // Zoom-dependent framing — height (h*) and depth (dist*) multipliers - // applied to cam.position. Interpolated by `dist`: - // NEAR = tight view (nut position, span<=4 -> dist~=93*K): lower/closer. - // FAR = wide view (midpoint fret 1<->20 -> dist~=141*K): higher/pulled back - // to fit the whole neck. - // Outside this range the values clamp at the endpoints. - const CAM_FRAME_DIST_NEAR = 93 * K; - const CAM_FRAME_DIST_FAR = 141 * K; - const CAM_FRAME_H_NEAR = 0.75; - const CAM_FRAME_H_FAR = 1.00; - const CAM_FRAME_D_NEAR = 0.575; - const CAM_FRAME_D_FAR = 0.60; - // Fret-row fit guard. The heat-coloured fret-number row is a band drawn - // BELOW the board (at sY(lowest) - S_GAP*1.4). The lower-third framing - // anchors the board CENTRE, not that row, so a tight zoom on a centred span - // (worst mid-neck — fine pushed to either end of the neck) drops the row off - // the bottom edge. Tilt can't add vertical room there (it would only trade a - // bottom clip for a top clip), so camUpdate dollies the camera back just - // enough to bring the row back into frame — auto-sized, capped, hysteretic. - const FRET_ROW_FIT_NDC_MIN = -0.86; // keep the row anchor at/above this NDC y (>-1 = on screen) - const FRET_ROW_FIT_DEADBAND = 0.06; // headroom past the min before the dolly relaxes (anti-hunt) - const FRET_ROW_FIT_BOOST_MAX = 1.6; // cap the pull-back so the zoom can't pop (never dolly back > +60%) - - // Camera-X targeting (issue #34). The visible AHEAD = 4.0 s window is - // far too coarse for picking where the camera should sit — a single - // 17th-fret bend 2.5 s away yanks tgtX several frets even though the - // immediate playing area hasn't moved. These constants are bounds for - // a smoothing dial (0 = twitchy, 1 = calm); the runtime lerps between - // the pair using the user's `cameraSmoothing` setting. - const CAM_TGT_BEHIND = 0.2; // s behind hit line for X targeting - const CAM_TGT_AHEAD_T = 2.0; // s — twitchy: longer lookahead (more reactive) - const CAM_TGT_AHEAD_C = 0.7; // s — calm: shorter lookahead (ignore distant outliers) - const CAM_TGT_TAU_T = 0.35; // s — twitchy: short recency time-constant - const CAM_TGT_TAU_C = 0.9; // s — calm: longer time-constant (averages more) - const CAM_TGT_HYST_T = 0.25; // frets — twitchy: tiny dead zone - const CAM_TGT_HYST_C = 5.0; // frets — calm: ~5-fret dead zone, wide - // enough to swallow chord-to-chord - // alternations across a 6-fret span - // (e.g. Am ↔ D in first position). - - // Zoom (tgtDist) damping. Controlled by its own `zoomSmoothing` setting - // so X-pan and zoom-pull-back can be tuned independently. New users - // (and existing users who never wrote zoomSmoothing) inherit - // cameraSmoothing's value on first read, so default behaviour is - // unchanged from when zoom + X shared a single slider. - const CAM_DIST_HYST_T = 0.5; // fret-span — twitchy: minimal dead zone - const CAM_DIST_HYST_C = 5.0; // fret-span — calm: 5-fret span change required - - // Vertical-tilt damping. Drives the tgtLookY self-correction loop in - // camUpdate(): how far the fretboard's NDC Y can drift from - // DESIRED_NDC_Y before we nudge the camera, and how strongly each - // nudge corrects. Twitchy = narrow band + strong correction (re-frame - // aggressively); calm = wide band + weak correction (let small drift - // ride). Driven by `tiltSmoothing`, mirrors cameraSmoothing on first - // read like zoomSmoothing does. - // Bounds chosen so the midpoint (tiltSmoothing=0.5) reproduces the - // pre-PR hardcoded behaviour (band=0.15, str=0.5). Without that, a - // fresh install would silently change the vertical-tilt feel even - // though the PR description promises "default behaviour unchanged." - const CAM_TILT_BAND_T = 0.05; // NDC — twitchy: narrow tolerance - const CAM_TILT_BAND_C = 0.25; // NDC — calm: wide tolerance, fewer corrections - const CAM_TILT_STR_T = 0.8; // multiplier — twitchy: strong nudge per correction - const CAM_TILT_STR_C = 0.2; // multiplier — calm: weak nudge per correction - - // Lock-low zoom range. The cameraLockZoom slider (0..1) blends between - // these two multipliers and scales the locked tgtDist. Defaults pick - // 1.0× at slider=0.5 so the previous locked view is the midpoint. - const CAM_LOCK_ZOOM_MIN = 0.55; // slider=0 — closest, biggest fretboard - const CAM_LOCK_ZOOM_MAX = 1.45; // slider=1 — furthest - const CAM_LOCK_CENTER_FRET = 6; // default camera X center (first-position midpoint) - - // ── 3D preview: lookahead fret bounds + smoothed focal X / span ───────── - /** User-selectable via `cameraMode`. Legacy `classic` in storage maps to `steady`. */ - const CAMERA_MODE_IDS = ['steady', 'lookahead']; - const CAM_LOOKAHEAD_SEC = 3.0; // fallback when no beats/measures are available - const CAM_LOOKAHEAD_MEASURES = 9; // lookahead window = N measures ahead - const CAM_FOCUS_BLEND_RATE = 0.7; - const CAM_FRET_EDGE_BLEND = 0.1; - const DEFAULT_LOOKAHEAD_FRET_SPAN = 4; - /** Schmitt: avoid lock↔dynamic flicker when lookahead maxF jitters at the 12th fret. */ - const LOOKAHEAD_LOCK_RELEASE_MAXF = 13; - const LOOKAHEAD_LOCK_ENGAGE_MAXF = 10; - // Note: we deliberately do NOT scale the camUpdate lerp speed with - // cameraSmoothing. Smoothing widens the hysteresis dead zones so the - // camera stays put through small/repetitive shifts; but when a shift - // *does* clear the gate (a real jump to a far fret), we want the slide - // to be snappy, not lethargic. The dead zone gates "should we move?", - // the BPM-scaled lerp answers "how fast" — keeping those orthogonal - // gives the right feel. - - const FOG_START = 200 * K; - const FOG_END = 670 * K; - - const DOTS = [3, 5, 7, 9, 12, 15, 17, 19, 21, 24]; - const DDOTS = new Set([12, 24]); - const INLAY_LABEL_FRETS = [3, 5, 7, 9, 12, 15, 17, 19, 22, 24]; // 22 not 21: intentional display choice - - // Fret-column reference markers: floor-aligned fret-number sprites - // that scroll toward the hit line every Nth measure. When the chart - // has , the row uses the inlay cadence (DOTS) around the - // anchor fret: two marker positions before and three after the - // snapped cadence cell (e.g. anchor fret 7 → 3,5,7,9,12,15). - const FRET_COL_MARKER_ANCHOR_BACK = 2; - const FRET_COL_MARKER_ANCHOR_FWD = 3; - - /** - * @param {number} anchorFret Chart anchor `.fret` (world start fret). - * @param {number[]} [cadence] Ascending frets (e.g. DOTS). - * @returns {number[]} - */ - function fretColumnMarkersForAnchor(anchorFret, cadence = DOTS) { - const f0 = Math.round(Number(anchorFret)); - if (!Number.isFinite(f0) || cadence.length === 0) return cadence.slice(); - let iBest = 0; - let dBest = Infinity; - for (let i = 0; i < cadence.length; i++) { - const d = Math.abs(cadence[i] - f0); - if (d < dBest || (d === dBest && cadence[i] < cadence[iBest])) { - dBest = d; - iBest = i; - } - } - const i0 = Math.max(0, iBest - FRET_COL_MARKER_ANCHOR_BACK); - const i1 = Math.min(cadence.length, iBest + FRET_COL_MARKER_ANCHOR_FWD + 1); - return cadence.slice(i0, i1); - } - - // _noteKey, lowerBoundT, hwyFirstRelevantFrettedTime — moved to src/geometry.js (h3d-carve-1b). - - // Last arrangement at or before chart time `t` (sorted by .time). - // Mirrors static/highway.js getAnchorAt — until t reaches the first anchor’s - // time, the first anchor still defines fret/width. - // Binary search: this is called inside per-frame loops (lane slicing, - // lookahead sampling, marker spawning), so the linear scan was O(samples * - // numAnchors) on dense charts. - function getChartAnchorAt(anchorArr, t) { - if (!anchorArr || !anchorArr.length) return null; - let lo = 0, hi = anchorArr.length; - while (lo < hi) { - const mid = (lo + hi) >>> 1; - if (anchorArr[mid].time <= t) lo = mid + 1; - else hi = mid; - } - return lo === 0 ? anchorArr[0] : anchorArr[lo - 1]; - } - - /** @returns {{ dMin: number, dMax: number } | null} */ - function laneBoundsFromAnchor(anc) { - if (!anc) return null; - let fStart = Math.round(Number(anc.fret)); - // Match anchorPlayedFretInclusiveSpan(): fret 0 (and below) clamps - // to 1, otherwise the lane span ends up one fret narrower than the - // played-fret span / label highlighting on charts that emit - // . - if (!Number.isFinite(fStart) || fStart < 1) fStart = 1; - let w = Number(anc.width); - if (!Number.isFinite(w)) w = 4; - w = Math.max(1, Math.round(w)); - const fLast = Math.min(NFRETS, fStart + w - 1); - const dMin = Math.max(0, fStart - 1); - const dMax = Math.min(NFRETS, fLast); - return { dMin, dMax }; - } - - /** Same horizontal span as the dynamic highway lane: anchor at chart time `t`. */ - function anchorLaneBoundsAt(anchorArr, t) { - if (!anchorArr || !anchorArr.length) return null; - return laneBoundsFromAnchor(getChartAnchorAt(anchorArr, t)); - } - - /** - * Inclusive chart-fret indices for the playing window (anchor `fret` + `width`), - * e.g. fret=5 width=4 → 5..8. Unlike {@link laneBoundsFromAnchor}'s `dMin`/`dMax` - * (diagram wire span), these are the labels shown on gems / row numbers. - * @returns {{ f0: number, f1: number } | null} - */ - function anchorPlayedFretInclusiveSpan(anc) { - if (!anc) return null; - let f0 = Math.round(Number(anc.fret)); - if (!Number.isFinite(f0) || f0 < 1) f0 = 1; - let w = Number(anc.width); - if (!Number.isFinite(w)) w = 4; - w = Math.max(1, Math.round(w)); - const f1 = Math.min(NFRETS, f0 + w - 1); - return { f0, f1 }; - } - - function anchorPlayedFretSpanAt(anchorArr, t) { - if (!anchorArr || !anchorArr.length) return null; - return anchorPlayedFretInclusiveSpan(getChartAnchorAt(anchorArr, t)); - } - - const FRET_COOLDOWN = 0.5; // seconds a lane fret stays active after last note - - const DIAG_LINGER_S = 0.55; - const DIAG_ENTRANCE_S = 0.20; - const DIAG_CROSSFADE_S = 0.15; - const DIAG_SIZE_MIN = 0.08; - const DIAG_SIZE_MAX = 0.16; - const DIAG_CELL_MAX = 34; - // 'bl' and 'br' removed — diagram is top-only. Legacy localStorage values - // that contain 'bl'/'br' will fall back to BG_DEFAULTS.chordDiagramPosition - // via _bgCoerce (which rejects values not in this list). - const CHORD_DIAG_POSITION_IDS = ['tl', 'tr']; - - /** Default chord-box rim / fill gradient (teal family). */ - const CHORD_BOX_TEAL_HEX = 0x00d2d5; - const CHORD_BOX_TEAL_DARK_HEX = 0x003c3d; - /** Frame edge quads: premultiplied-ish alpha match (~128/255). */ - const CHORD_BOX_EDGE_ALPHA = 128 / 255; - /** Interior gradient strip alpha on both stops (~32/255). */ - const CHORD_BOX_FILL_GRAD_ALPHA = 32 / 255; - /** Arpeggio interior wash; dedicated gradient tex so teal map doesn’t dominate. */ - const ARPEGGIO_BOX_BLUE_HEX = 0x454BB6; - const ARPEGGIO_BOX_BLUE_DARK_HEX = 0x2D3190; - /** Arpeggio rim accent and lane tint. */ - const ARPEGGIO_RIM_BLUE_HEX = 0x454BB6; - /** Post-hit chord-frame rim tints driven by the note-state provider - * (feedBack#254). Applied only to the teal frame during the linger - * fade (chDt <= 0) when a scorer is attached. - * Matches the gem hit/miss colours so chord frame and note body - * give a consistent signal: - * hit → neon spring-green 0x22ff88 (same as mHitBright). - * miss → hot magenta-red 0xff0066 (same as mMissOutline). */ - const CHORD_BOX_HIT_BRIGHT_HEX = 0x22ff88; - const CHORD_BOX_MISS_DARK_HEX = 0xff0066; - - /** Fret-number label tints — gold on approaching/active notes, muted blue when idle. */ - const FRET_LABEL_GOLD_HEX = '#D8A636'; - const FRET_LABEL_IDLE_HEX = '#9ab8cc'; - - /** 3D chord-box rim bars (thin on all chords, including repeats in a sequence). */ - const CHORD_FRAME_RIM_MIN = 0.055; // × K — floor thickness - const CHORD_FRAME_RIM_FRAC_H = 0.028; // × fullChordBoxH - const CHORD_FRAME_RIM_Z_MIN = 0.048; // × K — depth squash - const CHORD_FRAME_RIM_Z_SCAL = 0.68; // thickZ scales with ft - /** - * Highway arpeggio frame uses ``inferArpeggioFromNotePattern`` only inside this - * window around ``ch.t``. Hand-shape spans can cover many seconds and several - * separate strums of the same voicing; a full-span scan mis-detects arpeggio - * from beats that belong to different chord rows. - */ - const ARP_FRAME_ONSET_PAD_S = 0.06; - const ARP_FRAME_ONSET_CLUSTER_S = 0.26; - /** - * The chart format encodes fast alternating power chords (e.g. D5/D#5 gallops) as - * very short ```` rows (~0.05–0.2 s). Note-stream arpeggio - * inference must not treat strum spread across strings as arpeggio there — - * it false-triggers lavender highway rails / frames (see Frantic ~2:36). - */ - const ARP_INFER_MIN_HAND_SHAPE_SPAN_S = 0.21; - /** - * In a **short** chart window, chord strums (same voicing, strings picked - * within ~30–45 ms) barely exceed this total spread; real arpeggios in that - * window are usually slower across strings OR have 4+ plucks. - */ - const ARP_INFER_STRUM_VS_ARP_SPREAD_MIN_S = 0.047; - /** - * If more than ``shape.size + ARP_INFER_MULTI_STRUM_HIT_SLACKS`` matching picks - * sit inside a non-trivial hand-shape window, the chart is almost certainly - * **repeated strums** of the same chord (or gallops), not one arpeggio sweep. - */ - const ARP_INFER_MULTI_STRUM_HIT_SLACK = 2; - /** ``timeWin`` span above which we apply the multi-strum hit-count cap. */ - const ARP_INFER_MULTI_STRUM_WIN_MIN_S = 0.26; - /** - * Minimum staggered hits inside a hand-shape window for note-stream arpeggio - * inference. A genuine arpeggio sweeps several strings of the held shape; - * a 2-note melodic motif inside a multi-string ```` (e.g. Jackson 5 - * "I Want You Back" ~0:27 — Fm7 transition fingering with two plucks on - * strings 4–5) earlier registered as arpeggio and produced a stray lavender - * chord frame + purple lane outer dividers. Cap at ``min(shape.size, 3)`` - * so 2-string voicings still infer normally and 3+ string templates need - * a real sweep. - */ - const ARP_INFER_MIN_HITS_VS_SHAPE_CAP = 3; - - /* ====================================================================== - * Pure helpers - * ====================================================================== */ - - // _fretXLog, _fretXUniStep, _fretXUni — moved to src/geometry.js (h3d-carve-1). - - let _h3dFretUniform = true; - try { _h3dFretUniform = localStorage.getItem('highway_3d.fretSpacing') !== 'logarithmic'; } catch (_) {} - const fretX = f => geoFretX(f, _h3dFretUniform); // h3d-carve-1: delegator (1 beyond-subst) - - window.h3dSetFretSpacing = mode => { - // Validate against the two supported modes before persisting so an - // unexpected input can't leave an invalid value in localStorage - // (mirrors h3dBgSetFretNumberGhostScope's allowlist guard). No-op - // when the stored mode is already what was requested. - const m = mode === 'logarithmic' ? 'logarithmic' : 'uniform'; - try { - if (localStorage.getItem('highway_3d.fretSpacing') === m) return; - localStorage.setItem('highway_3d.fretSpacing', m); - } catch (_) {} - // Apply live rather than reloading the page — a full page reload - // reboots the SPA to the home screen (index.html's `.screen.active`), - // ejecting the user from Settings. Rebind the module-scope flag so - // panels mounted later this session pick up the new mode, recompute - // the fretX-derived scalars, then broadcast a change so every mounted - // panel rebuilds its board. Same live-update path as every other - // 3D-highway setting. - _h3dFretUniform = (m !== 'logarithmic'); - _recomputeFretSpacingDerived(); - _bgEmitChange('fretSpacing'); - }; - - const fretMid = f => geoFretMid(f, _h3dFretUniform); // h3d-carve-1b: delegator (1 beyond-subst) - /** World-space width of fret column (wires f−1 .. f); used to scale row markers past ~12. */ - function fretColumnWorldW(f) { - const fi = Math.round(Number(f)); - if (!Number.isFinite(fi) || fi <= 0) return Math.abs(fretX(1) - fretX(0)); - const lo = Math.min(NFRETS, Math.max(1, fi)); - return Math.abs(fretX(lo) - fretX(lo - 1)); - } - /** Reference column (~mid board): prior fixed K-based sprites matched this neighborhood. */ - const FRET_LABEL_SCALE_REF_FRET = 5; - // `let` (not `const`): recomputed by _recomputeFretSpacingDerived when the - // user flips Uniform/Logarithmic at runtime so label scaling tracks the - // new geometry without a page reload. - let _fretLabelScaleRefW = Math.max(1e-8, fretColumnWorldW(FRET_LABEL_SCALE_REF_FRET)); - function fretLabelScaleForFret(f) { - const w = fretColumnWorldW(f); - const m = w / _fretLabelScaleRefW; - return Math.max(0.32, Math.min(1.45, m)); - } - // dZ — moved to src/geometry.js (h3d-carve-1). - - // slideTrailEnd — moved to src/geometry.js (h3d-carve-1). - - /** - * Lateral slide offset along the fretboard during sustain — easing - * mirrors the pitched/unpitched slide offset convention above. - * @param {{ endFret: number, unpitched: boolean } | null} [st_] from slideTrailEnd - */ - function slideOffsetWorldX(n, chartTime, st_) { - const st = st_ || slideTrailEnd(n); - if (!st || n.f <= 0 || !(n.sus > 0)) return 0; - const denom = Math.max(n.sus, 1e-6); - const p = Math.max(0, Math.min(1, (chartTime - n.t) / denom)); - const startX = fretMid(n.f); - const endX = fretMid(st.endFret); - const w = st.unpitched - ? 1 - Math.sin((1 - p) * Math.PI / 2) - : Math.pow(Math.sin(p * Math.PI / 2), 3); - return (endX - startX) * w; - } - - // camBaseDistU, camLowFretPullbackU — moved to src/geometry.js (h3d-carve-1). - - // World-units-per-fret near mid-neck. Used by the camera-X hysteresis - // gate (issue #34) to convert a fret-equivalent dead zone into world - // units. Pure function of SCALE — hoist out of update()'s hot path. - // `let` (not `const`): recomputed alongside _fretLabelScaleRefW when the - // fret-spacing mode flips at runtime — see _recomputeFretSpacingDerived. - let FRET_WIDTH_MID = fretX(7) - fretX(6); - - // Recompute the fretX-derived scalars baked at module init. Called from - // h3dSetFretSpacing after _h3dFretUniform flips so label scaling and the - // camera hysteresis threshold track the newly chosen spacing — the live - // alternative to the old location.reload(), which ejected the user from - // Settings back to the home screen. - function _recomputeFretSpacingDerived() { - _fretLabelScaleRefW = Math.max(1e-8, fretColumnWorldW(FRET_LABEL_SCALE_REF_FRET)); - FRET_WIDTH_MID = fretX(7) - fretX(6); - } - - // computeBPM, _makeGaussTex — moved to src/geometry.js (h3d-carve-1). - - // T, loadThree — moved to src/three-loader.js (h3d-carve-2). - // T is a live-binding export; the IIFE reads the updated value after loadThree() resolves. - - // _ssActive, _ssIsCanvasFocused — moved to src/utils.js (h3d-carve-3). - - // Shortcut for the wide-pane framing tuner. Opens/closes the floating panel - // (the A/B on/off and the per-pane target live inside it now). Registered - // once per session via a module-level guard (it drives shared module state, - // so per-instance registration would stack duplicate handlers and cancel - // itself out); it's a harmless debug control, so it is never unregistered. - // No-ops where the core shortcut API isn't present (older core / borrowed - // contexts). - let _tunerShortcutRegistered = false; - function _registerTunerShortcut() { - if (_tunerShortcutRegistered) return; - if (typeof window.registerShortcut !== 'function') return; - _tunerShortcutRegistered = true; - try { - window.registerShortcut({ - key: 'A', // uppercase e.key → produced with Shift held (Shift+A) - description: '3D Highway: open/close wide-pane framing tuner (Shift+A)', - scope: 'player', - handler: () => { - // Open/close the live tuner panel. The A/B on/off and the - // per-pane target now live in the panel itself, so the - // shortcut is just a dismiss/reveal. - _toggleAspectPanel(); - }, - }); - } catch (e) { - _tunerShortcutRegistered = false; // allow a later retry if it threw - } - } - - // ── Wide-pane framing: live tuner bridge + panel ────────────────────────── - // window.__h3dAspectTune is the single source of truth the renderer reads - // each frame (see effectiveVfov + camUpdate). The defaults reproduce the - // current framing exactly (enabled:false). Values persist to localStorage so - // a tuning session survives reloads; the floating panel (Shift+A) writes the - // same object live. All of this is a debug aid — none of it runs unless the - // user opts in. - // Versioned key: the first iteration shipped a broken default (enabled:true, - // baseVfov:30) and may have persisted it. Bumping the key ignores that stale - // state so the corrected default-off config actually takes effect. - const _ASPECT_LS = 'h3d_aspect_tune2'; - // Working defaults. Default OFF, so out of the box this is an exact no-op — - // every pane renders byte-for-byte as before (effectiveVfov returns - // BASE_VFOV and the pose nudges gate off). The config is also coherent when - // a tester turns it ON via Shift+A: baseVfov == BASE_VFOV so normal ~16:9 - // panes (single-player, most 2x2) stay at 70° even enabled, and only panes - // wider than startAspect (2.25) engage the Hor+ hold; blend:1 makes that - // hold actually take effect; minVfovDeg (28) sits below baseVfov so the floor - // is a real floor. The pose nudges are the in-progress wide-pane look a - // tester sees once enabled. localStorage overrides all of this per machine. - const _ASPECT_DEFAULTS = { - enabled: false, baseVfov: BASE_VFOV, startAspect: 2.25, hfovDeg: null, - blend: 1, minVfovDeg: HORPLUS_MIN_VFOV, splitOnly: false, - heightMul: 0.30, distMul: 0.95, pitchAdd: -1.5, lookDepthMul: 1, - }; - // Slider specs (numeric fields). Checkboxes (enabled/splitOnly) + the hfov - // override are handled separately in the panel builder. Ranges are wide on - // purpose — this is a tuning aid, the no-op default sits mid-range. - const _ASPECT_FIELDS = [ - { k: 'baseVfov', label: 'Base vFOV°', min: 18, max: 90, step: 1 }, - { k: 'startAspect', label: 'Start aspect', min: 1.0, max: 4.0, step: 0.05 }, - { k: 'blend', label: 'Blend', min: 0, max: 1, step: 0.05 }, - { k: 'minVfovDeg', label: 'Min vFOV°', min: 10, max: 60, step: 1 }, - { k: 'heightMul', label: 'Height ×', min: 0.1, max: 2.5, step: 0.05 }, - { k: 'distMul', label: 'Dolly ×', min: 0.2, max: 3.0, step: 0.05 }, - { k: 'pitchAdd', label: 'Pitch +', min: -40, max: 40, step: 0.5 }, - // Aims the camera further down the neck (>1) or pulls the aim back (<1). - // This is the lever that flattens the mid-distance "hump" toward a - // straight gradual recede. - { k: 'lookDepthMul', label: 'Look depth', min: 0.2, max: 3.0, step: 0.05 }, - ]; - let _aspectPanelEl = null; // the floating panel root (built once) - let _aspectPanelRO = null; // readout
- let _aspectPanelRAF = 0; // readout poll handle - let _aspectTargetSel = null; // the "Target" . - let _aspectPanesDirty = true; - // Monotonic counter for the per-instance fallback key (when a pane has no - // arrangement name to key by). - let _aspectPaneCounter = 0; - function _aspectNowMs() { - try { if (performance && performance.now) return performance.now(); } catch (e) {} - try { return Date.now(); } catch (e) { return 0; } // keep pruning functional - } - // Pane key: prefer the arrangement name ('arr:Bass') so a pane's framing is - // stable across songs AND distinct between split panes, with no dependency on - // the external splitscreen panel index (which isn't always available). Fall - // back to a per-instance id ('pane:3') when there's no arrangement. - function _aspectPaneKey(arrangement, uid) { - const a = (typeof arrangement === 'string') ? arrangement.trim() : ''; - return a ? ('arr:' + a) : ('pane:' + uid); - } - // Human label derived from the key. - function _aspectPaneLabel(paneKey) { - if (paneKey.slice(0, 4) === 'arr:') return paneKey.slice(4); - if (paneKey.slice(0, 5) === 'pane:') return 'Pane ' + paneKey.slice(5); - return paneKey; - } - - // Get-or-create the shared bridge object, seeded from defaults + localStorage. - // May carry a sparse `__panels` map of per-pane overrides. - function _aspectTune() { - let t = window.__h3dAspectTune; - if (!t || typeof t !== 'object') { - t = Object.assign({}, _ASPECT_DEFAULTS); - try { - const raw = localStorage.getItem(_ASPECT_LS); - if (raw) Object.assign(t, JSON.parse(raw)); - } catch (e) {} - window.__h3dAspectTune = t; - } - return t; - } - // Bumped on every tune mutation (all writes funnel through _aspectPersist) so - // the per-pane resolve cache below can invalidate cheaply. - let _aspectRev = 0; - function _aspectPersist() { - _aspectRev++; - try { - const t = _aspectTune(), out = {}; - Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t[k]; }); - // Persist per-pane overrides keyed by arrangement ('arr:*') only, so a - // pane's framing carries across songs. Instance-id fallback keys - // ('pane:*') are session-only — persisting them would leak a new key - // every reload. - if (t.__panels) { - const p = {}; let any = false; - Object.keys(t.__panels).forEach((k) => { - if (k.slice(0, 4) === 'arr:') { p[k] = t.__panels[k]; any = true; } - }); - if (any) out.__panels = p; - } - localStorage.setItem(_ASPECT_LS, JSON.stringify(out)); - } catch (e) {} - } - - // Resolve the effective tune for a pane: the shared base, with that pane's - // override keys (if any) laid on top. Called every frame per renderer, so the - // merged object is memoized per pane and only rebuilt when the tune mutates - // (_aspectRev changes). Panes with no override return the base directly (no - // allocation). - const _aspectResolveCache = new Map(); // paneKey -> { rev, obj } - function _resolveTuneFor(paneKey) { - const base = _aspectTune(); - const ov = base.__panels && base.__panels[paneKey]; - if (!ov) return base; - const c = _aspectResolveCache.get(paneKey); - if (c && c.rev === _aspectRev) return c.obj; - const out = {}; - Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = (k in ov) ? ov[k] : base[k]; }); - _aspectResolveCache.set(paneKey, { rev: _aspectRev, obj: out }); - return out; - } - // Record a live pane so the Target dropdown can list it. Called every frame - // by each renderer with its pane key. `seen` is refreshed each call for - // pruning; the dropdown is only marked dirty when a pane is newly added — not - // on every re-report, which would flicker the + let _aspectTgtRow = null; // the Target row (hidden when only one pane) + let _aspectHfovCb = null; // hfov-override checkbox (synced explicitly) + let _aspectHfovSl = null; // hfov-override slider + // Which pane the panel edits. '' = all panes (writes the shared base object); + // a pane key ('arr:' or the fallback 'pane:') writes that pane's + // sparse override, so one split pane can be framed independently. + let _aspectEditTarget = ''; + // Bumped when the SET of live panes changes (add/prune) so the panel rebuilds + // the Target dropdown — never on a per-frame label re-report, which would + // flicker the . + function _aspectRegisterPane(paneKey) { + const reg = window.__h3dAspectPanes || (window.__h3dAspectPanes = {}); + const label = _aspectPaneLabel(paneKey); + let e = reg[paneKey]; + if (!e) { e = reg[paneKey] = { label, seen: 0 }; _aspectPanesDirty = true; } + else if (e.label !== label) { e.label = label; _aspectPanesDirty = true; } + e.seen = _aspectNowMs(); + } + // Drop panes not reported recently (song change, split teardown, pane close). + function _aspectPrunePanes() { + const reg = window.__h3dAspectPanes; + if (!reg) return; + const now = _aspectNowMs(); + const ro = window.__h3dAspectReadout; + Object.keys(reg).forEach((k) => { + if (now - (reg[k].seen || 0) > 1500) { + delete reg[k]; + // Prune the matching readout slot so it can't grow unbounded as + // songs/arrangements churn, and drop a dangling __last pointer. + if (ro) { delete ro[k]; if (ro.__last === k) delete ro.__last; } + _aspectPanesDirty = true; + } + }); + } + + // True while _syncAspectPanel is programmatically refreshing controls, so the + // synthetic 'input' events it dispatches to update labels don't write back + // into the tune (which would populate a full override for every field and + // spam localStorage). Real user input runs with this false. + let _aspectSyncing = false; + // Read/write against the current edit target ('' → base, else pane override). + function _aspectReadVal(k) { + const base = _aspectTune(); + if (!_aspectEditTarget) return base[k]; + const ov = base.__panels && base.__panels[_aspectEditTarget]; + return (ov && (k in ov)) ? ov[k] : base[k]; + } + function _aspectWriteVal(k, v) { + const base = _aspectTune(); + if (!_aspectEditTarget) { base[k] = v; } + else { + const m = base.__panels || (base.__panels = {}); + (m[_aspectEditTarget] || (m[_aspectEditTarget] = {}))[k] = v; + } + _aspectPersist(); + } + // Clear a field: for the base target set the explicit auto value (null); for a + // pane target delete the override key so the pane re-inherits the base value + // (and drop the pane's override object once it's empty). + function _aspectClearVal(k) { + const base = _aspectTune(); + if (!_aspectEditTarget) { base[k] = null; } + else { + const m = base.__panels, ov = m && m[_aspectEditTarget]; + if (ov) { delete ov[k]; if (!Object.keys(ov).length) delete m[_aspectEditTarget]; } + } + _aspectPersist(); + } + + // (Re)build the Target dropdown from the live pane registry, preserving the + // current selection when it's still valid. + function _aspectBuildTargets() { + if (!_aspectTargetSel) return; + // Don't yank a dropdown the user is actively interacting with — leave it + // dirty and rebuild on a later tick once it's no longer focused. + if (document.activeElement === _aspectTargetSel) return; + const reg = window.__h3dAspectPanes || {}; + const keys = Object.keys(reg).sort(); + _aspectTargetSel.innerHTML = ''; + const all = document.createElement('option'); + all.value = ''; all.textContent = keys.length > 1 ? 'All panes' : 'All'; + _aspectTargetSel.appendChild(all); + keys.forEach((pk) => { + const o = document.createElement('option'); + o.value = pk; o.textContent = reg[pk].label; + _aspectTargetSel.appendChild(o); + }); + // Force the edit target back to "All" when the Target row is hidden + // (single pane) or the selected pane is gone — otherwise a stale pane + // target would silently route edits into a hidden (and persistent + // arr:*) override in single-player. + if (keys.length <= 1 || (_aspectEditTarget && !reg[_aspectEditTarget])) { + _aspectEditTarget = ''; + } + _aspectTargetSel.value = _aspectEditTarget; + // The Target row only matters with more than one pane (a split). With a + // single pane there's nothing to disambiguate, so hide it. + if (_aspectTgtRow) _aspectTgtRow.style.display = keys.length > 1 ? '' : 'none'; + _aspectPanesDirty = false; + } + + function _ensureAspectPanel() { + if (_aspectPanelEl || typeof document === 'undefined') return; + const wrap = document.createElement('div'); + wrap.id = 'h3d-aspect-tuner'; + wrap.style.cssText = [ + 'position:fixed', 'top:64px', 'right:12px', 'z-index:99999', + 'width:236px', 'padding:10px 12px', 'border-radius:8px', + 'background:rgba(12,18,28,0.92)', 'border:1px solid rgba(120,150,200,0.35)', + 'box-shadow:0 6px 24px rgba(0,0,0,0.5)', 'color:#cfe0f5', + 'font:11px/1.35 system-ui,sans-serif', 'user-select:none', + 'pointer-events:auto', + ].join(';'); + + // Header: title + close (×). Close hides the panel; the feature keeps + // whatever enabled state it had — this is a dismiss, not an A/B toggle. + const hdr = document.createElement('div'); + hdr.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;'; + const title = document.createElement('div'); + title.textContent = 'Wide-pane framing'; + title.style.cssText = 'font-weight:700;color:#e8c040;'; + const close = document.createElement('button'); + close.type = 'button'; // never submit if nested in a
+ close.textContent = '×'; + close.title = 'Close (Shift+A)'; + close.setAttribute('aria-label', 'Close'); + close.style.cssText = 'border:none;background:transparent;color:#cfe0f5;font-size:17px;line-height:1;cursor:pointer;padding:0 2px;'; + close.addEventListener('click', () => _setAspectPanelVisible(false)); + hdr.appendChild(title); hdr.appendChild(close); wrap.appendChild(hdr); + + // Target selector — which pane the controls below edit. + const tgtRow = document.createElement('div'); tgtRow.style.cssText = 'margin:2px 0 7px;'; + _aspectTgtRow = tgtRow; + const tgtLab = document.createElement('div'); + tgtLab.textContent = 'Target'; tgtLab.style.cssText = 'color:#9fb0c8;margin-bottom:2px;'; + _aspectTargetSel = document.createElement('select'); + _aspectTargetSel.setAttribute('aria-label', 'Target pane'); + _aspectTargetSel.style.cssText = 'width:100%;background:rgba(30,44,66,0.9);color:#cfe0f5;border:1px solid rgba(120,150,200,0.4);border-radius:4px;padding:3px;'; + _aspectTargetSel.addEventListener('change', () => { + _aspectEditTarget = _aspectTargetSel.value; _syncAspectPanel(); + }); + tgtRow.appendChild(tgtLab); tgtRow.appendChild(_aspectTargetSel); wrap.appendChild(tgtRow); + _aspectBuildTargets(); + + // enabled + splitOnly checkboxes (per-target) + [['enabled', 'Enabled'], ['splitOnly', 'Split panes only']].forEach(([k, lbl]) => { + const row = document.createElement('label'); + row.style.cssText = 'display:flex;align-items:center;gap:6px;margin:2px 0;cursor:pointer;'; + const cb = document.createElement('input'); + cb.type = 'checkbox'; cb.checked = !!_aspectReadVal(k); cb.dataset.k = k; + cb.addEventListener('change', () => { _aspectWriteVal(k, cb.checked); }); + const span = document.createElement('span'); span.textContent = lbl; + row.appendChild(cb); row.appendChild(span); wrap.appendChild(row); + }); + + // numeric sliders (per-target) + _ASPECT_FIELDS.forEach((f) => { + const row = document.createElement('div'); + row.style.cssText = 'margin:5px 0;'; + const head = document.createElement('div'); + head.style.cssText = 'display:flex;justify-content:space-between;'; + const lab = document.createElement('span'); lab.textContent = f.label; + const val = document.createElement('span'); + val.style.cssText = 'color:#8fb6ff;font-variant-numeric:tabular-nums;'; + head.appendChild(lab); head.appendChild(val); row.appendChild(head); + const sl = document.createElement('input'); + sl.type = 'range'; sl.min = f.min; sl.max = f.max; sl.step = f.step; + const rv = _aspectReadVal(f.k); + sl.value = Number.isFinite(rv) ? rv : _ASPECT_DEFAULTS[f.k]; + sl.dataset.k = f.k; + sl.style.cssText = 'width:100%;'; + const show = () => { val.textContent = (+sl.value).toFixed(f.step < 1 ? 2 : 0); }; + show(); + sl.addEventListener('input', () => { + show(); // label always refreshes + if (!_aspectSyncing) _aspectWriteVal(f.k, parseFloat(sl.value)); + }); + row.appendChild(sl); wrap.appendChild(row); + }); + + // hfov override (checkbox enables a slider; off → hfovDeg=null = auto) + { + const row = document.createElement('div'); row.style.cssText = 'margin:5px 0;'; + const head = document.createElement('label'); + head.style.cssText = 'display:flex;align-items:center;gap:6px;cursor:pointer;'; + const cb = document.createElement('input'); + cb.type = 'checkbox'; cb.checked = Number.isFinite(_aspectReadVal('hfovDeg')); + const lbl = document.createElement('span'); lbl.textContent = 'Override held hFOV°'; + head.appendChild(cb); head.appendChild(lbl); row.appendChild(head); + const sl = document.createElement('input'); + sl.type = 'range'; sl.min = 40; sl.max = 160; sl.step = 1; + const hv = _aspectReadVal('hfovDeg'); + sl.value = Number.isFinite(hv) ? hv : 102; + sl.disabled = !cb.checked; + sl.style.cssText = 'width:100%;'; + cb.addEventListener('change', () => { + if (_aspectSyncing) return; + sl.disabled = !cb.checked; + if (cb.checked) _aspectWriteVal('hfovDeg', parseFloat(sl.value)); + else _aspectClearVal('hfovDeg'); // base → auto (null); pane → re-inherit base + }); + sl.addEventListener('input', () => { + if (!_aspectSyncing && cb.checked) _aspectWriteVal('hfovDeg', parseFloat(sl.value)); + }); + row.appendChild(sl); wrap.appendChild(row); + _aspectHfovCb = cb; _aspectHfovSl = sl; + } + + // live readout + _aspectPanelRO = document.createElement('div'); + _aspectPanelRO.style.cssText = 'margin-top:6px;padding-top:6px;border-top:1px solid rgba(120,150,200,0.25);color:#9fb;font-variant-numeric:tabular-nums;'; + _aspectPanelRO.textContent = 'aspect — · vFOV —'; + wrap.appendChild(_aspectPanelRO); + + // buttons + const btnRow = document.createElement('div'); + btnRow.style.cssText = 'display:flex;gap:6px;margin-top:8px;'; + const mkBtn = (txt, fn) => { + const b = document.createElement('button'); + b.type = 'button'; // never submit if nested in a + b.textContent = txt; + b.style.cssText = 'flex:1;padding:4px 0;border-radius:5px;border:1px solid rgba(120,150,200,0.4);background:rgba(40,60,90,0.6);color:#cfe0f5;cursor:pointer;font:11px system-ui;'; + b.addEventListener('click', fn); + return b; + }; + // Reset: for "All" restores the shared defaults exactly; for a pane + // clears that pane's override so it inherits the shared base again. Panel + // visibility is independent (Shift+A / ×), so Reset doesn't force it open. + btnRow.appendChild(mkBtn('Reset', () => { + const base = _aspectTune(); + if (!_aspectEditTarget) { + Object.keys(_ASPECT_DEFAULTS).forEach((k) => { base[k] = _ASPECT_DEFAULTS[k]; }); + } else if (base.__panels) { + delete base.__panels[_aspectEditTarget]; + } + _aspectPersist(); _syncAspectPanel(); + })); + // Copy: the resolved values for the current target, as JSON. + btnRow.appendChild(mkBtn('Copy', () => { + const r = _aspectEditTarget ? _resolveTuneFor(_aspectEditTarget) : _aspectTune(); + const out = {}; + Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = r[k]; }); + const json = JSON.stringify(out, null, 2); + try { console.log('[h3d] wide-pane framing values (' + (_aspectEditTarget || 'all') + '):\n' + json); } catch (e) {} + try { if (navigator.clipboard) navigator.clipboard.writeText(json); } catch (e) {} + })); + wrap.appendChild(btnRow); + + document.body.appendChild(wrap); + _aspectPanelEl = wrap; + _aspectPanelEl.style.display = 'none'; + } + + // Push the current target's values back into the panel controls (after Reset, + // a target switch, or an external edit). Cheap; only runs on demand. + function _syncAspectPanel() { + if (!_aspectPanelEl) return; + _aspectBuildTargets(); + // Guard so the synthetic 'input' events below only refresh labels and + // don't write the read-back values into the target (which would turn a + // sparse pane override into a full one and spam localStorage). + _aspectSyncing = true; + try { + _aspectPanelEl.querySelectorAll('input[type=checkbox][data-k]').forEach((cb) => { + cb.checked = !!_aspectReadVal(cb.dataset.k); + }); + _aspectPanelEl.querySelectorAll('input[type=range][data-k]').forEach((sl) => { + const v = _aspectReadVal(sl.dataset.k); + if (Number.isFinite(v)) sl.value = v; + sl.dispatchEvent(new Event('input')); // refresh the value label only + }); + if (_aspectHfovCb) { + const hv = _aspectReadVal('hfovDeg'); + _aspectHfovCb.checked = Number.isFinite(hv); + _aspectHfovSl.disabled = !_aspectHfovCb.checked; + if (Number.isFinite(hv)) _aspectHfovSl.value = hv; + } + } finally { + _aspectSyncing = false; + } + } + + function _setAspectPanelVisible(on) { + _ensureAspectPanel(); + if (!_aspectPanelEl) return; + _aspectPanelEl.style.display = on ? 'block' : 'none'; + window.__h3dAspectPanelOpen = !!on; // gates the per-frame readout publish + // Prune before the first build so panes from a prior song/split don't + // flash in the dropdown until the first RAF tick. + if (on) { _aspectPrunePanes(); _aspectBuildTargets(); } + if (on && !_aspectPanelRAF) { + const tick = () => { + if (!window.__h3dAspectPanelOpen) { _aspectPanelRAF = 0; return; } + _aspectPrunePanes(); + if (_aspectPanesDirty) _aspectBuildTargets(); + const ro = window.__h3dAspectReadout; + if (_aspectPanelRO && ro) { + const key = _aspectEditTarget || ro.__last; + const e = key && ro[key]; + if (e && Number.isFinite(e.aspect)) { + _aspectPanelRO.textContent = + 'aspect ' + e.aspect.toFixed(2) + ' · vFOV ' + e.vfov.toFixed(1) + '°'; + } + } + _aspectPanelRAF = requestAnimationFrame(tick); + }; + _aspectPanelRAF = requestAnimationFrame(tick); + } + } + // Toggle the panel open/closed (the Shift+A dismiss/reveal). + function _toggleAspectPanel() { + _ensureAspectPanel(); + const open = !(_aspectPanelEl && _aspectPanelEl.style.display !== 'none'); + _setAspectPanelVisible(open); + if (open) _syncAspectPanel(); + } + + /* ====================================================================== + * Background animations (issue #13) + * + * Audio-reactive ambient scenery in the fog band beyond the highway. + * Module-level singletons share an AudioContext + AnalyserNode tap on + * the feedBack core