mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-13 06:58:31 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38297937b7 |
@@ -109,7 +109,7 @@ def apply_activity(counters, delta):
|
|||||||
def consecutive_run_length(dates):
|
def consecutive_run_length(dates):
|
||||||
"""Longest run of consecutive calendar dates in ``dates`` (ISO 'YYYY-MM-DD').
|
"""Longest run of consecutive calendar dates in ``dates`` (ISO 'YYYY-MM-DD').
|
||||||
|
|
||||||
Used by the `secret_witching` Feat (practise in the 2–5am window on N
|
Used by the `secret_witching` Feat (practice in the 2–5am window on N
|
||||||
consecutive nights). Pure date arithmetic so it's unit-testable; routes.py
|
consecutive nights). Pure date arithmetic so it's unit-testable; routes.py
|
||||||
feeds it the distinct night-dates recorded in `comp_ledger`.
|
feeds it the distinct night-dates recorded in `comp_ledger`.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
{
|
{
|
||||||
"id": "secret_witching",
|
"id": "secret_witching",
|
||||||
"title": "The Witching Hour",
|
"title": "The Witching Hour",
|
||||||
"description": "Practise in the dead of night, seven nights running.",
|
"description": "Practice in the dead of night, seven nights running.",
|
||||||
"category": "global",
|
"category": "global",
|
||||||
"sourceId": "achievements",
|
"sourceId": "achievements",
|
||||||
"secret": true,
|
"secret": true,
|
||||||
|
|||||||
@@ -1083,22 +1083,6 @@
|
|||||||
const FOCUS_D = 600 * K;
|
const FOCUS_D = 600 * K;
|
||||||
const CAM_LERP_BASE = 0.02;
|
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
|
// Zoom-dependent framing — height (h*) and depth (dist*) multipliers
|
||||||
// applied to cam.position. Interpolated by `dist`:
|
// applied to cam.position. Interpolated by `dist`:
|
||||||
// NEAR = tight view (nut position, span<=4 -> dist~=93*K): lower/closer.
|
// NEAR = tight view (nut position, span<=4 -> dist~=93*K): lower/closer.
|
||||||
@@ -1607,257 +1591,6 @@
|
|||||||
ss.isCanvasFocused(highwayCanvas));
|
ss.isCanvasFocused(highwayCanvas));
|
||||||
}
|
}
|
||||||
|
|
||||||
// A/B toggle for the wide-pane horizontal-FOV-hold. Flips
|
|
||||||
// window.__h3dAspectTune.enabled so the running app can switch between the
|
|
||||||
// current framing (off, the baseline) and the Hor+ framing (on) with one
|
|
||||||
// keypress, across all panes at once. Registered once per session via a
|
|
||||||
// module-level guard (it toggles a shared global, 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 _abShortcutRegistered = false;
|
|
||||||
function _registerAspectAbShortcut() {
|
|
||||||
if (_abShortcutRegistered) return;
|
|
||||||
if (typeof window.registerShortcut !== 'function') return;
|
|
||||||
_abShortcutRegistered = true;
|
|
||||||
try {
|
|
||||||
window.registerShortcut({
|
|
||||||
key: 'A', // uppercase e.key → produced with Shift held (Shift+A)
|
|
||||||
description: '3D Highway: toggle wide-pane framing A/B (Shift+A)',
|
|
||||||
scope: 'player',
|
|
||||||
handler: () => {
|
|
||||||
const t = _aspectTune();
|
|
||||||
t.enabled = !t.enabled;
|
|
||||||
try { console.log('[h3d] wide-pane framing', t.enabled ? 'ON' : 'OFF'); } catch (e) {}
|
|
||||||
// Surface the live tuner panel whenever the feature is on,
|
|
||||||
// hide it when off. Built lazily on first use.
|
|
||||||
_ensureAspectPanel();
|
|
||||||
_setAspectPanelVisible(t.enabled);
|
|
||||||
_syncAspectPanel();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
_abShortcutRegistered = 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 <div>
|
|
||||||
let _aspectPanelRAF = 0; // readout poll handle
|
|
||||||
|
|
||||||
// Get-or-create the live bridge object, seeded from defaults + localStorage.
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
function _aspectPersist() {
|
|
||||||
try {
|
|
||||||
const t = _aspectTune(), out = {};
|
|
||||||
Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t[k]; });
|
|
||||||
localStorage.setItem(_ASPECT_LS, JSON.stringify(out));
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _ensureAspectPanel() {
|
|
||||||
if (_aspectPanelEl || typeof document === 'undefined') return;
|
|
||||||
const t = _aspectTune();
|
|
||||||
const wrap = document.createElement('div');
|
|
||||||
wrap.id = 'h3d-aspect-tuner';
|
|
||||||
wrap.style.cssText = [
|
|
||||||
'position:fixed', 'top:64px', 'right:12px', 'z-index:99999',
|
|
||||||
'width:230px', '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(';');
|
|
||||||
|
|
||||||
const title = document.createElement('div');
|
|
||||||
title.textContent = 'Wide-pane framing (A/B)';
|
|
||||||
title.style.cssText = 'font-weight:700;margin-bottom:6px;color:#e8c040;';
|
|
||||||
wrap.appendChild(title);
|
|
||||||
|
|
||||||
// enabled + splitOnly checkboxes
|
|
||||||
[['enabled', 'Enabled (Shift+A)'], ['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 = !!t[k]; cb.dataset.k = k;
|
|
||||||
cb.addEventListener('change', () => {
|
|
||||||
_aspectTune()[k] = cb.checked; _aspectPersist();
|
|
||||||
if (k === 'enabled') _setAspectPanelVisible(cb.checked);
|
|
||||||
});
|
|
||||||
const span = document.createElement('span'); span.textContent = lbl;
|
|
||||||
row.appendChild(cb); row.appendChild(span); wrap.appendChild(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
// numeric sliders
|
|
||||||
_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;
|
|
||||||
sl.value = Number.isFinite(t[f.k]) ? t[f.k] : _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', () => {
|
|
||||||
_aspectTune()[f.k] = parseFloat(sl.value); show(); _aspectPersist();
|
|
||||||
});
|
|
||||||
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(t.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;
|
|
||||||
sl.value = Number.isFinite(t.hfovDeg) ? t.hfovDeg : 102;
|
|
||||||
sl.disabled = !cb.checked;
|
|
||||||
sl.style.cssText = 'width:100%;';
|
|
||||||
cb.addEventListener('change', () => {
|
|
||||||
sl.disabled = !cb.checked;
|
|
||||||
_aspectTune().hfovDeg = cb.checked ? parseFloat(sl.value) : null;
|
|
||||||
_aspectPersist();
|
|
||||||
});
|
|
||||||
sl.addEventListener('input', () => {
|
|
||||||
if (cb.checked) { _aspectTune().hfovDeg = parseFloat(sl.value); _aspectPersist(); }
|
|
||||||
});
|
|
||||||
row.appendChild(sl); wrap.appendChild(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.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;
|
|
||||||
};
|
|
||||||
btnRow.appendChild(mkBtn('Reset', () => {
|
|
||||||
const t2 = _aspectTune();
|
|
||||||
Object.keys(_ASPECT_DEFAULTS).forEach((k) => { t2[k] = _ASPECT_DEFAULTS[k]; });
|
|
||||||
t2.enabled = true; // keep panel up after reset
|
|
||||||
_aspectPersist(); _syncAspectPanel();
|
|
||||||
}));
|
|
||||||
btnRow.appendChild(mkBtn('Copy', () => {
|
|
||||||
const t2 = _aspectTune(), out = {};
|
|
||||||
Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t2[k]; });
|
|
||||||
const json = JSON.stringify(out, null, 2);
|
|
||||||
try { console.log('[h3d] wide-pane framing values:\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 current bridge values back into the panel controls (after Reset or an
|
|
||||||
// external edit). Cheap; only runs on demand.
|
|
||||||
function _syncAspectPanel() {
|
|
||||||
if (!_aspectPanelEl) return;
|
|
||||||
const t = _aspectTune();
|
|
||||||
_aspectPanelEl.querySelectorAll('input[type=checkbox][data-k]').forEach((cb) => {
|
|
||||||
cb.checked = !!t[cb.dataset.k];
|
|
||||||
});
|
|
||||||
_aspectPanelEl.querySelectorAll('input[type=range][data-k]').forEach((sl) => {
|
|
||||||
const k = sl.dataset.k;
|
|
||||||
if (Number.isFinite(t[k])) sl.value = t[k];
|
|
||||||
sl.dispatchEvent(new Event('input')); // refresh the value label
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _setAspectPanelVisible(on) {
|
|
||||||
_ensureAspectPanel();
|
|
||||||
if (!_aspectPanelEl) return;
|
|
||||||
_aspectPanelEl.style.display = on ? 'block' : 'none';
|
|
||||||
window.__h3dAspectPanelOpen = !!on; // gates the per-frame readout publish
|
|
||||||
if (on && !_aspectPanelRAF) {
|
|
||||||
const tick = () => {
|
|
||||||
if (!window.__h3dAspectPanelOpen) { _aspectPanelRAF = 0; return; }
|
|
||||||
const ro = window.__h3dAspectReadout;
|
|
||||||
if (_aspectPanelRO && ro && Number.isFinite(ro.aspect)) {
|
|
||||||
_aspectPanelRO.textContent =
|
|
||||||
'aspect ' + ro.aspect.toFixed(2) + ' · vFOV ' + ro.vfov.toFixed(1) + '°';
|
|
||||||
}
|
|
||||||
_aspectPanelRAF = requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
_aspectPanelRAF = requestAnimationFrame(tick);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================================================================
|
/* ======================================================================
|
||||||
* Background animations (issue #13)
|
* Background animations (issue #13)
|
||||||
*
|
*
|
||||||
@@ -3765,11 +3498,6 @@
|
|||||||
// that CSS-box drift and re-frame, instead of the user having to
|
// that CSS-box drift and re-frame, instead of the user having to
|
||||||
// un/re-maximize the window.
|
// un/re-maximize the window.
|
||||||
let _appliedW = 0, _appliedH = 0;
|
let _appliedW = 0, _appliedH = 0;
|
||||||
// Last pane aspect (w/h) handed to the camera, cached so camUpdate can
|
|
||||||
// recompute the horizontal-FOV-hold each frame (and react to live
|
|
||||||
// __h3dAspectTune edits) without waiting for a resize. 0 until first
|
|
||||||
// applySize().
|
|
||||||
let _paneAspect = 0;
|
|
||||||
// True once applySize() has pinned the .h3d-wrap overlay to the
|
// True once applySize() has pinned the .h3d-wrap overlay to the
|
||||||
// highway canvas's offset box. Stays false while the canvas has no
|
// highway canvas's offset box. Stays false while the canvas has no
|
||||||
// layout yet (init() can run before #highway has a real box, where
|
// layout yet (init() can run before #highway has a real box, where
|
||||||
@@ -6248,7 +5976,7 @@
|
|||||||
scene = new T.Scene();
|
scene = new T.Scene();
|
||||||
scene.fog = new T.Fog(0x101820, FOG_START * 0.8, FOG_END * 1.2);
|
scene.fog = new T.Fog(0x101820, FOG_START * 0.8, FOG_END * 1.2);
|
||||||
|
|
||||||
cam = new T.PerspectiveCamera(BASE_VFOV, 1, 0.01, FOG_END * 3);
|
cam = new T.PerspectiveCamera(70, 1, 0.01, FOG_END * 3);
|
||||||
|
|
||||||
ambLight = new T.AmbientLight(0xffffff, 0.85);
|
ambLight = new T.AmbientLight(0xffffff, 0.85);
|
||||||
scene.add(ambLight);
|
scene.add(ambLight);
|
||||||
@@ -14187,77 +13915,11 @@
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Horizontal-FOV-hold ("Hor+"). Returns the vertical fov (deg) the
|
|
||||||
// camera should use for the given pane aspect. With the bridge off (or
|
|
||||||
// absent), or at/under the start aspect, it returns the base vertical
|
|
||||||
// fov unchanged — an exact no-op, so normal panes render identically to
|
|
||||||
// before. Past the start aspect it lowers the vertical fov to keep the
|
|
||||||
// horizontal cone ~constant, so the neck fills an ultra-wide pane
|
|
||||||
// instead of collapsing into a central sliver. Pure + finite-guarded.
|
|
||||||
function effectiveVfov(aspect, tune) {
|
|
||||||
const base = (tune && Number.isFinite(tune.baseVfov)) ? tune.baseVfov : BASE_VFOV;
|
|
||||||
if (!tune || !tune.enabled || !Number.isFinite(aspect) || aspect <= 0) return base;
|
|
||||||
const start = (Number.isFinite(tune.startAspect) && tune.startAspect > 0)
|
|
||||||
? tune.startAspect : HORPLUS_START_ASPECT;
|
|
||||||
if (aspect <= start) return base;
|
|
||||||
const floor = Number.isFinite(tune.minVfovDeg) ? tune.minVfovDeg : HORPLUS_MIN_VFOV;
|
|
||||||
const DEG = Math.PI / 180;
|
|
||||||
// Held horizontal fov: explicit hfovDeg if given, else the horizontal
|
|
||||||
// cone the base vertical fov produces at the start aspect.
|
|
||||||
const hfov = (Number.isFinite(tune.hfovDeg) && tune.hfovDeg > 0)
|
|
||||||
? tune.hfovDeg * DEG
|
|
||||||
: 2 * Math.atan(Math.tan(base * DEG / 2) * start);
|
|
||||||
// Vertical fov that reproduces that horizontal cone at this aspect.
|
|
||||||
let vfov = 2 * Math.atan(Math.tan(hfov / 2) / aspect) / DEG;
|
|
||||||
const blend = Number.isFinite(tune.blend) ? Math.max(0, Math.min(1, tune.blend)) : 1;
|
|
||||||
vfov = base + (vfov - base) * blend; // 0 = base, 1 = full Hor+
|
|
||||||
if (!Number.isFinite(vfov)) return base;
|
|
||||||
return Math.max(floor, Math.min(base, vfov));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Camera smooth lerp ──────────────────────────────────────────── */
|
/* ── Camera smooth lerp ──────────────────────────────────────────── */
|
||||||
function camUpdate(bundle) {
|
function camUpdate(bundle) {
|
||||||
const bpm = computeBPM(bundle.beats, bundle.currentTime);
|
const bpm = computeBPM(bundle.beats, bundle.currentTime);
|
||||||
const lerp = CAM_LERP_BASE * Math.max(bpm, 60) / 120;
|
const lerp = CAM_LERP_BASE * Math.max(bpm, 60) / 120;
|
||||||
|
|
||||||
// ── Horizontal-FOV-hold + optional wide-pane pose nudges ──
|
|
||||||
// Driven by window.__h3dAspectTune (default off → exact no-op).
|
|
||||||
// _aspectTune() returns the live bridge object, seeded from defaults
|
|
||||||
// + localStorage on first read so a persisted tuning session applies
|
|
||||||
// on load without opening the panel. Every field is finite-coerced.
|
|
||||||
// When disabled (or splitOnly and not in a split) the tune is treated
|
|
||||||
// as null, so effectiveVfov returns the base vertical fov and cam.fov
|
|
||||||
// is restored to it. The fov write is guarded on an actual change so
|
|
||||||
// a steady pane costs nothing.
|
|
||||||
const _aspTune = _aspectTune();
|
|
||||||
const _aspActive = !!(_aspTune && _aspTune.enabled
|
|
||||||
&& !(_aspTune.splitOnly && !_ssActive()));
|
|
||||||
const _tune = _aspActive ? _aspTune : null;
|
|
||||||
const _vfov = effectiveVfov(_paneAspect, _tune);
|
|
||||||
if (Number.isFinite(_vfov) && Math.abs(_vfov - cam.fov) > 1e-4) {
|
|
||||||
cam.fov = _vfov;
|
|
||||||
cam.updateProjectionMatrix();
|
|
||||||
}
|
|
||||||
// Publish a live readout for the tuner panel (only while it's open,
|
|
||||||
// so the steady path stays allocation-free). Last pane to render wins
|
|
||||||
// the slot — fine, all panes share the same aspect in a split layout.
|
|
||||||
if (window.__h3dAspectPanelOpen) {
|
|
||||||
const _ro = window.__h3dAspectReadout || (window.__h3dAspectReadout = {});
|
|
||||||
_ro.aspect = _paneAspect; _ro.vfov = _vfov;
|
|
||||||
}
|
|
||||||
// Optional pose nudges (height / dolly / pitch) to chase a low-flat
|
|
||||||
// wide-pane look if fov alone isn't enough. Gated to wide panes and
|
|
||||||
// suppressed while the Camera Director owns the view (it wins).
|
|
||||||
const _startAspect = (_tune && Number.isFinite(_tune.startAspect) && _tune.startAspect > 0)
|
|
||||||
? _tune.startAspect : HORPLUS_START_ASPECT;
|
|
||||||
const _dirActive = !!(window.__h3dCamCtl && window.__h3dCamCtl.enabled);
|
|
||||||
const _wide = !!(_tune && _paneAspect > _startAspect) && !_dirActive;
|
|
||||||
const _poseHMul = (_wide && Number.isFinite(_tune.heightMul)) ? _tune.heightMul : 1;
|
|
||||||
const _poseDMul = (_wide && Number.isFinite(_tune.distMul)) ? _tune.distMul : 1;
|
|
||||||
const _poseLookYAdd = (_wide && Number.isFinite(_tune.pitchAdd)) ? _tune.pitchAdd * K : 0;
|
|
||||||
const _poseLookZMul = (_wide && Number.isFinite(_tune.lookDepthMul) && _tune.lookDepthMul > 0)
|
|
||||||
? _tune.lookDepthMul : 1;
|
|
||||||
|
|
||||||
curX += (tgtX - curX) * lerp;
|
curX += (tgtX - curX) * lerp;
|
||||||
// The fret-row fit guard (end of camUpdate) may dolly the camera back
|
// The fret-row fit guard (end of camUpdate) may dolly the camera back
|
||||||
// via _fretRowFitBoost; the span-driven tgtDist still owns zooming IN.
|
// via _fretRowFitBoost; the span-driven tgtDist still owns zooming IN.
|
||||||
@@ -14273,9 +13935,6 @@
|
|||||||
const _dMul = CAM_FRAME_D_NEAR + (CAM_FRAME_D_FAR - CAM_FRAME_D_NEAR) * _zt;
|
const _dMul = CAM_FRAME_D_NEAR + (CAM_FRAME_D_FAR - CAM_FRAME_D_NEAR) * _zt;
|
||||||
const shoulderOffset = (_leftyCached ? -1 : 1) * 10 * K;
|
const shoulderOffset = (_leftyCached ? -1 : 1) * 10 * K;
|
||||||
let _camX = curX + shoulderOffset, _camY = h * _hMul, _camZ = dist * _dMul;
|
let _camX = curX + shoulderOffset, _camY = h * _hMul, _camZ = dist * _dMul;
|
||||||
// Optional wide-pane pose nudges (default identity → no-op).
|
|
||||||
if (_poseHMul !== 1) _camY *= _poseHMul;
|
|
||||||
if (_poseDMul !== 1) _camZ *= _poseDMul;
|
|
||||||
// ── Free-camera user tweaks (orbit / height / zoom / pan) ──
|
// ── Free-camera user tweaks (orbit / height / zoom / pan) ──
|
||||||
// Driven by the Camera Director plugin via window.__h3dCamCtl.
|
// Driven by the Camera Director plugin via window.__h3dCamCtl.
|
||||||
// Layered ON TOP of the auto-framing so note tracking still works.
|
// Layered ON TOP of the auto-framing so note tracking still works.
|
||||||
@@ -14284,7 +13943,7 @@
|
|||||||
// finite number before use so a malformed object can never feed NaN
|
// finite number before use so a malformed object can never feed NaN
|
||||||
// into cam.position / cam.lookAt.
|
// into cam.position / cam.lookAt.
|
||||||
const _freeCam = window.__h3dCamCtl;
|
const _freeCam = window.__h3dCamCtl;
|
||||||
const _lookAtZ = -FOCUS_D * 0.35 * _poseLookZMul;
|
const _lookAtZ = -FOCUS_D * 0.35;
|
||||||
if (_freeCam && _freeCam.enabled) {
|
if (_freeCam && _freeCam.enabled) {
|
||||||
const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1;
|
const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1;
|
||||||
const _heightMul = Number.isFinite(_freeCam.heightMul) ? _freeCam.heightMul : 1;
|
const _heightMul = Number.isFinite(_freeCam.heightMul) ? _freeCam.heightMul : 1;
|
||||||
@@ -14305,7 +13964,7 @@
|
|||||||
// This lets the camera adapt to any panel aspect ratio automatically.
|
// This lets the camera adapt to any panel aspect ratio automatically.
|
||||||
const fretMidY = (sY(0) + sY(nStr - 1)) / 2;
|
const fretMidY = (sY(0) + sY(nStr - 1)) / 2;
|
||||||
_probe.set(curX, fretMidY, 0); // play-line fretboard centre
|
_probe.set(curX, fretMidY, 0); // play-line fretboard centre
|
||||||
cam.lookAt(curX, curLookY + _poseLookYAdd, _lookAtZ); // tentative look — needed for project()
|
cam.lookAt(curX, curLookY, -FOCUS_D * 0.35); // tentative look — needed for project()
|
||||||
cam.updateMatrixWorld();
|
cam.updateMatrixWorld();
|
||||||
_probe.project(cam); // _probe.y → NDC in [-1, 1]
|
_probe.project(cam); // _probe.y → NDC in [-1, 1]
|
||||||
|
|
||||||
@@ -14334,7 +13993,7 @@
|
|||||||
const _pitch = Number.isFinite(_freeCam.pitch) ? _freeCam.pitch : 0;
|
const _pitch = Number.isFinite(_freeCam.pitch) ? _freeCam.pitch : 0;
|
||||||
cam.lookAt(curX + _panX * K, curLookY + (_pitch + _panY) * K, _lookAtZ);
|
cam.lookAt(curX + _panX * K, curLookY + (_pitch + _panY) * K, _lookAtZ);
|
||||||
} else {
|
} else {
|
||||||
cam.lookAt(curX, curLookY + _poseLookYAdd, _lookAtZ);
|
cam.lookAt(curX, curLookY, _lookAtZ);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fret-row fit guard ────────────────────────────────────────────
|
// ── Fret-row fit guard ────────────────────────────────────────────
|
||||||
@@ -14431,10 +14090,6 @@
|
|||||||
cam.aspect = w / h;
|
cam.aspect = w / h;
|
||||||
cam.updateProjectionMatrix();
|
cam.updateProjectionMatrix();
|
||||||
aspectScale = Math.max(1, REF_ASPECT / Math.max(cam.aspect, 0.5));
|
aspectScale = Math.max(1, REF_ASPECT / Math.max(cam.aspect, 0.5));
|
||||||
// Cache the pane aspect for the horizontal-FOV-hold in camUpdate.
|
|
||||||
// cam.fov itself is owned by camUpdate (not set here) so live
|
|
||||||
// __h3dAspectTune edits apply every frame without a resize.
|
|
||||||
_paneAspect = cam.aspect;
|
|
||||||
_appliedW = w; _appliedH = h;
|
_appliedW = w; _appliedH = h;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14678,7 +14333,6 @@
|
|||||||
}
|
}
|
||||||
_destroyed = _isReady = false;
|
_destroyed = _isReady = false;
|
||||||
_isFocused = true;
|
_isFocused = true;
|
||||||
_registerAspectAbShortcut(); // session-global A/B toggle (self-guarded)
|
|
||||||
const myToken = ++_initToken;
|
const myToken = ++_initToken;
|
||||||
highwayCanvas = canvas;
|
highwayCanvas = canvas;
|
||||||
_invertedCached = !!(bundle && bundle.inverted);
|
_invertedCached = !!(bundle && bundle.inverted);
|
||||||
@@ -15097,8 +14751,6 @@
|
|||||||
_destroyed = true; _isReady = false; _diagChord = null; _diagPrev = null; _diagLastKey = null; _diagRenderCache.clear();
|
_destroyed = true; _isReady = false; _diagChord = null; _diagPrev = null; _diagLastKey = null; _diagRenderCache.clear();
|
||||||
_lastHwW = 0; _lastHwH = 0;
|
_lastHwW = 0; _lastHwH = 0;
|
||||||
_appliedW = 0; _appliedH = 0;
|
_appliedW = 0; _appliedH = 0;
|
||||||
_paneAspect = 0;
|
|
||||||
if (cam && cam.fov !== BASE_VFOV) { cam.fov = BASE_VFOV; cam.updateProjectionMatrix(); }
|
|
||||||
_wrapPinned = false;
|
_wrapPinned = false;
|
||||||
_unsubscribeFocus(); teardown();
|
_unsubscribeFocus(); teardown();
|
||||||
highwayCanvas = null;
|
highwayCanvas = null;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "tuner",
|
"id": "tuner",
|
||||||
"name": "Guitar/Bass Tuner",
|
"name": "Guitar/Bass Tuner",
|
||||||
"version": "1.3.2",
|
"version": "1.3.1",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"private": false,
|
"private": false,
|
||||||
"script": "screen.js",
|
"script": "screen.js",
|
||||||
|
|||||||
@@ -18,8 +18,6 @@
|
|||||||
// ── Constants ─────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────
|
||||||
var _TUNER_LABEL_H = 12; // px height of each drum label
|
var _TUNER_LABEL_H = 12; // px height of each drum label
|
||||||
var _TUNER_NEEDLE_HALF_SWEEP = 90; // degrees — ±50 cents = horizontal (180° apart)
|
var _TUNER_NEEDLE_HALF_SWEEP = 90; // degrees — ±50 cents = horizontal (180° apart)
|
||||||
var _SETTLE_A = 0.05; // deg — needle "settled" threshold (sub-visible)
|
|
||||||
var _SETTLE_Y = 0.1; // px — drum-strip "settled" threshold
|
|
||||||
var _TUNER_IN_TUNE_THRESHOLD = 2;
|
var _TUNER_IN_TUNE_THRESHOLD = 2;
|
||||||
var _TUNER_STRIP_START_MIDI = 14; // ~18 Hz — covers 20 Hz minimum
|
var _TUNER_STRIP_START_MIDI = 14; // ~18 Hz — covers 20 Hz minimum
|
||||||
var _TUNER_STRIP_END_MIDI = 84; // ~1047 Hz C6
|
var _TUNER_STRIP_END_MIDI = 84; // ~1047 Hz C6
|
||||||
@@ -323,34 +321,9 @@
|
|||||||
currentAngle += (targetAngle - currentAngle) * lf;
|
currentAngle += (targetAngle - currentAngle) * lf;
|
||||||
_setNeedle(currentAngle);
|
_setNeedle(currentAngle);
|
||||||
|
|
||||||
// Stop once the needle has settled on its target — a static needle
|
|
||||||
// needs no repaint. update() re-kicks the loop when a new reading
|
|
||||||
// moves the target, so this idles the always-on tuner (no signal /
|
|
||||||
// steady pitch) instead of pinning a core at 60 fps forever.
|
|
||||||
if (Math.abs(targetDrumY - currentDrumY) <= _SETTLE_Y
|
|
||||||
&& Math.abs(targetAngle - currentAngle) <= _SETTLE_A) {
|
|
||||||
currentDrumY = targetDrumY; currentAngle = targetAngle;
|
|
||||||
freqStrip.style.transform = 'translateY(' + currentDrumY + 'px)';
|
|
||||||
noteStrip.style.transform = 'translateY(' + currentDrumY + 'px)';
|
|
||||||
_setNeedle(currentAngle);
|
|
||||||
rafId = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rafId = requestAnimationFrame(_animate);
|
rafId = requestAnimationFrame(_animate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restart the loop only when there's actually something to animate toward
|
|
||||||
// (a new target). Reset lastTime so the first frame after an idle gap
|
|
||||||
// doesn't take one big easing step.
|
|
||||||
function _kick() {
|
|
||||||
if (rafId === null
|
|
||||||
&& (Math.abs(targetDrumY - currentDrumY) > _SETTLE_Y
|
|
||||||
|| Math.abs(targetAngle - currentAngle) > _SETTLE_A)) {
|
|
||||||
lastTime = performance.now();
|
|
||||||
rafId = requestAnimationFrame(_animate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rafId = requestAnimationFrame(_animate);
|
rafId = requestAnimationFrame(_animate);
|
||||||
|
|
||||||
// ── Public API ────────────────────────────────────────────────
|
// ── Public API ────────────────────────────────────────────────
|
||||||
@@ -387,7 +360,6 @@
|
|||||||
bulbEl.style.backgroundColor = '#2a1010';
|
bulbEl.style.backgroundColor = '#2a1010';
|
||||||
bulbEl.style.border = '2px solid #4a2020';
|
bulbEl.style.border = '2px solid #4a2020';
|
||||||
bulbEl.style.boxShadow = 'none';
|
bulbEl.style.boxShadow = 'none';
|
||||||
_kick(); // animate back to rest, then the loop self-stops
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +376,6 @@
|
|||||||
bulbEl.style.border = '2px solid #4a2020';
|
bulbEl.style.border = '2px solid #4a2020';
|
||||||
bulbEl.style.boxShadow = 'none';
|
bulbEl.style.boxShadow = 'none';
|
||||||
}
|
}
|
||||||
_kick(); // a new reading moved the target → run until it settles
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroy() {
|
function destroy() {
|
||||||
|
|||||||
@@ -620,20 +620,8 @@
|
|||||||
if (_mt3Mode === 'strobe') { _computeStrobeStates(); }
|
if (_mt3Mode === 'strobe') { _computeStrobeStates(); }
|
||||||
|
|
||||||
_applyTickStates();
|
_applyTickStates();
|
||||||
|
|
||||||
// No signal and both the glow and strobe drift have fully settled →
|
|
||||||
// idle the loop. update() re-kicks it on the next note.
|
|
||||||
if (!_mt3HasSignal && _mt3GlowOpacity < 0.004
|
|
||||||
&& Math.abs(_mt3SmoothedCents) <= 0.1) {
|
|
||||||
_mt3RafId = null;
|
|
||||||
_mt3LastTime = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_mt3RafId = requestAnimationFrame(_animateStrobe);
|
_mt3RafId = requestAnimationFrame(_animateStrobe);
|
||||||
}
|
}
|
||||||
function _kick() {
|
|
||||||
if (_mt3RafId === null) { _mt3LastTime = null; _mt3RafId = requestAnimationFrame(_animateStrobe); }
|
|
||||||
}
|
|
||||||
_mt3RafId = requestAnimationFrame(_animateStrobe);
|
_mt3RafId = requestAnimationFrame(_animateStrobe);
|
||||||
|
|
||||||
// ── MODE button ───────────────────────────────────────────────
|
// ── MODE button ───────────────────────────────────────────────
|
||||||
@@ -689,7 +677,6 @@
|
|||||||
_renderNote(' ');
|
_renderNote(' ');
|
||||||
_applyAccidental();
|
_applyAccidental();
|
||||||
}
|
}
|
||||||
if (hasNote) { _kick(); } // new signal → restart the strobe loop if idled
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Public: destroy ───────────────────────────────────────────
|
// ── Public: destroy ───────────────────────────────────────────
|
||||||
|
|||||||
@@ -354,19 +354,10 @@
|
|||||||
if (_smoothedCents > 0) { speed = -speed; }
|
if (_smoothedCents > 0) { speed = -speed; }
|
||||||
_strobeOffset = ((_strobeOffset + speed * dt) % _totalDash + _totalDash) % _totalDash;
|
_strobeOffset = ((_strobeOffset + speed * dt) % _totalDash + _totalDash) % _totalDash;
|
||||||
arcPath.setAttribute('stroke-dashoffset', String(_strobeOffset));
|
arcPath.setAttribute('stroke-dashoffset', String(_strobeOffset));
|
||||||
} else if (_currentCents === 0) {
|
|
||||||
// Fully decelerated and no live signal → idle the loop instead of
|
|
||||||
// rescheduling forever. update() re-kicks it on the next note.
|
|
||||||
_rafId = null;
|
|
||||||
_lastTime = null;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_rafId = requestAnimationFrame(_animateStrobe);
|
_rafId = requestAnimationFrame(_animateStrobe);
|
||||||
}
|
}
|
||||||
function _kick() {
|
|
||||||
if (_rafId === null) { _lastTime = null; _rafId = requestAnimationFrame(_animateStrobe); }
|
|
||||||
}
|
|
||||||
_rafId = requestAnimationFrame(_animateStrobe);
|
_rafId = requestAnimationFrame(_animateStrobe);
|
||||||
|
|
||||||
// ── Helper: derive octave number from frequency ───────────────
|
// ── Helper: derive octave number from frequency ───────────────
|
||||||
@@ -448,7 +439,6 @@
|
|||||||
|
|
||||||
// Strobe state — smoothed animation decelerates naturally when _currentCents → 0
|
// Strobe state — smoothed animation decelerates naturally when _currentCents → 0
|
||||||
_currentCents = hasNote ? cents : 0;
|
_currentCents = hasNote ? cents : 0;
|
||||||
if (hasNote) { _kick(); } // new signal → restart the decel loop if idled
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Public: destroy ───────────────────────────────────────────
|
// ── Public: destroy ───────────────────────────────────────────
|
||||||
|
|||||||
@@ -142,20 +142,9 @@ window._tunerViz_strobe = function (container) {
|
|||||||
strobeEl.style.opacity = '0';
|
strobeEl.style.opacity = '0';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idle the loop when there's no live signal — the strobe only needs to
|
|
||||||
// paint while a note is sounding. update() re-kicks it on the next note,
|
|
||||||
// so a silent tuner stops repainting instead of spinning at 60 fps.
|
|
||||||
if (!strobeActive) { rafId = null; return; }
|
|
||||||
rafId = requestAnimationFrame(_animate);
|
rafId = requestAnimationFrame(_animate);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _kick() {
|
|
||||||
if (rafId === null) {
|
|
||||||
lastAnimateTime = performance.now();
|
|
||||||
rafId = requestAnimationFrame(_animate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rafId = requestAnimationFrame(_animate);
|
rafId = requestAnimationFrame(_animate);
|
||||||
|
|
||||||
// ── Public API ────────────────────────────────────────────────────
|
// ── Public API ────────────────────────────────────────────────────
|
||||||
@@ -199,7 +188,6 @@ window._tunerViz_strobe = function (container) {
|
|||||||
const inTune = Math.abs(cents) < 5;
|
const inTune = Math.abs(cents) < 5;
|
||||||
strobeEl.style.opacity = inTune ? '1' : '0.6';
|
strobeEl.style.opacity = inTune ? '1' : '0.6';
|
||||||
strobeEl.style.filter = inTune ? _STROBE_GLOW_IN_TUNE : _STROBE_GLOW_OUT;
|
strobeEl.style.filter = inTune ? _STROBE_GLOW_IN_TUNE : _STROBE_GLOW_OUT;
|
||||||
_kick();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroy() {
|
function destroy() {
|
||||||
|
|||||||
@@ -122,26 +122,6 @@
|
|||||||
plungerEl.style.left = _leftPct.toFixed(2) + '%';
|
plungerEl.style.left = _leftPct.toFixed(2) + '%';
|
||||||
plungerEl.style.top = _topPct.toFixed(2) + '%';
|
plungerEl.style.top = _topPct.toFixed(2) + '%';
|
||||||
|
|
||||||
// No live signal and the plunger has eased back to its resting centre
|
|
||||||
// → idle the loop. update() re-kicks it on the next note.
|
|
||||||
if (_currentNote === null && !_plungerDipped
|
|
||||||
&& Math.abs(targetLeft - _leftPct) < 0.05) {
|
|
||||||
_leftPct = targetLeft;
|
|
||||||
plungerEl.style.left = _leftPct.toFixed(2) + '%';
|
|
||||||
_rafId = null;
|
|
||||||
_lastTime = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_rafId = requestAnimationFrame(_animate);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _kick() {
|
|
||||||
if (_rafId !== null) return;
|
|
||||||
// Already parked at rest with no signal → nothing to animate, stay idle.
|
|
||||||
if (_currentNote === null && !_plungerDipped
|
|
||||||
&& Math.abs(_TUNER_TT_CENTRE_PCT - _leftPct) < 0.05) return;
|
|
||||||
_lastTime = null;
|
|
||||||
_rafId = requestAnimationFrame(_animate);
|
_rafId = requestAnimationFrame(_animate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +130,6 @@
|
|||||||
_currentNote = note;
|
_currentNote = note;
|
||||||
_currentCents = note === null ? 0 : cents;
|
_currentCents = note === null ? 0 : cents;
|
||||||
if (!_plungerDipped) { noteEl.textContent = note || '–'; }
|
if (!_plungerDipped) { noteEl.textContent = note || '–'; }
|
||||||
_kick(); // a new reading may move the plunger → ensure the loop runs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroy() {
|
function destroy() {
|
||||||
|
|||||||
+3
-15
@@ -855,12 +855,9 @@
|
|||||||
<div id="v3-live-performance-state" class="v3-live-performance-state" aria-hidden="true"></div>
|
<div id="v3-live-performance-state" class="v3-live-performance-state" aria-hidden="true"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="v3-upnext" class="v3-upnext hidden">
|
<div id="v3-upnext" class="v3-upnext hidden">
|
||||||
<div class="v3-upnext-row">
|
<span class="text-gray-400">Up Next:</span>
|
||||||
<span class="text-gray-400">Up Next:</span>
|
<span id="v3-upnext-name" class="v3-upnext-name"></span>
|
||||||
<span id="v3-upnext-name" class="v3-upnext-name"></span>
|
<span id="v3-upnext-eta" class="text-gray-400 text-xs"></span>
|
||||||
<span id="v3-upnext-eta" class="text-gray-400 text-xs"></span>
|
|
||||||
</div>
|
|
||||||
<div class="v3-upnext-bar"><div id="v3-upnext-bar-fill" class="v3-upnext-bar-fill"></div></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -926,15 +923,6 @@
|
|||||||
<option value="0.5">Low</option>
|
<option value="0.5">Low</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="v3-pop-row">
|
|
||||||
<span class="v3-pop-label" id="min-scale-label">Min res</span>
|
|
||||||
<select id="min-scale-select" onchange="highway.setMinRenderScale && highway.setMinRenderScale(parseFloat(this.value))" class="v3-pop-select" aria-labelledby="min-scale-label" title="Minimum auto resolution — how far the highway may lower its resolution to hold the frame rate on heavy scenes. 'Full' disables auto-downscaling, but the Quality selector still caps the maximum (so it's only full resolution at Quality = HD).">
|
|
||||||
<option value="0.25">25%</option>
|
|
||||||
<option value="0.5">50%</option>
|
|
||||||
<option value="0.75">75%</option>
|
|
||||||
<option value="1">Full</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="v3-pop-row">
|
<div class="v3-pop-row">
|
||||||
<span class="v3-pop-label" id="scoreboard-label">Scoreboard</span>
|
<span class="v3-pop-label" id="scoreboard-label">Scoreboard</span>
|
||||||
<select id="scoreboard-select" onchange="setScoreboard(this.value)" class="v3-pop-select" aria-labelledby="scoreboard-label" title="Highway scoreboard">
|
<select id="scoreboard-select" onchange="setScoreboard(this.value)" class="v3-pop-select" aria-labelledby="scoreboard-label" title="Highway scoreboard">
|
||||||
|
|||||||
@@ -187,19 +187,6 @@
|
|||||||
const nm = $('v3-upnext-name'), eta = $('v3-upnext-eta');
|
const nm = $('v3-upnext-name'), eta = $('v3-upnext-eta');
|
||||||
if (nm) nm.textContent = next.name || '—';
|
if (nm) nm.textContent = next.name || '—';
|
||||||
if (eta) eta.textContent = 'in ' + dt.toFixed(1) + 's';
|
if (eta) eta.textContent = 'in ' + dt.toFixed(1) + 's';
|
||||||
// Progress bar: fraction of the current section elapsed toward `next`.
|
|
||||||
// Previous boundary is the last section at/before now (else song start).
|
|
||||||
const fill = $('v3-upnext-bar-fill');
|
|
||||||
if (fill) {
|
|
||||||
let prevT = 0;
|
|
||||||
for (let i = 0; i < secs.length; i++) {
|
|
||||||
if (typeof secs[i].time === 'number' && secs[i].time <= t) prevT = secs[i].time;
|
|
||||||
else break;
|
|
||||||
}
|
|
||||||
const span = next.time - prevT;
|
|
||||||
const prog = span > 0 ? Math.max(0, Math.min(1, (t - prevT) / span)) : 0;
|
|
||||||
fill.style.width = (prog * 100).toFixed(1) + '%';
|
|
||||||
}
|
|
||||||
pill.classList.remove('hidden');
|
pill.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -375,7 +375,7 @@
|
|||||||
// Step 3 — Achievements wall opt-in (first-run only; default OFF).
|
// Step 3 — Achievements wall opt-in (first-run only; default OFF).
|
||||||
'<div id="v3-ob-step3" class="hidden">' +
|
'<div id="v3-ob-step3" class="hidden">' +
|
||||||
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Feats of Power</label>' +
|
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Feats of Power</label>' +
|
||||||
'<p class="text-sm text-fb-textDim mb-3">As you practise you’ll earn rare <span class="text-fb-text">Feats of Power</span> — silly, bombastic activity trophies. Want to show them off on the public <span class="text-fb-text">Feats wall</span>?</p>' +
|
'<p class="text-sm text-fb-textDim mb-3">As you practice you’ll earn rare <span class="text-fb-text">Feats of Power</span> — silly, bombastic activity trophies. Want to show them off on the public <span class="text-fb-text">Feats wall</span>?</p>' +
|
||||||
'<label class="flex items-start gap-3 cursor-pointer rounded-lg border border-fb-border/50 bg-fb-bg/40 p-3">' +
|
'<label class="flex items-start gap-3 cursor-pointer rounded-lg border border-fb-border/50 bg-fb-bg/40 p-3">' +
|
||||||
'<input type="checkbox" id="v3-ob-optin" class="mt-1 h-4 w-4 rounded border-gray-600 bg-gray-800 text-fb-primary focus:ring-fb-primary">' +
|
'<input type="checkbox" id="v3-ob-optin" class="mt-1 h-4 w-4 rounded border-gray-600 bg-gray-800 text-fb-primary focus:ring-fb-primary">' +
|
||||||
'<span class="text-sm text-fb-text">Share my Feats on the wall' +
|
'<span class="text-sm text-fb-text">Share my Feats on the wall' +
|
||||||
|
|||||||
@@ -1567,7 +1567,6 @@
|
|||||||
'<select id="v3-songs-format" class="' + ctrl + '">' + opt(FORMATS, state.format) + '</select>' +
|
'<select id="v3-songs-format" class="' + ctrl + '">' + opt(FORMATS, state.format) + '</select>' +
|
||||||
'<button id="v3-songs-filters" class="relative ' + ctrl + ' flex items-center gap-2">Filters<span id="v3-songs-filter-count" class="hidden bg-fb-primary text-white text-xs rounded-full px-1.5">0</span></button>' +
|
'<button id="v3-songs-filters" class="relative ' + ctrl + ' flex items-center gap-2">Filters<span id="v3-songs-filter-count" class="hidden bg-fb-primary text-white text-xs rounded-full px-1.5">0</span></button>' +
|
||||||
'<button id="v3-songs-select" class="' + ctrl + (state.selectMode ? ' bg-fb-primary text-white' : '') + '">Select</button>' +
|
'<button id="v3-songs-select" class="' + ctrl + (state.selectMode ? ' bg-fb-primary text-white' : '') + '">Select</button>' +
|
||||||
'<button id="v3-songs-refresh" title="Refresh library (scan for new songs)" class="' + ctrl + '">⟳ Refresh</button>' +
|
|
||||||
'<button id="v3-songs-upload" class="' + ctrl + '">Upload</button>' +
|
'<button id="v3-songs-upload" class="' + ctrl + '">Upload</button>' +
|
||||||
'</div></div></div>' +
|
'</div></div></div>' +
|
||||||
// Practice-aware library home: a repertoire progress meter + a
|
// Practice-aware library home: a repertoire progress meter + a
|
||||||
@@ -1625,16 +1624,6 @@
|
|||||||
if (legacy) { legacy.click(); watchUploadScan(); }
|
if (legacy) { legacy.click(); watchUploadScan(); }
|
||||||
});
|
});
|
||||||
byId('v3-songs-select').addEventListener('click', () => setSelectMode(!state.selectMode));
|
byId('v3-songs-select').addEventListener('click', () => setSelectMode(!state.selectMode));
|
||||||
byId('v3-songs-refresh')?.addEventListener('click', refreshLibrary);
|
|
||||||
// Reflect a scan already in progress (Settings button or a background
|
|
||||||
// pass) on the Refresh button, so its state isn't just tied to clicks here.
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/scan-status');
|
|
||||||
const sd = r.ok ? await r.json() : null;
|
|
||||||
if (sd && sd.running) { _setRefreshState(sd); _watchScan({ announce: false }); }
|
|
||||||
} catch (e) { /* */ }
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Bulletproof multi-select: in select mode, a capture-phase click on the
|
// Bulletproof multi-select: in select mode, a capture-phase click on the
|
||||||
// grid toggles the card and STOPS the event, so nothing (a per-card
|
// grid toggles the card and STOPS the event, so nothing (a per-card
|
||||||
@@ -1764,73 +1753,6 @@
|
|||||||
// legacy screens, so without this newly-uploaded songs wouldn't appear in
|
// legacy screens, so without this newly-uploaded songs wouldn't appear in
|
||||||
// v3 until a manual refresh. Bounded so a no-op upload can't poll forever.
|
// v3 until a manual refresh. Bounded so a no-op upload can't poll forever.
|
||||||
let _uploadScanTimer = null;
|
let _uploadScanTimer = null;
|
||||||
// ── Library refresh (rescan) from the Songs toolbar ───────────────────────
|
|
||||||
// The tester ask: a media-server-style "I dropped files in my folder, hit
|
|
||||||
// refresh" button, with live progress. Reuses the SAME machinery the Settings
|
|
||||||
// Rescan buttons drive (/api/rescan + /api/scan-status) and emits
|
|
||||||
// library:changed so the grid reloads. A scan already running (Settings or a
|
|
||||||
// background pass) is reflected on the button too.
|
|
||||||
let _refreshPoll = null;
|
|
||||||
function _setRefreshState(sd) {
|
|
||||||
const btn = document.getElementById('v3-songs-refresh');
|
|
||||||
if (!btn) return;
|
|
||||||
if (sd && sd.running) {
|
|
||||||
// Determinate count only exists in the 'scanning' stage; 'listing' is
|
|
||||||
// indeterminate (total 0), so show a plain "Scanning…" then.
|
|
||||||
const det = (sd.stage === 'scanning' && sd.total) ? ' ' + sd.done + '/' + sd.total : '';
|
|
||||||
btn.textContent = '⟳ Scanning' + det + '…';
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.classList.add('opacity-70');
|
|
||||||
const pct = sd.total ? Math.round((sd.done / sd.total) * 100) + '% · ' : '';
|
|
||||||
btn.title = 'Scanning new/changed songs… ' + pct + (sd.current || '');
|
|
||||||
} else {
|
|
||||||
btn.textContent = '⟳ Refresh';
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.classList.remove('opacity-70');
|
|
||||||
btn.title = 'Refresh library (scan for new songs)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Show a completion toast (reuses the shared fbNotify surface), suppressed
|
|
||||||
// while in a song. Honest + never-punishing copy; the precise "N added" count
|
|
||||||
// arrives with the background-scan delta work — until then this is a generic,
|
|
||||||
// truthful confirmation.
|
|
||||||
function _scanCompleteToast(sd) {
|
|
||||||
if (document.querySelector('.screen.active') && document.querySelector('.screen.active').id === 'player') return;
|
|
||||||
if (!window.fbNotify) return;
|
|
||||||
const msg = (sd && sd.error) ? 'Scan finished with an error' : 'Your library is up to date';
|
|
||||||
try { window.fbNotify.show({ title: 'Library scan complete', message: msg, icon: '🔄', accent: '#22C55E' }); } catch (e) { /* */ }
|
|
||||||
}
|
|
||||||
// Poll scan-status until the scan finishes, driving the button state. On
|
|
||||||
// completion, emit library:changed (grid reloads via the listener below) and,
|
|
||||||
// for a user-initiated refresh (announce), show the toast. announce:false is
|
|
||||||
// used when we only attached to a scan we didn't start.
|
|
||||||
function _watchScan(opts) {
|
|
||||||
if (_refreshPoll) return;
|
|
||||||
const announce = !opts || opts.announce !== false;
|
|
||||||
let sawRunning = false, ticks = 0;
|
|
||||||
_refreshPoll = setInterval(async () => {
|
|
||||||
ticks++;
|
|
||||||
let sd = null;
|
|
||||||
try { const r = await fetch('/api/scan-status'); if (r.ok) sd = await r.json(); } catch (e) { /* */ }
|
|
||||||
_setRefreshState(sd);
|
|
||||||
if (sd && sd.running) sawRunning = true;
|
|
||||||
// A user-initiated refresh that never saw a running scan = nothing to
|
|
||||||
// do (already up to date); give prompt feedback instead of waiting.
|
|
||||||
const noopDone = announce && !sawRunning && ticks >= 3;
|
|
||||||
if ((sawRunning && sd && !sd.running) || noopDone || ticks >= 180) {
|
|
||||||
clearInterval(_refreshPoll); _refreshPoll = null;
|
|
||||||
_setRefreshState(null);
|
|
||||||
if (sawRunning && window.feedBack) { try { window.feedBack.emit('library:changed', { reason: 'rescan' }); } catch (e) { /* */ } }
|
|
||||||
if (announce) _scanCompleteToast(sd);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
async function refreshLibrary() {
|
|
||||||
if (_refreshPoll) return; // a scan is already in progress
|
|
||||||
try { await fetch('/api/rescan', { method: 'POST' }); } catch (e) { /* */ }
|
|
||||||
_watchScan({ announce: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function watchUploadScan() {
|
function watchUploadScan() {
|
||||||
if (_uploadScanTimer) clearInterval(_uploadScanTimer);
|
if (_uploadScanTimer) clearInterval(_uploadScanTimer);
|
||||||
let sawRunning = false, ticks = 0;
|
let sawRunning = false, ticks = 0;
|
||||||
|
|||||||
+2
-23
@@ -340,9 +340,8 @@ input, textarea, select,
|
|||||||
/* — Up Next pill (top-right, persistent) — */
|
/* — Up Next pill (top-right, persistent) — */
|
||||||
#player-hud .v3-upnext {
|
#player-hud .v3-upnext {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
align-items: stretch;
|
gap: .5rem;
|
||||||
gap: .35rem;
|
|
||||||
padding: .45rem .9rem;
|
padding: .45rem .9rem;
|
||||||
border-radius: .75rem;
|
border-radius: .75rem;
|
||||||
background: rgba(15, 23, 42, .7);
|
background: rgba(15, 23, 42, .7);
|
||||||
@@ -352,26 +351,6 @@ input, textarea, select,
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
#player-hud .v3-upnext.hidden { display: none; }
|
#player-hud .v3-upnext.hidden { display: none; }
|
||||||
/* Text row keeps the original inline layout untouched. */
|
|
||||||
#player-hud .v3-upnext .v3-upnext-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: .5rem;
|
|
||||||
}
|
|
||||||
/* Progress bar under the text — fills as the current section elapses. */
|
|
||||||
#player-hud .v3-upnext .v3-upnext-bar {
|
|
||||||
height: 4px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(148, 163, 184, .25);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
#player-hud .v3-upnext .v3-upnext-bar-fill {
|
|
||||||
height: 100%;
|
|
||||||
width: 0%;
|
|
||||||
border-radius: inherit;
|
|
||||||
background: linear-gradient(90deg, #22d3ee, #a855f7, #f472b6);
|
|
||||||
transition: width .12s linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* — Live performance HUD (top-right, read-only) — */
|
/* — Live performance HUD (top-right, read-only) — */
|
||||||
.v3-live-performance-hud {
|
.v3-live-performance-hud {
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
// Pins the wide-pane horizontal-FOV-hold ("Hor+") framing in
|
|
||||||
// plugins/highway_3d/screen.js.
|
|
||||||
//
|
|
||||||
// What it guards: ultra-wide panes (top/bottom 2-player split → full-width /
|
|
||||||
// half-height → ~32:9) used to render the neck as a thin central sliver because
|
|
||||||
// THREE's PerspectiveCamera fov is VERTICAL and was locked at 70°, ballooning
|
|
||||||
// the horizontal cone past 130°. The fix lets camUpdate lower the effective
|
|
||||||
// vertical fov as the pane widens (holding the horizontal cone ~constant) so the
|
|
||||||
// neck fills the pane. It is gated behind window.__h3dAspectTune (default off →
|
|
||||||
// byte-for-byte the prior behaviour) for live A/B comparison.
|
|
||||||
//
|
|
||||||
// A refactor that re-hardcodes the camera fov, drops the change-guarded cam.fov
|
|
||||||
// write, stops caching the pane aspect, or removes the no-op-at-startAspect
|
|
||||||
// guarantee would silently regress the feature (or worse, change normal-pane
|
|
||||||
// framing). These are source-level pins — same strategy as the other
|
|
||||||
// tests/js/ files (no DOM / WebGL in CI).
|
|
||||||
|
|
||||||
const { test } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
|
|
||||||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
|
||||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
|
||||||
|
|
||||||
// ── Constants ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test('BASE_VFOV is a named constant (not a literal in the camera ctor)', () => {
|
|
||||||
assert.match(
|
|
||||||
src,
|
|
||||||
/const\s+BASE_VFOV\s*=\s*70\s*;/,
|
|
||||||
'BASE_VFOV must be declared as a constant',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the camera is constructed with BASE_VFOV, not a bare 70', () => {
|
|
||||||
assert.match(
|
|
||||||
src,
|
|
||||||
/new\s+T\.PerspectiveCamera\(\s*BASE_VFOV\s*,/,
|
|
||||||
'PerspectiveCamera must take BASE_VFOV as its vertical fov',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the Hor+ start-aspect and min-vfov defaults exist', () => {
|
|
||||||
assert.match(src, /const\s+HORPLUS_START_ASPECT\s*=\s*16\s*\/\s*9\s*;/,
|
|
||||||
'HORPLUS_START_ASPECT must default to 16/9 (no-op at/under the reference aspect)');
|
|
||||||
assert.match(src, /const\s+HORPLUS_MIN_VFOV\s*=\s*\d+\s*;/,
|
|
||||||
'HORPLUS_MIN_VFOV floor must be declared');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── effectiveVfov: no-op guarantees ──────────────────────────────────────────
|
|
||||||
|
|
||||||
test('effectiveVfov returns the base fov when the bridge is off/absent', () => {
|
|
||||||
// The disabled / malformed-input guard returns `base` before any Hor+ math,
|
|
||||||
// so normal panes are unaffected when __h3dAspectTune is missing or off.
|
|
||||||
assert.match(
|
|
||||||
src,
|
|
||||||
/function\s+effectiveVfov\s*\(\s*aspect\s*,\s*tune\s*\)\s*\{[\s\S]*?if\s*\(\s*!tune\s*\|\|\s*!tune\.enabled[\s\S]*?return\s+base\s*;/,
|
|
||||||
'effectiveVfov must short-circuit to the base fov when disabled',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('effectiveVfov is a no-op at/under the start aspect', () => {
|
|
||||||
assert.match(
|
|
||||||
src,
|
|
||||||
/if\s*\(\s*aspect\s*<=\s*start\s*\)\s*return\s+base\s*;/,
|
|
||||||
'effectiveVfov must return base when aspect <= start (no-op for normal/2x2 panes)',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── shipped defaults: off + coherent ─────────────────────────────────────────
|
|
||||||
// The "default off → byte-for-byte prior behaviour" contract only holds if the
|
|
||||||
// shipped _ASPECT_DEFAULTS actually ship disabled with a base that matches the
|
|
||||||
// camera's constructed fov. A previous revision shipped enabled:true with
|
|
||||||
// baseVfov:30 (and blend:0), which forced every pane's fov to 30/36 and
|
|
||||||
// silently re-framed normal single-player panes. These pin against that.
|
|
||||||
|
|
||||||
test('_ASPECT_DEFAULTS ships disabled (no-op out of the box)', () => {
|
|
||||||
assert.match(
|
|
||||||
src,
|
|
||||||
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\benabled\s*:\s*false\b/,
|
|
||||||
'_ASPECT_DEFAULTS.enabled must default to false so the feature is opt-in',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the default base fov matches BASE_VFOV (enabling is still a no-op on normal panes)', () => {
|
|
||||||
// baseVfov === BASE_VFOV means even with the feature ON, a <=startAspect pane
|
|
||||||
// returns the unchanged 70° — the effect is confined to genuinely wide panes.
|
|
||||||
assert.match(
|
|
||||||
src,
|
|
||||||
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\bbaseVfov\s*:\s*BASE_VFOV\b/,
|
|
||||||
'_ASPECT_DEFAULTS.baseVfov must default to BASE_VFOV, not a divergent literal',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the default blend engages the hold and the floor sits below the base', () => {
|
|
||||||
// blend:1 means turning the feature on actually holds the horizontal cone
|
|
||||||
// (blend:0 would collapse effectiveVfov back to base = feature inert), and
|
|
||||||
// minVfovDeg:HORPLUS_MIN_VFOV keeps the floor below baseVfov (a real floor,
|
|
||||||
// not one that clamps the base upward).
|
|
||||||
assert.match(
|
|
||||||
src,
|
|
||||||
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\bblend\s*:\s*1\b/,
|
|
||||||
'_ASPECT_DEFAULTS.blend must default to 1 so the Hor+ hold actually applies when enabled',
|
|
||||||
);
|
|
||||||
assert.match(
|
|
||||||
src,
|
|
||||||
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\bminVfovDeg\s*:\s*HORPLUS_MIN_VFOV\b/,
|
|
||||||
'_ASPECT_DEFAULTS.minVfovDeg must default to HORPLUS_MIN_VFOV (a floor below baseVfov)',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── camUpdate: change-guarded fov write + cached aspect ───────────────────────
|
|
||||||
|
|
||||||
test('applySize caches the pane aspect for camUpdate', () => {
|
|
||||||
assert.match(
|
|
||||||
src,
|
|
||||||
/_paneAspect\s*=\s*cam\.aspect\s*;/,
|
|
||||||
'applySize must cache cam.aspect into _paneAspect',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('camUpdate reads the live tune bridge and respects splitOnly', () => {
|
|
||||||
assert.match(
|
|
||||||
src,
|
|
||||||
/const\s+_aspTune\s*=\s*_aspectTune\(\)\s*;[\s\S]*?_aspTune\.splitOnly\s*&&\s*!_ssActive\(\)/,
|
|
||||||
'camUpdate must read the bridge via _aspectTune() and gate splitOnly on _ssActive()',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the tune bridge seeds from localStorage (persisted sessions apply on load)', () => {
|
|
||||||
assert.match(
|
|
||||||
src,
|
|
||||||
/function\s+_aspectTune\s*\(\)[\s\S]*?localStorage\.getItem\(\s*_ASPECT_LS\s*\)/,
|
|
||||||
'_aspectTune() must seed the bridge from localStorage',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a floating tuner panel is built and toggled with the A/B state', () => {
|
|
||||||
assert.match(src, /function\s+_ensureAspectPanel\s*\(\)/,
|
|
||||||
'_ensureAspectPanel() must exist to build the live panel');
|
|
||||||
assert.match(src, /function\s+_setAspectPanelVisible\s*\(/,
|
|
||||||
'_setAspectPanelVisible() must show/hide the panel with the feature');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('camUpdate only writes cam.fov when it actually changes', () => {
|
|
||||||
// Guarding the write avoids a per-frame updateProjectionMatrix on a steady
|
|
||||||
// pane and keeps the disabled path free.
|
|
||||||
assert.match(
|
|
||||||
src,
|
|
||||||
/Math\.abs\(\s*_vfov\s*-\s*cam\.fov\s*\)\s*>\s*1e-4[\s\S]*?cam\.fov\s*=\s*_vfov\s*;[\s\S]*?cam\.updateProjectionMatrix\(\)/,
|
|
||||||
'camUpdate must guard the cam.fov write behind a change check',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── A/B toggle + lifecycle reset ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
test('an A/B toggle shortcut flips the tune enabled flag', () => {
|
|
||||||
assert.match(
|
|
||||||
src,
|
|
||||||
/registerShortcut\(\{[\s\S]*?const\s+t\s*=\s*_aspectTune\(\)\s*;[\s\S]*?t\.enabled\s*=\s*!\s*t\.enabled/,
|
|
||||||
'a registerShortcut handler must toggle the bridge enabled flag',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('destroy() resets the pane aspect and restores the base fov', () => {
|
|
||||||
assert.match(src, /_paneAspect\s*=\s*0\s*;/,
|
|
||||||
'destroy() must reset _paneAspect to 0');
|
|
||||||
assert.match(
|
|
||||||
src,
|
|
||||||
/cam\.fov\s*!==\s*BASE_VFOV[\s\S]*?cam\.fov\s*=\s*BASE_VFOV\s*;\s*cam\.updateProjectionMatrix\(\)/,
|
|
||||||
'destroy() must restore cam.fov to BASE_VFOV for instance reuse',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user