mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 04:08:33 +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",
|
"id": "drum_highway_3d",
|
||||||
"name": "3D Drum Highway",
|
"name": "3D Drum Highway",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"type": "visualization",
|
"type": "visualization",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"script": "screen.js",
|
"script": "screen.js",
|
||||||
|
|||||||
@@ -1361,6 +1361,41 @@
|
|||||||
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
} 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
|
* Renderer factory
|
||||||
* ====================================================================== */
|
* ====================================================================== */
|
||||||
@@ -1414,7 +1449,7 @@
|
|||||||
let _sparkPts = null, _sparkPos = null, _sparkCol = null, _sparkVel = null, _sparkLife = null;
|
let _sparkPts = null, _sparkPos = null, _sparkCol = null, _sparkVel = null, _sparkLife = null;
|
||||||
let _fxLastWall = 0; // wall clock for FX integration (sparks, pulse decay)
|
let _fxLastWall = 0; // wall clock for FX integration (sparks, pulse decay)
|
||||||
let _kickPulse = 0; // kick-hit camera-dip + floor-wash envelope
|
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 _gaussTex = null; // shared soft-falloff texture for flash quads
|
||||||
let _laneFlashQuads = []; // pooled additive quad per hand lane (z=0)
|
let _laneFlashQuads = []; // pooled additive quad per hand lane (z=0)
|
||||||
let _kickFlashQuad = null; // full-width flash quad for the kick bar
|
let _kickFlashQuad = null; // full-width flash quad for the kick bar
|
||||||
@@ -2651,6 +2686,48 @@
|
|||||||
cam.lookAt(0, 0, -AHEAD * TS * 0.45);
|
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) {
|
function buildLanes(_floorW, floorD) {
|
||||||
laneGroup = new T.Group();
|
laneGroup = new T.Group();
|
||||||
laneStripeMats = [];
|
laneStripeMats = [];
|
||||||
@@ -3431,15 +3508,18 @@
|
|||||||
BG_STYLES[_bgState._style].update(_bgState.s, bands, fdt, nowMs / 1000);
|
BG_STYLES[_bgState._style].update(_bgState.s, bands, fdt, nowMs / 1000);
|
||||||
} catch (_) { /* visual-only */ }
|
} catch (_) { /* visual-only */ }
|
||||||
}
|
}
|
||||||
|
// Kick pulse decays each frame; it drives the floor flash and,
|
||||||
|
// via applyCamera(), the camera Y dip.
|
||||||
if (_kickPulse > 0.001) {
|
if (_kickPulse > 0.001) {
|
||||||
_kickPulse *= Math.exp(-fdt * 7);
|
_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;
|
if (_floorFlash) _floorFlash.material.opacity = 0.25 * _kickPulse * fx.hitFx;
|
||||||
} else if (_kickPulse !== 0) {
|
} else if (_kickPulse !== 0) {
|
||||||
_kickPulse = 0;
|
_kickPulse = 0;
|
||||||
cam.position.y = _camBaseH;
|
|
||||||
if (_floorFlash) _floorFlash.material.opacity = 0;
|
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
|
// Approach highlight: raise each lane stripe toward its next
|
||||||
// note (accumulated by the rebuildNotes walk above).
|
// note (accumulated by the rebuildNotes walk above).
|
||||||
@@ -3565,6 +3645,8 @@
|
|||||||
// vm-loaded with no DOM/WebGL; everything here must stay side-effect
|
// vm-loaded with no DOM/WebGL; everything here must stay side-effect
|
||||||
// free to call).
|
// free to call).
|
||||||
window.slopsmithViz_drum_highway_3d.__test = {
|
window.slopsmithViz_drum_highway_3d.__test = {
|
||||||
|
_resolveFreeCam,
|
||||||
|
_ssApi,
|
||||||
_variantForHit,
|
_variantForHit,
|
||||||
_classifyTiming,
|
_classifyTiming,
|
||||||
readFxSettings,
|
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",
|
"id": "highway_3d",
|
||||||
"name": "3D Highway",
|
"name": "3D Highway",
|
||||||
"version": "3.31.3",
|
"version": "3.31.4",
|
||||||
"type": "visualization",
|
"type": "visualization",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"script": "screen.js",
|
"script": "screen.js",
|
||||||
|
|||||||
@@ -2595,10 +2595,51 @@
|
|||||||
}
|
}
|
||||||
const FRET_NUMBER_GHOST_SCOPE_IDS = ['chords', 'all'];
|
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) {
|
function _bgPanelKey(canvas) {
|
||||||
const ss = window.feedBackSplitscreen;
|
const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen;
|
||||||
const idx = (ss && typeof ss.panelIndexFor === 'function') ? ss.panelIndexFor(canvas) : null;
|
let idx = null;
|
||||||
return (idx == null) ? 'main' : 'panel' + idx;
|
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,
|
// In-memory fallback for when localStorage is blocked (private mode,
|
||||||
// sandboxed iframes, some test runners). _bgWriteGlobal stages the
|
// sandboxed iframes, some test runners). _bgWriteGlobal stages the
|
||||||
@@ -14664,7 +14705,10 @@
|
|||||||
// suppressed while the Camera Director owns the view (it wins).
|
// suppressed while the Camera Director owns the view (it wins).
|
||||||
const _startAspect = (_tune && Number.isFinite(_tune.startAspect) && _tune.startAspect > 0)
|
const _startAspect = (_tune && Number.isFinite(_tune.startAspect) && _tune.startAspect > 0)
|
||||||
? _tune.startAspect : HORPLUS_START_ASPECT;
|
? _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 _wide = !!(_tune && _paneAspect > _startAspect) && !_dirActive;
|
||||||
const _poseHMul = (_wide && Number.isFinite(_tune.heightMul)) ? _tune.heightMul : 1;
|
const _poseHMul = (_wide && Number.isFinite(_tune.heightMul)) ? _tune.heightMul : 1;
|
||||||
const _poseDMul = (_wide && Number.isFinite(_tune.distMul)) ? _tune.distMul : 1;
|
const _poseDMul = (_wide && Number.isFinite(_tune.distMul)) ? _tune.distMul : 1;
|
||||||
@@ -14691,13 +14735,16 @@
|
|||||||
if (_poseHMul !== 1) _camY *= _poseHMul;
|
if (_poseHMul !== 1) _camY *= _poseHMul;
|
||||||
if (_poseDMul !== 1) _camZ *= _poseDMul;
|
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 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.
|
// Layered ON TOP of the auto-framing so note tracking still works.
|
||||||
// The bridge is read once into _freeCam and reused for both the
|
// The bridge is read once into _freeCam and reused for both the
|
||||||
// position and the look-at transforms; every field is coerced to a
|
// position and the look-at transforms; every field is coerced to a
|
||||||
// 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;
|
// _freeCam resolved above via _freeCamFor(highwayCanvas): the
|
||||||
|
// per-panel __h3dCamCtlPanels entry, else global __h3dCamCtl, else null.
|
||||||
const _lookAtZ = -FOCUS_D * 0.35 * _poseLookZMul;
|
const _lookAtZ = -FOCUS_D * 0.35 * _poseLookZMul;
|
||||||
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;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "keys_highway_3d",
|
"id": "keys_highway_3d",
|
||||||
"name": "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.",
|
"description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.",
|
||||||
"type": "visualization",
|
"type": "visualization",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
|
|||||||
@@ -1600,6 +1600,41 @@
|
|||||||
_aiRegisteredCount = 0;
|
_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
|
* Renderer factory
|
||||||
* ====================================================================== */
|
* ====================================================================== */
|
||||||
@@ -1849,6 +1884,18 @@
|
|||||||
_rigOut.lookZ = _camPreset.lookZ;
|
_rigOut.lookZ = _camPreset.lookZ;
|
||||||
return _rigOut;
|
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
|
// 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.
|
// 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
|
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;
|
_camX += (_camTargetX - _camX) * CAM_PAN_LERP;
|
||||||
_camZoom += (_camTargetZoom - _camZoom) * CAM_ZOOM_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 km of keyMeshes.values()) km.userData.glow = 0;
|
||||||
for (const { mesh, note, len, label } of noteMeshes) {
|
for (const { mesh, note, len, label } of noteMeshes) {
|
||||||
@@ -3960,6 +4033,8 @@
|
|||||||
};
|
};
|
||||||
// Pure data-layer + scoring hooks for headless tests.
|
// Pure data-layer + scoring hooks for headless tests.
|
||||||
window.slopsmithViz_keys_highway_3d.__test = {
|
window.slopsmithViz_keys_highway_3d.__test = {
|
||||||
|
_resolveFreeCam,
|
||||||
|
_ssApi,
|
||||||
beatDurSec,
|
beatDurSec,
|
||||||
flattenNotation,
|
flattenNotation,
|
||||||
keyRange,
|
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