mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 04:04:30 +00:00
Merge pull request #823 from got-feedBack/feat/highway-per-panel-camera
feat(highways): per-splitscreen-panel Camera Director cameras
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "drum_highway_3d",
|
||||
"name": "3D Drum Highway",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
|
||||
@@ -1361,6 +1361,41 @@
|
||||
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
||||
};
|
||||
|
||||
/* ======================================================================
|
||||
* Camera Director bridge resolver (pure — exported via createFactory.__test)
|
||||
* ====================================================================== */
|
||||
|
||||
/**
|
||||
* The active splitscreen API, defensive on the global-name rename in flight
|
||||
* (feedBackSplitscreen is canonical; slopsmithSplitscreen is the legacy alias).
|
||||
* @returns {object|null} the splitscreen API, or null when not present
|
||||
*/
|
||||
function _ssApi() { return window.feedBackSplitscreen || window.slopsmithSplitscreen || null; }
|
||||
|
||||
/**
|
||||
* Resolve the Camera Director camera for a canvas: this panel's camera under
|
||||
* splitscreen, else the global, else null (Camera Director absent → stock
|
||||
* framing). Throw-safe on panelIndexFor so a misbehaving splitscreen build
|
||||
* can't break framing.
|
||||
* @param {HTMLCanvasElement} canvas this renderer's highway canvas
|
||||
* @param {object|null} ss the splitscreen API (see _ssApi)
|
||||
* @param {object|null} panelsMap window.__h3dCamCtlPanels (per-panel cameras by index)
|
||||
* @param {object|null} globalCam window.__h3dCamCtl (single global camera)
|
||||
* @returns {object|null} the resolved free-camera bridge, or null
|
||||
*/
|
||||
function _resolveFreeCam(canvas, ss, panelsMap, globalCam) {
|
||||
if (panelsMap && ss && typeof ss.panelIndexFor === 'function') {
|
||||
try {
|
||||
const i = ss.panelIndexFor(canvas);
|
||||
// Only a non-negative integer indexes the panel map — a non-int /
|
||||
// negative / string index (or a prototype key) must not resolve an
|
||||
// unintended/inherited property; fall through to the global then.
|
||||
if (Number.isInteger(i) && i >= 0 && panelsMap[i]) return panelsMap[i];
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
return globalCam || null;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Renderer factory
|
||||
* ====================================================================== */
|
||||
@@ -1414,7 +1449,7 @@
|
||||
let _sparkPts = null, _sparkPos = null, _sparkCol = null, _sparkVel = null, _sparkLife = null;
|
||||
let _fxLastWall = 0; // wall clock for FX integration (sparks, pulse decay)
|
||||
let _kickPulse = 0; // kick-hit camera-dip + floor-wash envelope
|
||||
let _camBaseH = 0, _camBaseD = 0; // positionCamera's unpulsed pose
|
||||
let _camBaseH = null, _camBaseD = null; // positionCamera's unpulsed pose (null until it first runs; applyCamera's guard depends on this)
|
||||
let _gaussTex = null; // shared soft-falloff texture for flash quads
|
||||
let _laneFlashQuads = []; // pooled additive quad per hand lane (z=0)
|
||||
let _kickFlashQuad = null; // full-width flash quad for the kick bar
|
||||
@@ -2651,6 +2686,48 @@
|
||||
cam.lookAt(0, 0, -AHEAD * TS * 0.45);
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera Director bridge for THIS panel — delegates to the pure, unit-
|
||||
* tested _resolveFreeCam / _ssApi (resolver block above the factory).
|
||||
* Reads the live globals: per-panel map __h3dCamCtlPanels → this panel's
|
||||
* camera, else the global __h3dCamCtl, else null (stock framing).
|
||||
* @param {HTMLCanvasElement} canvas this panel's highway canvas
|
||||
* @returns {object|null} the resolved free-camera bridge, or null
|
||||
*/
|
||||
function _freeCamFor(canvas) {
|
||||
return _resolveFreeCam(canvas, _ssApi(), window.__h3dCamCtlPanels, window.__h3dCamCtl);
|
||||
}
|
||||
|
||||
// Per-frame camera write: static base pose (positionCamera) + kick-pulse Y
|
||||
// dip, then layer Camera Director free-cam offsets (dolly/height/orbit on
|
||||
// the camera-from-target vector; pan/pitch on the look target). Runs every
|
||||
// frame so a live free-cam drag is smooth; allocation-free; NaN-safe; a
|
||||
// null/disabled bridge reproduces the stock static+pulse pose exactly.
|
||||
function applyCamera() {
|
||||
if (_camBaseH == null) return; // before first positionCamera()
|
||||
const _dip = (_kickPulse > 0.001) ? (0.8 * K * _kickPulse * fx.hitFx) : 0;
|
||||
let _cx = 0, _cy = _camBaseH - _dip, _cz = _camBaseD;
|
||||
let _lx = 0, _ly = 0, _lz = -AHEAD * TS * 0.45;
|
||||
const _fc = _freeCamFor(highwayCanvas);
|
||||
if (_fc && _fc.enabled) {
|
||||
const _dm = Number.isFinite(_fc.distMul) ? _fc.distMul : 1;
|
||||
const _hm = Number.isFinite(_fc.heightMul) ? _fc.heightMul : 1;
|
||||
const _yaw = Number.isFinite(_fc.yaw) ? _fc.yaw : 0;
|
||||
let _vx = _cx - _lx, _vy = _cy - _ly, _vz = _cz - _lz;
|
||||
_vx *= _dm; _vy *= _dm; _vz *= _dm; // dolly (zoom)
|
||||
_vy *= _hm; // height
|
||||
const _cyw = Math.cos(_yaw), _syw = Math.sin(_yaw);
|
||||
const _rx = _vx * _cyw - _vz * _syw, _rz = _vx * _syw + _vz * _cyw; // orbit around Y
|
||||
_cx = _lx + _rx; _cy = _ly + _vy; _cz = _lz + _rz;
|
||||
const _px = Number.isFinite(_fc.panX) ? _fc.panX : 0;
|
||||
const _py = Number.isFinite(_fc.panY) ? _fc.panY : 0;
|
||||
const _pt = Number.isFinite(_fc.pitch) ? _fc.pitch : 0;
|
||||
_lx += _px * K; _ly += (_pt + _py) * K;
|
||||
}
|
||||
cam.position.set(_cx, _cy, _cz);
|
||||
cam.lookAt(_lx, _ly, _lz);
|
||||
}
|
||||
|
||||
function buildLanes(_floorW, floorD) {
|
||||
laneGroup = new T.Group();
|
||||
laneStripeMats = [];
|
||||
@@ -3431,15 +3508,18 @@
|
||||
BG_STYLES[_bgState._style].update(_bgState.s, bands, fdt, nowMs / 1000);
|
||||
} catch (_) { /* visual-only */ }
|
||||
}
|
||||
// Kick pulse decays each frame; it drives the floor flash and,
|
||||
// via applyCamera(), the camera Y dip.
|
||||
if (_kickPulse > 0.001) {
|
||||
_kickPulse *= Math.exp(-fdt * 7);
|
||||
cam.position.y = _camBaseH - 0.8 * K * _kickPulse * fx.hitFx;
|
||||
if (_floorFlash) _floorFlash.material.opacity = 0.25 * _kickPulse * fx.hitFx;
|
||||
} else if (_kickPulse !== 0) {
|
||||
_kickPulse = 0;
|
||||
cam.position.y = _camBaseH;
|
||||
if (_floorFlash) _floorFlash.material.opacity = 0;
|
||||
}
|
||||
// Write the camera every frame: static base pose + kick dip +
|
||||
// Camera Director free-cam offsets (per-panel-aware).
|
||||
applyCamera();
|
||||
}
|
||||
// Approach highlight: raise each lane stripe toward its next
|
||||
// note (accumulated by the rebuildNotes walk above).
|
||||
@@ -3565,6 +3645,8 @@
|
||||
// vm-loaded with no DOM/WebGL; everything here must stay side-effect
|
||||
// free to call).
|
||||
window.slopsmithViz_drum_highway_3d.__test = {
|
||||
_resolveFreeCam,
|
||||
_ssApi,
|
||||
_variantForHit,
|
||||
_classifyTiming,
|
||||
readFxSettings,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Camera Director bridge resolver tests: per-panel select, global fallback,
|
||||
// null-when-absent, throw-safety, and the splitscreen global-name alias. Loads
|
||||
// screen.js in a bare vm window and exercises the __test exports (no DOM/WebGL).
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
function load() {
|
||||
const window = {
|
||||
console,
|
||||
location: { protocol: 'http:', host: 'localhost' },
|
||||
slopsmith: {},
|
||||
};
|
||||
window.window = window;
|
||||
window.globalThis = window;
|
||||
const context = vm.createContext(window);
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
|
||||
vm.runInContext(src, context, { filename: 'screen.js' });
|
||||
return { window, __test: window.slopsmithViz_drum_highway_3d.__test };
|
||||
}
|
||||
|
||||
test('_resolveFreeCam: per-panel camera under splitscreen', () => {
|
||||
const { __test } = load();
|
||||
const c0 = {}, c1 = {};
|
||||
const ss = { panelIndexFor: (c) => (c === c0 ? 0 : 1) };
|
||||
const map = { 0: { id: 'p0' }, 1: { id: 'p1' } };
|
||||
assert.equal(__test._resolveFreeCam(c0, ss, map, { id: 'g' }).id, 'p0');
|
||||
assert.equal(__test._resolveFreeCam(c1, ss, map, { id: 'g' }).id, 'p1');
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: falls back to global when there is no panel map', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, null, g), g);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: falls back to global when the panel has no map entry', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
const ss = { panelIndexFor: () => 3 }; // index 3 absent from map
|
||||
assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: null when Camera Director is absent (no global)', () => {
|
||||
const { __test } = load();
|
||||
assert.equal(__test._resolveFreeCam({}, null, null, null), null);
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, {}, undefined), null);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: throw-safe on panelIndexFor → falls back to global', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
const ss = { panelIndexFor: () => { throw new Error('boom'); } };
|
||||
assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: NaN/negative/float/string index → falls back to global', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => NaN }, { 0: {} }, g), g);
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => -1 }, { 0: {} }, g), g);
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0.5 }, { 0: {} }, g), g);
|
||||
// A string/prototype key must not resolve an inherited property (e.g. toString).
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 'toString' }, {}, g), g);
|
||||
});
|
||||
|
||||
test('_ssApi: null when neither global set; slopsmith alias; feedBack canonical wins', () => {
|
||||
const { window, __test } = load();
|
||||
assert.equal(__test._ssApi(), null);
|
||||
const legacy = { panelIndexFor: () => 0 };
|
||||
window.slopsmithSplitscreen = legacy;
|
||||
assert.equal(__test._ssApi(), legacy); // legacy alias picked up
|
||||
const current = { panelIndexFor: () => 1 };
|
||||
window.feedBackSplitscreen = current;
|
||||
assert.equal(__test._ssApi(), current); // canonical name takes precedence
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "highway_3d",
|
||||
"name": "3D Highway",
|
||||
"version": "3.31.3",
|
||||
"version": "3.31.4",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
|
||||
@@ -2595,10 +2595,51 @@
|
||||
}
|
||||
const FRET_NUMBER_GHOST_SCOPE_IDS = ['chords', 'all'];
|
||||
|
||||
/**
|
||||
* localStorage panel key for per-panel background settings ('main' or
|
||||
* 'panel<index>'). Defensive on the splitscreen global-name rename in flight,
|
||||
* and throw-safe on panelIndexFor — same as _freeCamFor — so a misbehaving
|
||||
* splitscreen build can't take down background-settings resolution. Only a
|
||||
* non-negative integer index yields a 'panel<N>' key; anything else (null,
|
||||
* NaN, negative, non-integer) falls back to 'main' so a bad index can never
|
||||
* mint a bogus "panelNaN"-style key.
|
||||
* @param {HTMLCanvasElement} canvas this renderer's highway canvas
|
||||
* @returns {string} 'main' or 'panel<index>'
|
||||
*/
|
||||
function _bgPanelKey(canvas) {
|
||||
const ss = window.feedBackSplitscreen;
|
||||
const idx = (ss && typeof ss.panelIndexFor === 'function') ? ss.panelIndexFor(canvas) : null;
|
||||
return (idx == null) ? 'main' : 'panel' + idx;
|
||||
const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen;
|
||||
let idx = null;
|
||||
if (ss && typeof ss.panelIndexFor === 'function') {
|
||||
try { idx = ss.panelIndexFor(canvas); } catch (e) { idx = null; }
|
||||
}
|
||||
return (Number.isInteger(idx) && idx >= 0) ? 'panel' + idx : 'main';
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera Director bridge resolver. Prefers THIS panel's per-panel camera under
|
||||
* splitscreen (window.__h3dCamCtlPanels[panelIndex]) and falls back to the
|
||||
* single global (window.__h3dCamCtl); returns null when Camera Director is
|
||||
* absent → 100% stock framing. Defensive on the splitscreen global-name rename
|
||||
* in flight (feedBackSplitscreen vs slopsmithSplitscreen); throw-safe on
|
||||
* panelIndexFor. Mirrors the panel resolution in _bgPanelKey.
|
||||
* @param {HTMLCanvasElement} canvas this renderer's highway canvas
|
||||
* @returns {object|null} the resolved free-camera bridge, or null
|
||||
*/
|
||||
function _freeCamFor(canvas) {
|
||||
const map = window.__h3dCamCtlPanels;
|
||||
if (map) {
|
||||
const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen;
|
||||
if (ss && typeof ss.panelIndexFor === 'function') {
|
||||
try {
|
||||
const i = ss.panelIndexFor(canvas);
|
||||
// Only a non-negative integer indexes the map (same hardening
|
||||
// as _bgPanelKey) — a non-int / negative / string index must not
|
||||
// resolve an unintended/inherited property; fall through then.
|
||||
if (Number.isInteger(i) && i >= 0 && map[i]) return map[i];
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return window.__h3dCamCtl || null;
|
||||
}
|
||||
// In-memory fallback for when localStorage is blocked (private mode,
|
||||
// sandboxed iframes, some test runners). _bgWriteGlobal stages the
|
||||
@@ -14664,7 +14705,10 @@
|
||||
// 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);
|
||||
// Resolve the Camera Director bridge once (per-panel under splitscreen,
|
||||
// else global). Used both for the wide-pane gate and the transforms below.
|
||||
const _freeCam = _freeCamFor(highwayCanvas);
|
||||
const _dirActive = !!(_freeCam && _freeCam.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;
|
||||
@@ -14691,13 +14735,16 @@
|
||||
if (_poseHMul !== 1) _camY *= _poseHMul;
|
||||
if (_poseDMul !== 1) _camZ *= _poseDMul;
|
||||
// ── Free-camera user tweaks (orbit / height / zoom / pan) ──
|
||||
// Driven by the Camera Director plugin via window.__h3dCamCtl.
|
||||
// Driven by the Camera Director plugin via the camera bridge:
|
||||
// window.__h3dCamCtlPanels[panelIndexFor(canvas)] when split (this
|
||||
// panel's own camera), falling back to the global window.__h3dCamCtl.
|
||||
// Layered ON TOP of the auto-framing so note tracking still works.
|
||||
// The bridge is read once into _freeCam and reused for both the
|
||||
// position and the look-at transforms; every field is coerced to a
|
||||
// finite number before use so a malformed object can never feed NaN
|
||||
// into cam.position / cam.lookAt.
|
||||
const _freeCam = window.__h3dCamCtl;
|
||||
// _freeCam resolved above via _freeCamFor(highwayCanvas): the
|
||||
// per-panel __h3dCamCtlPanels entry, else global __h3dCamCtl, else null.
|
||||
const _lookAtZ = -FOCUS_D * 0.35 * _poseLookZMul;
|
||||
if (_freeCam && _freeCam.enabled) {
|
||||
const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "keys_highway_3d",
|
||||
"name": "Keys Highway 3D",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
|
||||
@@ -1600,6 +1600,41 @@
|
||||
_aiRegisteredCount = 0;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Camera Director bridge resolver (pure — exported via createFactory.__test)
|
||||
* ====================================================================== */
|
||||
|
||||
/**
|
||||
* The active splitscreen API, defensive on the global-name rename in flight
|
||||
* (feedBackSplitscreen is canonical; slopsmithSplitscreen is the legacy alias).
|
||||
* @returns {object|null} the splitscreen API, or null when not present
|
||||
*/
|
||||
function _ssApi() { return window.feedBackSplitscreen || window.slopsmithSplitscreen || null; }
|
||||
|
||||
/**
|
||||
* Resolve the Camera Director camera for a canvas: this panel's camera under
|
||||
* splitscreen, else the global, else null (Camera Director absent → 100% stock
|
||||
* framing). Throw-safe on panelIndexFor so a misbehaving splitscreen build
|
||||
* can't break framing.
|
||||
* @param {HTMLCanvasElement} canvas this renderer's highway canvas
|
||||
* @param {object|null} ss the splitscreen API (see _ssApi)
|
||||
* @param {object|null} panelsMap window.__h3dCamCtlPanels (per-panel cameras by index)
|
||||
* @param {object|null} globalCam window.__h3dCamCtl (single global camera)
|
||||
* @returns {object|null} the resolved free-camera bridge, or null
|
||||
*/
|
||||
function _resolveFreeCam(canvas, ss, panelsMap, globalCam) {
|
||||
if (panelsMap && ss && typeof ss.panelIndexFor === 'function') {
|
||||
try {
|
||||
const i = ss.panelIndexFor(canvas);
|
||||
// Only a non-negative integer indexes the panel map — a non-int /
|
||||
// negative / string index (or a prototype key) must not resolve an
|
||||
// unintended/inherited property; fall through to the global then.
|
||||
if (Number.isInteger(i) && i >= 0 && panelsMap[i]) return panelsMap[i];
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
return globalCam || null;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Renderer factory
|
||||
* ====================================================================== */
|
||||
@@ -1849,6 +1884,18 @@
|
||||
_rigOut.lookZ = _camPreset.lookZ;
|
||||
return _rigOut;
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera Director bridge for THIS panel — delegates to the pure, unit-
|
||||
* tested _resolveFreeCam / _ssApi (resolver block above the factory).
|
||||
* Reads the live globals: per-panel map __h3dCamCtlPanels → this panel's
|
||||
* camera, else the global __h3dCamCtl, else null (stock framing).
|
||||
* @param {HTMLCanvasElement} canvas this panel's highway canvas
|
||||
* @returns {object|null} the resolved free-camera bridge, or null
|
||||
*/
|
||||
function _freeCamFor(canvas) {
|
||||
return _resolveFreeCam(canvas, _ssApi(), window.__h3dCamCtlPanels, window.__h3dCamCtl);
|
||||
}
|
||||
// Per-key approach glow: a key lights in its pitch-class color ONLY while a
|
||||
// note is heading for it, ramping up the closer that note gets to the hit-line.
|
||||
const KEY_GLOW_AHEAD = 2.0; // seconds before the hit-line a key starts to light
|
||||
@@ -3259,7 +3306,33 @@
|
||||
}
|
||||
_camX += (_camTargetX - _camX) * CAM_PAN_LERP;
|
||||
_camZoom += (_camTargetZoom - _camZoom) * CAM_ZOOM_LERP;
|
||||
{ const r = _rig(); cam.position.set(_camX, r.y * K * _camZoom, r.z * K * _camZoom); cam.lookAt(_camX, r.lookY * K * _camZoom, r.lookZ * K * _camZoom); }
|
||||
{
|
||||
const r = _rig();
|
||||
let _cx = _camX, _cy = r.y * K * _camZoom, _cz = r.z * K * _camZoom;
|
||||
let _lx = _camX, _ly = r.lookY * K * _camZoom, _lz = r.lookZ * K * _camZoom;
|
||||
// Camera Director free-cam offsets (per-panel-aware), layered on top
|
||||
// of the auto-framing so pan/zoom-follow still works. Dolly/height/
|
||||
// orbit act on the camera-from-target vector; pan/pitch shift the
|
||||
// look target. NaN-safe; null/disabled bridge → stock.
|
||||
const _fc = _freeCamFor(highwayCanvas);
|
||||
if (_fc && _fc.enabled) {
|
||||
const _dm = Number.isFinite(_fc.distMul) ? _fc.distMul : 1;
|
||||
const _hm = Number.isFinite(_fc.heightMul) ? _fc.heightMul : 1;
|
||||
const _yaw = Number.isFinite(_fc.yaw) ? _fc.yaw : 0;
|
||||
let _vx = _cx - _lx, _vy = _cy - _ly, _vz = _cz - _lz;
|
||||
_vx *= _dm; _vy *= _dm; _vz *= _dm; // dolly (zoom)
|
||||
_vy *= _hm; // height
|
||||
const _cyw = Math.cos(_yaw), _syw = Math.sin(_yaw);
|
||||
const _rx = _vx * _cyw - _vz * _syw, _rz = _vx * _syw + _vz * _cyw; // orbit around Y
|
||||
_cx = _lx + _rx; _cy = _ly + _vy; _cz = _lz + _rz;
|
||||
const _px = Number.isFinite(_fc.panX) ? _fc.panX : 0;
|
||||
const _py = Number.isFinite(_fc.panY) ? _fc.panY : 0;
|
||||
const _pt = Number.isFinite(_fc.pitch) ? _fc.pitch : 0;
|
||||
_lx += _px * K; _ly += (_pt + _py) * K;
|
||||
}
|
||||
cam.position.set(_cx, _cy, _cz);
|
||||
cam.lookAt(_lx, _ly, _lz);
|
||||
}
|
||||
|
||||
for (const km of keyMeshes.values()) km.userData.glow = 0;
|
||||
for (const { mesh, note, len, label } of noteMeshes) {
|
||||
@@ -3960,6 +4033,8 @@
|
||||
};
|
||||
// Pure data-layer + scoring hooks for headless tests.
|
||||
window.slopsmithViz_keys_highway_3d.__test = {
|
||||
_resolveFreeCam,
|
||||
_ssApi,
|
||||
beatDurSec,
|
||||
flattenNotation,
|
||||
keyRange,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Camera Director bridge resolver tests: per-panel select, global fallback,
|
||||
// null-when-absent, throw-safety, and the splitscreen global-name alias. Loads
|
||||
// screen.js in a bare vm window and exercises the __test exports (no DOM/WebGL).
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
function load() {
|
||||
const window = {
|
||||
console,
|
||||
location: { protocol: 'http:', host: 'localhost' },
|
||||
slopsmith: {},
|
||||
};
|
||||
window.window = window;
|
||||
window.globalThis = window;
|
||||
const context = vm.createContext(window);
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
|
||||
vm.runInContext(src, context, { filename: 'screen.js' });
|
||||
return { window, __test: window.slopsmithViz_keys_highway_3d.__test };
|
||||
}
|
||||
|
||||
test('_resolveFreeCam: per-panel camera under splitscreen', () => {
|
||||
const { __test } = load();
|
||||
const c0 = {}, c1 = {};
|
||||
const ss = { panelIndexFor: (c) => (c === c0 ? 0 : 1) };
|
||||
const map = { 0: { id: 'p0' }, 1: { id: 'p1' } };
|
||||
assert.equal(__test._resolveFreeCam(c0, ss, map, { id: 'g' }).id, 'p0');
|
||||
assert.equal(__test._resolveFreeCam(c1, ss, map, { id: 'g' }).id, 'p1');
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: falls back to global when there is no panel map', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, null, g), g);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: falls back to global when the panel has no map entry', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
const ss = { panelIndexFor: () => 3 }; // index 3 absent from map
|
||||
assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: null when Camera Director is absent (no global)', () => {
|
||||
const { __test } = load();
|
||||
assert.equal(__test._resolveFreeCam({}, null, null, null), null);
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, {}, undefined), null);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: throw-safe on panelIndexFor → falls back to global', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
const ss = { panelIndexFor: () => { throw new Error('boom'); } };
|
||||
assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: NaN/negative/float/string index → falls back to global', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => NaN }, { 0: {} }, g), g);
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => -1 }, { 0: {} }, g), g);
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0.5 }, { 0: {} }, g), g);
|
||||
// A string/prototype key must not resolve an inherited property (e.g. toString).
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 'toString' }, {}, g), g);
|
||||
});
|
||||
|
||||
test('_ssApi: null when neither global set; slopsmith alias; feedBack canonical wins', () => {
|
||||
const { window, __test } = load();
|
||||
assert.equal(__test._ssApi(), null);
|
||||
const legacy = { panelIndexFor: () => 0 };
|
||||
window.slopsmithSplitscreen = legacy;
|
||||
assert.equal(__test._ssApi(), legacy); // legacy alias picked up
|
||||
const current = { panelIndexFor: () => 1 };
|
||||
window.feedBackSplitscreen = current;
|
||||
assert.equal(__test._ssApi(), current); // canonical name takes precedence
|
||||
});
|
||||
Reference in New Issue
Block a user