mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 04:28:30 +00:00
refactor(h3d-carve-8): extract Q-helpers (lighting/FX) → src/fx.js
Six functions extracted from Q-section of screen.js into a new
src/fx.js ES module behind a createFx({...}) factory:
_h3dHexOrDefault, _applyCinematic, _timingHex,
_sparkBurst, _sparkUpdate, _bloomEnsure
buildBoard (330 lines) deferred to plan §3 row 16 (P-section cut):
its write-back surface spans 9 factory-scope vars owned by P/U/Y —
premature extraction would require ~50 DI params. See plans/highway3d-carve.md.
DI surface (26 beyond-subst rewires):
BG_DEFAULTS, K — plain IIFE-scope constants
getT — live-accessor (Three.js, lazy)
getAmbLight/getDirLight/getCinematic/getTimingFx — live-accessors
getSparkPts/.../getSparkN — live-accessors (per-call, not init-cached)
setSparkPts/setSparkPos/setSparkVel/setSparkCol/setSparkLife — setters
getComposer/setComposer — getter+setter (_bloomEnsure lazy reassigns)
getBloomLoad/setBloomLoad/getBloomPass/setBloomPass — getter+setter pairs
getBloomW/setBloomW/getBloomH/setBloomH — getter+setter pairs
getRen/getScene/getCam/getHighwayCanvas — live-accessors (null pre-init)
canvasSize — factory function ref (stable)
Class-killers (all mutations verified RED before commit):
- _timingHex EARLY branch removed → RED
- _sparkBurst stale init-time sparkPos cache → RED (live-accessor required)
- setBloomLoad silenced → RED (synchronous write-back path)
- setComposer getComposer init-cached → RED (live getComposer() required)
GAP declared: setComposer(comp) inside .then() (async, no import() mock)
Tests: 1266/1268 (2 pre-existing failures unrelated to this cut)
plugin.json: 3.43.0 → 3.44.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
4b87db0c17
commit
9b98d78cbe
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "highway_3d",
|
"id": "highway_3d",
|
||||||
"name": "3D Highway",
|
"name": "3D Highway",
|
||||||
"version": "3.43.0",
|
"version": "3.44.0",
|
||||||
"type": "visualization",
|
"type": "visualization",
|
||||||
"scriptType": "module",
|
"scriptType": "module",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { _bcIsDesktop, _bcCreateController, _bcLoadSettings, _bcFfIdx } from './
|
|||||||
import { createBgControl } from './src/bg-control.js'; // h3d-carve-5
|
import { createBgControl } from './src/bg-control.js'; // h3d-carve-5
|
||||||
import { createMaterialBuilders } from './src/materials.js'; // h3d-carve-6
|
import { createMaterialBuilders } from './src/materials.js'; // h3d-carve-6
|
||||||
import { createOverlay } from './src/overlay.js'; // h3d-carve-7
|
import { createOverlay } from './src/overlay.js'; // h3d-carve-7
|
||||||
|
import { createFx } from './src/fx.js'; // h3d-carve-8
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -6378,93 +6379,26 @@ import { createOverlay } from './src/overlay.js'; // h3d-carve-7
|
|||||||
else _laneTargetColor = new T.Color(laneLit);
|
else _laneTargetColor = new T.Color(laneLit);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Fretboard (static geometry) ────────────────────────────────── */
|
/* ── h3d-carve-8: Q-helpers (lighting/FX) → src/fx.js ─────────── */
|
||||||
function _h3dHexOrDefault(hexStr, defHex) {
|
/* buildBoard deferred: see plans/highway3d-carve.md §3 row 16. */
|
||||||
const d = defHex || BG_DEFAULTS.nutColor;
|
const { _h3dHexOrDefault, _applyCinematic, _timingHex, _sparkBurst, _sparkUpdate, _bloomEnsure } = createFx({
|
||||||
const s = (typeof hexStr === 'string' && /^#[0-9a-fA-F]{6}$/.test(hexStr.trim()))
|
BG_DEFAULTS, K,
|
||||||
? hexStr.trim().toLowerCase()
|
getT: () => T,
|
||||||
: d;
|
getAmbLight: () => ambLight, getDirLight: () => dirLight, getCinematic: () => _cinematic,
|
||||||
return parseInt(s.slice(1), 16);
|
getTimingFx: () => _timingFx,
|
||||||
}
|
getSparkPts: () => _sparkPts, setSparkPts: (v) => { _sparkPts = v; }, getSparkN: () => _SPARK_N,
|
||||||
// Cinematic lighting (#2): darken ambient so emissive gems have a dark
|
getSparkPos: () => _sparkPos, setSparkPos: (v) => { _sparkPos = v; },
|
||||||
// surround to pop against; strengthen the key light for modelling.
|
getSparkVel: () => _sparkVel, setSparkVel: (v) => { _sparkVel = v; },
|
||||||
// Toggle via the 'cinematic' setting so it's directly comparable.
|
getSparkCol: () => _sparkCol, setSparkCol: (v) => { _sparkCol = v; },
|
||||||
function _applyCinematic() {
|
getSparkLife: () => _sparkLife, setSparkLife: (v) => { _sparkLife = v; },
|
||||||
if (!ambLight || !dirLight) return;
|
getComposer: () => _composer, setComposer: (v) => { _composer = v; },
|
||||||
ambLight.intensity = _cinematic ? 0.45 : 0.85;
|
getBloomLoad: () => _bloomLoad, setBloomLoad: (v) => { _bloomLoad = v; },
|
||||||
dirLight.intensity = _cinematic ? 1.15 : 0.8;
|
getBloomPass: () => _bloomPass, setBloomPass: (v) => { _bloomPass = v; },
|
||||||
}
|
getBloomW: () => _bloomW, setBloomW: (v) => { _bloomW = v; },
|
||||||
// #5 early/late: tint the hit feedback by timing — on-time green, early cyan,
|
getBloomH: () => _bloomH, setBloomH: (v) => { _bloomH = v; },
|
||||||
// late amber. Falls back to green when timing is unknown (pure-provider path).
|
getRen: () => ren, getScene: () => scene, getCam: () => cam, getHighwayCanvas: () => highwayCanvas,
|
||||||
function _timingHex(ts) {
|
canvasSize,
|
||||||
if (!_timingFx || !ts || ts === 'OK') return 0x22ff88;
|
});
|
||||||
if (ts === 'EARLY') return 0x35d6ff;
|
|
||||||
if (ts === 'LATE') return 0xffb84d;
|
|
||||||
return 0x22ff88;
|
|
||||||
}
|
|
||||||
function _sparkBurst(x, y, z, hex, count) {
|
|
||||||
if (!_sparkPts || count <= 0) return;
|
|
||||||
const r = ((hex >> 16) & 255) / 255, g = ((hex >> 8) & 255) / 255, b = (hex & 255) / 255;
|
|
||||||
let made = 0;
|
|
||||||
for (let i = 0; i < _SPARK_N && made < count; i++) {
|
|
||||||
if (_sparkLife[i] > 0) continue;
|
|
||||||
const j = i * 3, ang = Math.random() * Math.PI * 2, sp = (5 + Math.random() * 12) * K;
|
|
||||||
_sparkPos[j] = x; _sparkPos[j + 1] = y; _sparkPos[j + 2] = z;
|
|
||||||
_sparkVel[j] = Math.cos(ang) * sp; _sparkVel[j + 1] = (12 + Math.random() * 24) * K; _sparkVel[j + 2] = Math.sin(ang) * sp * 0.55;
|
|
||||||
_sparkCol[j] = r; _sparkCol[j + 1] = g; _sparkCol[j + 2] = b;
|
|
||||||
_sparkLife[i] = 0.30 + Math.random() * 0.16; made++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function _sparkUpdate(dt) {
|
|
||||||
if (!_sparkPts) return;
|
|
||||||
const grav = 55 * K; let any = false;
|
|
||||||
for (let i = 0; i < _SPARK_N; i++) {
|
|
||||||
if (_sparkLife[i] <= 0) continue;
|
|
||||||
const j = i * 3;
|
|
||||||
_sparkLife[i] -= dt;
|
|
||||||
if (_sparkLife[i] <= 0) { _sparkCol[j] = _sparkCol[j + 1] = _sparkCol[j + 2] = 0; continue; }
|
|
||||||
any = true;
|
|
||||||
_sparkVel[j + 1] -= grav * dt;
|
|
||||||
_sparkPos[j] += _sparkVel[j] * dt; _sparkPos[j + 1] += _sparkVel[j + 1] * dt; _sparkPos[j + 2] += _sparkVel[j + 2] * dt;
|
|
||||||
const fade = 1 - Math.min(1, dt * 3.2);
|
|
||||||
_sparkCol[j] *= fade; _sparkCol[j + 1] *= fade; _sparkCol[j + 2] *= fade;
|
|
||||||
}
|
|
||||||
_sparkPts.geometry.attributes.position.needsUpdate = true;
|
|
||||||
_sparkPts.geometry.attributes.color.needsUpdate = true;
|
|
||||||
_sparkPts.visible = any;
|
|
||||||
}
|
|
||||||
// #4 Bloom: lazy-load the vendored postprocessing addons and build an
|
|
||||||
// EffectComposer (RenderPass -> UnrealBloomPass -> OutputPass/ACES). Returns
|
|
||||||
// the composer once ready, or null (caller falls back to a direct render).
|
|
||||||
function _bloomEnsure() {
|
|
||||||
if (_composer) return _composer;
|
|
||||||
if (_bloomLoad || !ren || !scene || !cam) return null;
|
|
||||||
const A = '/static/vendor/three/addons/';
|
|
||||||
_bloomLoad = Promise.all([
|
|
||||||
import(A + 'postprocessing/EffectComposer.js'),
|
|
||||||
import(A + 'postprocessing/RenderPass.js'),
|
|
||||||
import(A + 'postprocessing/UnrealBloomPass.js'),
|
|
||||||
import(A + 'postprocessing/OutputPass.js'),
|
|
||||||
]).then(([EC, RP, UB, OP]) => {
|
|
||||||
try {
|
|
||||||
const sz = canvasSize(highwayCanvas) || { w: 1280, h: 720 };
|
|
||||||
const w = Math.max(2, sz.w | 0), h = Math.max(2, sz.h | 0);
|
|
||||||
// Multisampled (WebGL2 MSAA) HalfFloat target so anti-aliasing
|
|
||||||
// survives the bloom path — EffectComposer's default target has no
|
|
||||||
// `samples`, which is why bloom-on looked jagged (worst on non-Retina
|
|
||||||
// DPR1 displays that have no supersampling cushion).
|
|
||||||
const _bloomRT = new T.WebGLRenderTarget(w, h, { type: T.HalfFloatType, samples: 4 });
|
|
||||||
const comp = new EC.EffectComposer(ren, _bloomRT);
|
|
||||||
comp.addPass(new RP.RenderPass(scene, cam));
|
|
||||||
_bloomPass = new UB.UnrealBloomPass(new T.Vector2(w, h), 0.65, 0.5, 0.82); // strength, radius, threshold (high → only emissive blooms)
|
|
||||||
comp.addPass(_bloomPass);
|
|
||||||
comp.addPass(new OP.OutputPass());
|
|
||||||
comp.setSize(w, h);
|
|
||||||
_bloomW = w; _bloomH = h; _composer = comp;
|
|
||||||
} catch (e) { console.warn('[3D-Hwy] bloom init failed', e); _composer = null; }
|
|
||||||
}).catch((e) => console.warn('[3D-Hwy] bloom modules failed', e));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
function buildBoard() {
|
function buildBoard() {
|
||||||
// Dispose before clearing (traverse: nut/headstock may live in a Group).
|
// Dispose before clearing (traverse: nut/headstock may live in a Group).
|
||||||
while (fretG.children.length) {
|
while (fretG.children.length) {
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// h3d-carve-8: Q-section partial — lighting/FX utilities.
|
||||||
|
// buildBoard stays in screen.js (deferred to plan §3 row 16, P-section cut)
|
||||||
|
// because its write-back surface spans 9 factory-scope vars that P/U/Y also
|
||||||
|
// own; premature extraction would require a ~50-param DI. See plans/highway3d-carve.md.
|
||||||
|
//
|
||||||
|
// DI surface (per-call live-accessors — never cache at factory init):
|
||||||
|
// BG_DEFAULTS, K — plain IIFE-scope constants
|
||||||
|
// getT — live-accessor (Three.js, lazy-loaded)
|
||||||
|
// getAmbLight/getDirLight — live-accessors (null until initScene)
|
||||||
|
// getCinematic/getTimingFx — live-accessors (toggle flags)
|
||||||
|
// getSparkPts/setSparkPts — live-accessor + setter (Points object, set by buildBoard)
|
||||||
|
// getSparkN/getSparkPos/… — live-accessors for particle buffers (element-mutated in place)
|
||||||
|
// getComposer/setComposer — getter+setter (lazy-assigned inside _bloomEnsure)
|
||||||
|
// getBloomLoad/setBloomLoad — getter+setter (Promise, assigned inside _bloomEnsure)
|
||||||
|
// getBloomPass/setBloomPass — getter+setter (pass object, assigned inside _bloomEnsure)
|
||||||
|
// getBloomW/setBloomW/H — getter+setter (dimensions, assigned inside _bloomEnsure)
|
||||||
|
// getRen/getScene/getCam/getHighwayCanvas — live-accessors (null until initScene)
|
||||||
|
// canvasSize — factory-scope function ref (stable)
|
||||||
|
|
||||||
|
export function createFx({
|
||||||
|
BG_DEFAULTS, K,
|
||||||
|
getT,
|
||||||
|
getAmbLight, getDirLight, getCinematic,
|
||||||
|
getTimingFx,
|
||||||
|
getSparkPts, setSparkPts, getSparkN,
|
||||||
|
getSparkPos, setSparkPos, getSparkVel, setSparkVel,
|
||||||
|
getSparkCol, setSparkCol, getSparkLife, setSparkLife,
|
||||||
|
getComposer, setComposer,
|
||||||
|
getBloomLoad, setBloomLoad,
|
||||||
|
getBloomPass, setBloomPass,
|
||||||
|
getBloomW, setBloomW, getBloomH, setBloomH,
|
||||||
|
getRen, getScene, getCam, getHighwayCanvas,
|
||||||
|
canvasSize,
|
||||||
|
}) {
|
||||||
|
function _h3dHexOrDefault(hexStr, defHex) {
|
||||||
|
// VERBATIM MOVE. BG_DEFAULTS from plain DI param.
|
||||||
|
const d = defHex || BG_DEFAULTS.nutColor;
|
||||||
|
const s = (typeof hexStr === 'string' && /^#[0-9a-fA-F]{6}$/.test(hexStr.trim()))
|
||||||
|
? hexStr.trim().toLowerCase()
|
||||||
|
: d;
|
||||||
|
return parseInt(s.slice(1), 16);
|
||||||
|
}
|
||||||
|
// Cinematic lighting (#2): darken ambient so emissive gems have a dark
|
||||||
|
// surround to pop against; strengthen the key light for modelling.
|
||||||
|
// Toggle via the 'cinematic' setting so it's directly comparable.
|
||||||
|
function _applyCinematic() {
|
||||||
|
// VERBATIM MOVE. DI rewire: ambLight/dirLight/_cinematic from live-accessors.
|
||||||
|
const ambLight = getAmbLight(), dirLight = getDirLight(), _cinematic = getCinematic();
|
||||||
|
if (!ambLight || !dirLight) return;
|
||||||
|
ambLight.intensity = _cinematic ? 0.45 : 0.85;
|
||||||
|
dirLight.intensity = _cinematic ? 1.15 : 0.8;
|
||||||
|
}
|
||||||
|
// #5 early/late: tint the hit feedback by timing — on-time green, early cyan,
|
||||||
|
// late amber. Falls back to green when timing is unknown (pure-provider path).
|
||||||
|
function _timingHex(ts) {
|
||||||
|
// VERBATIM MOVE. DI rewire: _timingFx from getTimingFx().
|
||||||
|
if (!getTimingFx() || !ts || ts === 'OK') return 0x22ff88;
|
||||||
|
if (ts === 'EARLY') return 0x35d6ff;
|
||||||
|
if (ts === 'LATE') return 0xffb84d;
|
||||||
|
return 0x22ff88;
|
||||||
|
}
|
||||||
|
function _sparkBurst(x, y, z, hex, count) {
|
||||||
|
// VERBATIM MOVE. DI rewire: spark vars from live-accessors (per-call,
|
||||||
|
// not cached at factory init — buildBoard may reassign the refs).
|
||||||
|
const _sparkPts = getSparkPts();
|
||||||
|
if (!_sparkPts || count <= 0) return;
|
||||||
|
const _sparkPos = getSparkPos(), _sparkVel = getSparkVel(), _sparkCol = getSparkCol(), _sparkLife = getSparkLife();
|
||||||
|
const _SPARK_N = getSparkN();
|
||||||
|
const r = ((hex >> 16) & 255) / 255, g = ((hex >> 8) & 255) / 255, b = (hex & 255) / 255;
|
||||||
|
let made = 0;
|
||||||
|
for (let i = 0; i < _SPARK_N && made < count; i++) {
|
||||||
|
if (_sparkLife[i] > 0) continue;
|
||||||
|
const j = i * 3, ang = Math.random() * Math.PI * 2, sp = (5 + Math.random() * 12) * K;
|
||||||
|
_sparkPos[j] = x; _sparkPos[j + 1] = y; _sparkPos[j + 2] = z;
|
||||||
|
_sparkVel[j] = Math.cos(ang) * sp; _sparkVel[j + 1] = (12 + Math.random() * 24) * K; _sparkVel[j + 2] = Math.sin(ang) * sp * 0.55;
|
||||||
|
_sparkCol[j] = r; _sparkCol[j + 1] = g; _sparkCol[j + 2] = b;
|
||||||
|
_sparkLife[i] = 0.30 + Math.random() * 0.16; made++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function _sparkUpdate(dt) {
|
||||||
|
// VERBATIM MOVE. DI rewire: spark vars from live-accessors (per-call).
|
||||||
|
const _sparkPts = getSparkPts();
|
||||||
|
if (!_sparkPts) return;
|
||||||
|
const _sparkPos = getSparkPos(), _sparkVel = getSparkVel(), _sparkCol = getSparkCol(), _sparkLife = getSparkLife();
|
||||||
|
const _SPARK_N = getSparkN();
|
||||||
|
const grav = 55 * K; let any = false;
|
||||||
|
for (let i = 0; i < _SPARK_N; i++) {
|
||||||
|
if (_sparkLife[i] <= 0) continue;
|
||||||
|
const j = i * 3;
|
||||||
|
_sparkLife[i] -= dt;
|
||||||
|
if (_sparkLife[i] <= 0) { _sparkCol[j] = _sparkCol[j + 1] = _sparkCol[j + 2] = 0; continue; }
|
||||||
|
any = true;
|
||||||
|
_sparkVel[j + 1] -= grav * dt;
|
||||||
|
_sparkPos[j] += _sparkVel[j] * dt; _sparkPos[j + 1] += _sparkVel[j + 1] * dt; _sparkPos[j + 2] += _sparkVel[j + 2] * dt;
|
||||||
|
const fade = 1 - Math.min(1, dt * 3.2);
|
||||||
|
_sparkCol[j] *= fade; _sparkCol[j + 1] *= fade; _sparkCol[j + 2] *= fade;
|
||||||
|
}
|
||||||
|
_sparkPts.geometry.attributes.position.needsUpdate = true;
|
||||||
|
_sparkPts.geometry.attributes.color.needsUpdate = true;
|
||||||
|
_sparkPts.visible = any;
|
||||||
|
}
|
||||||
|
// #4 Bloom: lazy-load the vendored postprocessing addons and build an
|
||||||
|
// EffectComposer (RenderPass -> UnrealBloomPass -> OutputPass/ACES). Returns
|
||||||
|
// the composer once ready, or null (caller falls back to a direct render).
|
||||||
|
function _bloomEnsure() {
|
||||||
|
// VERBATIM MOVE. DI rewire: all factory-scope vars via get/set accessors.
|
||||||
|
// _composer, _bloomLoad, _bloomPass, _bloomW, _bloomH are REASSIGNED here
|
||||||
|
// so setters are required — these must NOT silently become locals.
|
||||||
|
if (getComposer()) return getComposer();
|
||||||
|
const ren = getRen(), scene = getScene(), cam = getCam();
|
||||||
|
if (getBloomLoad() || !ren || !scene || !cam) return null;
|
||||||
|
const T = getT(), highwayCanvas = getHighwayCanvas();
|
||||||
|
const A = '/static/vendor/three/addons/';
|
||||||
|
setBloomLoad(Promise.all([
|
||||||
|
import(A + 'postprocessing/EffectComposer.js'),
|
||||||
|
import(A + 'postprocessing/RenderPass.js'),
|
||||||
|
import(A + 'postprocessing/UnrealBloomPass.js'),
|
||||||
|
import(A + 'postprocessing/OutputPass.js'),
|
||||||
|
]).then(([EC, RP, UB, OP]) => {
|
||||||
|
try {
|
||||||
|
const sz = canvasSize(highwayCanvas) || { w: 1280, h: 720 };
|
||||||
|
const w = Math.max(2, sz.w | 0), h = Math.max(2, sz.h | 0);
|
||||||
|
// Multisampled (WebGL2 MSAA) HalfFloat target so anti-aliasing
|
||||||
|
// survives the bloom path — EffectComposer's default target has no
|
||||||
|
// `samples`, which is why bloom-on looked jagged (worst on non-Retina
|
||||||
|
// DPR1 displays that have no supersampling cushion).
|
||||||
|
const _bloomRT = new T.WebGLRenderTarget(w, h, { type: T.HalfFloatType, samples: 4 });
|
||||||
|
const comp = new EC.EffectComposer(ren, _bloomRT);
|
||||||
|
comp.addPass(new RP.RenderPass(scene, cam));
|
||||||
|
const bp = new UB.UnrealBloomPass(new T.Vector2(w, h), 0.65, 0.5, 0.82); // strength, radius, threshold (high → only emissive blooms)
|
||||||
|
setBloomPass(bp);
|
||||||
|
comp.addPass(bp);
|
||||||
|
comp.addPass(new OP.OutputPass());
|
||||||
|
comp.setSize(w, h);
|
||||||
|
setBloomW(w); setBloomH(h); setComposer(comp);
|
||||||
|
} catch (e) { console.warn('[3D-Hwy] bloom init failed', e); setComposer(null); }
|
||||||
|
}).catch((e) => console.warn('[3D-Hwy] bloom modules failed', e)));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { _h3dHexOrDefault, _applyCinematic, _timingHex, _sparkBurst, _sparkUpdate, _bloomEnsure };
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
// Source + behavioural guards for h3d-carve-8: Q-helpers (lighting/FX utilities)
|
||||||
|
// extracted to src/fx.js.
|
||||||
|
//
|
||||||
|
// Class-killers guaranteed:
|
||||||
|
// 1. Module exports createFx (source)
|
||||||
|
// 2. createFx return set covers all 6 required symbols (source)
|
||||||
|
// 3. Stranded-caller: every returned symbol in screen.js destructure (source)
|
||||||
|
// 4. Private-guard: factory-depth-1 privates not bare in screen.js (source)
|
||||||
|
// 5. _timingHex returns EARLY tint when ts='EARLY' and _timingFx truthy (behavioural)
|
||||||
|
// 6. _sparkBurst writes to getSparkPos() array NOT a stale init-time capture
|
||||||
|
// (live-accessor class-killer: set new arrays after factory init → RED if stale-cached)
|
||||||
|
// 7. _sparkUpdate sets .visible on getSparkPts() object (live-accessor)
|
||||||
|
// 8. _bloomEnsure calls setBloomLoad (synchronous write-back: assignment silenced → RED)
|
||||||
|
// 8b. _bloomEnsure reads getComposer() live (not init-cached: setComposer after init → RED)
|
||||||
|
// GAP: setComposer(comp) inside the async .then() is not tested synchronously —
|
||||||
|
// would require mocking dynamic import(); declared to Toby/Creed for assessment.
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
const FX_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'fx.js');
|
||||||
|
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||||
|
|
||||||
|
function stripComments(s) {
|
||||||
|
return s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function src() { return fs.readFileSync(FX_JS, 'utf8'); }
|
||||||
|
function screenSrc() { return fs.readFileSync(SCREEN_JS, 'utf8'); }
|
||||||
|
|
||||||
|
// ── 1. Module exports createFx ───────────────────────────────────────────────
|
||||||
|
test('fx.js exports createFx', () => {
|
||||||
|
assert.match(src(), /export\s+function\s+createFx\s*\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 2. Return set covers all 6 required symbols ──────────────────────────────
|
||||||
|
test('createFx returns all 6 required symbols', () => {
|
||||||
|
const stripped = stripComments(src());
|
||||||
|
const REQUIRED = ['_h3dHexOrDefault', '_applyCinematic', '_timingHex', '_sparkBurst', '_sparkUpdate', '_bloomEnsure'];
|
||||||
|
// Factory-level return block: 4-space indent inside createFx body.
|
||||||
|
const retMatch = stripped.match(/\n {4}return\s*\{\s*([^}]+)\}/);
|
||||||
|
assert.ok(retMatch, 'factory-level return block must be present');
|
||||||
|
const returned = retMatch[1].split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
for (const sym of REQUIRED) {
|
||||||
|
assert.ok(returned.includes(sym), `return set must include ${sym}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 3. Stranded-caller: returned ⊆ screen.js destructure ────────────────────
|
||||||
|
test('every createFx returned symbol appears in screen.js destructure', () => {
|
||||||
|
const stripped = stripComments(src());
|
||||||
|
const retMatch = stripped.match(/\n {4}return\s*\{\s*([^}]+)\}/);
|
||||||
|
assert.ok(retMatch, 'factory-level return block must be present');
|
||||||
|
const returned = retMatch[1].split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
const scrRaw = screenSrc();
|
||||||
|
const destrMatch = scrRaw.match(/const\s*\{([^}]+)\}\s*=\s*createFx\s*\(/);
|
||||||
|
assert.ok(destrMatch, 'screen.js must have a createFx destructure');
|
||||||
|
const destructured = destrMatch[1].split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
for (const sym of returned) {
|
||||||
|
assert.ok(destructured.includes(sym),
|
||||||
|
`returned symbol '${sym}' must appear in screen.js createFx destructure`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 4. Private-guard: factory-depth-1 privates not bare in screen.js ─────────
|
||||||
|
test('factory-private symbols in fx.js do not appear bare in screen.js', () => {
|
||||||
|
const stripped = stripComments(src());
|
||||||
|
// Collect factory-level return symbols.
|
||||||
|
const retMatch = stripped.match(/\n {4}return\s*\{\s*([^}]+)\}/);
|
||||||
|
assert.ok(retMatch, 'factory-level return block must be present');
|
||||||
|
const returned = new Set(retMatch[1].split(',').map(s => s.trim()).filter(Boolean));
|
||||||
|
|
||||||
|
// Factory-depth-1: exactly 4-space indented const/let inside createFx.
|
||||||
|
// (There are none in this module — all vars are per-call locals inside functions.)
|
||||||
|
const privateSyms = [];
|
||||||
|
for (const m of stripped.matchAll(/^ {4}(?:const|let)\s+(\w+)/gm)) {
|
||||||
|
const sym = m[1];
|
||||||
|
if (!returned.has(sym)) privateSyms.push(sym);
|
||||||
|
}
|
||||||
|
// Guard: if there ARE any factory-depth-1 privates, they must not be bare in screen.js.
|
||||||
|
let scr = screenSrc().replace(/^import\s+.*\n/gm, '');
|
||||||
|
scr = stripComments(scr);
|
||||||
|
scr = scr.replace(/const\s*\{[^}]+\}\s*=\s*createFx\s*\([^)]*\)\s*;/, '');
|
||||||
|
|
||||||
|
const violations = privateSyms.filter(sym => new RegExp('\\b' + sym + '\\b').test(scr));
|
||||||
|
assert.deepStrictEqual(violations, [],
|
||||||
|
'screen.js must not reference factory-private fx.js symbols: ' + violations.join(', '));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Behavioural vm sandbox ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function loadFxModule(di) {
|
||||||
|
const raw = fs.readFileSync(FX_JS, 'utf8');
|
||||||
|
// Strip ES export keyword so the script runs in a vm context.
|
||||||
|
const code = raw.replace(/^export\s+function\s+createFx/m, 'function createFx');
|
||||||
|
// Provide Promise so Promise.all() in _bloomEnsure is defined.
|
||||||
|
// dynamic import() inside the vm will reject (no module resolution),
|
||||||
|
// but setBloomLoad() is called BEFORE the rejection fires — it receives
|
||||||
|
// the pending Promise synchronously, which is what the write-back test checks.
|
||||||
|
const sandbox = { console, Promise, __exports: {} };
|
||||||
|
vm.createContext(sandbox);
|
||||||
|
vm.runInContext(code + '\n__exports.createFx = createFx;', sandbox);
|
||||||
|
return sandbox.__exports.createFx(di);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDi(overrides = {}) {
|
||||||
|
// Minimal valid DI for behavioural tests.
|
||||||
|
const state = {
|
||||||
|
ambLight: null, dirLight: null, _cinematic: false, _timingFx: false,
|
||||||
|
_sparkPts: null, _SPARK_N: 10,
|
||||||
|
_sparkPos: new Float32Array(30), _sparkVel: new Float32Array(30),
|
||||||
|
_sparkCol: new Float32Array(30), _sparkLife: new Float32Array(10),
|
||||||
|
_composer: null, _bloomLoad: null, _bloomPass: null, _bloomW: 0, _bloomH: 0,
|
||||||
|
ren: null, scene: null, cam: null, highwayCanvas: null,
|
||||||
|
T: null,
|
||||||
|
};
|
||||||
|
return Object.assign({
|
||||||
|
BG_DEFAULTS: { nutColor: '#cccccc' },
|
||||||
|
K: 0.01,
|
||||||
|
getT: () => state.T, getAmbLight: () => state.ambLight,
|
||||||
|
getDirLight: () => state.dirLight, getCinematic: () => state._cinematic,
|
||||||
|
getTimingFx: () => state._timingFx,
|
||||||
|
getSparkPts: () => state._sparkPts, setSparkPts: (v) => { state._sparkPts = v; },
|
||||||
|
getSparkN: () => state._SPARK_N,
|
||||||
|
getSparkPos: () => state._sparkPos, setSparkPos: (v) => { state._sparkPos = v; },
|
||||||
|
getSparkVel: () => state._sparkVel, setSparkVel: (v) => { state._sparkVel = v; },
|
||||||
|
getSparkCol: () => state._sparkCol, setSparkCol: (v) => { state._sparkCol = v; },
|
||||||
|
getSparkLife: () => state._sparkLife, setSparkLife: (v) => { state._sparkLife = v; },
|
||||||
|
getComposer: () => state._composer, setComposer: (v) => { state._composer = v; },
|
||||||
|
getBloomLoad: () => state._bloomLoad, setBloomLoad: (v) => { state._bloomLoad = v; },
|
||||||
|
getBloomPass: () => state._bloomPass, setBloomPass: (v) => { state._bloomPass = v; },
|
||||||
|
getBloomW: () => state._bloomW, setBloomW: (v) => { state._bloomW = v; },
|
||||||
|
getBloomH: () => state._bloomH, setBloomH: (v) => { state._bloomH = v; },
|
||||||
|
getRen: () => state.ren, getScene: () => state.scene,
|
||||||
|
getCam: () => state.cam, getHighwayCanvas: () => state.highwayCanvas,
|
||||||
|
canvasSize: () => ({ w: 800, h: 600 }),
|
||||||
|
_state: state,
|
||||||
|
}, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. _timingHex returns EARLY tint when ts='EARLY' and timingFx truthy ─────
|
||||||
|
test('_timingHex returns EARLY hex when ts=EARLY and timingFx is truthy', () => {
|
||||||
|
// Mutation that goes RED: remove the EARLY branch → returns 0x22ff88 instead.
|
||||||
|
const di = makeDi();
|
||||||
|
di._state._timingFx = true;
|
||||||
|
const { _timingHex } = loadFxModule(di);
|
||||||
|
assert.equal(_timingHex('EARLY'), 0x35d6ff, 'EARLY timing must return cyan 0x35d6ff');
|
||||||
|
assert.equal(_timingHex('LATE'), 0xffb84d, 'LATE timing must return amber 0xffb84d');
|
||||||
|
assert.equal(_timingHex('OK'), 0x22ff88, 'OK timing must return green');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 6. _sparkBurst live-accessor class-killer ─────────────────────────────────
|
||||||
|
test('_sparkBurst writes to the sparkPos array returned by getSparkPos (live, not init-cached)', () => {
|
||||||
|
// Mutation that goes RED: if _sparkBurst caches `const _sparkPos = getSparkPos()` at
|
||||||
|
// factory init time instead of per-call, then calling setSparkPos(newArray) after init
|
||||||
|
// and triggering a burst will write to the STALE array → newArray stays all zeros → RED.
|
||||||
|
const di = makeDi();
|
||||||
|
// Bootstrap: provide a sparkPts stub so _sparkBurst doesn't bail early.
|
||||||
|
di._state._sparkPts = { geometry: { attributes: { position: { needsUpdate: false }, color: { needsUpdate: false } } }, visible: false };
|
||||||
|
// Set a dead particle slot so _sparkBurst can spawn into it.
|
||||||
|
di._state._sparkLife[0] = 0;
|
||||||
|
const { _sparkBurst } = loadFxModule(di);
|
||||||
|
|
||||||
|
// Burst writes into initial array.
|
||||||
|
_sparkBurst(1, 2, 3, 0xff0000, 1);
|
||||||
|
const firstArr = di._state._sparkPos;
|
||||||
|
// At least x position should be set (= 1).
|
||||||
|
assert.equal(firstArr[0], 1, 'sparkPos[0] should be x=1 after burst into initial array');
|
||||||
|
|
||||||
|
// Now rebuild: replace sparkPos with a fresh zero array.
|
||||||
|
const newPos = new Float32Array(30);
|
||||||
|
di.setSparkPos(newPos);
|
||||||
|
// Reset life for slot 0 so burst fires again.
|
||||||
|
di._state._sparkLife[0] = 0;
|
||||||
|
_sparkBurst(5, 6, 7, 0x00ff00, 1);
|
||||||
|
|
||||||
|
assert.equal(newPos[0], 5,
|
||||||
|
'_sparkBurst must write into the NEW sparkPos after setSparkPos rebuild; ' +
|
||||||
|
'if 0 it cached the initial array at factory init time (live-accessor broken)');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 7. _sparkUpdate live-accessor class-killer ────────────────────────────────
|
||||||
|
test('_sparkUpdate reads sparkPts from getter each call (not init-cached)', () => {
|
||||||
|
// Mutation: if getSparkPts() result is cached at factory init, setSparkPts(newPts)
|
||||||
|
// after init → _sparkUpdate still references stale (null) pts → .visible never set.
|
||||||
|
const di = makeDi();
|
||||||
|
const { _sparkUpdate } = loadFxModule(di);
|
||||||
|
|
||||||
|
// No sparkPts yet — _sparkUpdate should short-circuit.
|
||||||
|
_sparkUpdate(0.016); // must not throw
|
||||||
|
|
||||||
|
// Now provide sparkPts (simulates buildBoard completing).
|
||||||
|
const pts = {
|
||||||
|
geometry: { attributes: {
|
||||||
|
position: { needsUpdate: false },
|
||||||
|
color: { needsUpdate: false },
|
||||||
|
}},
|
||||||
|
visible: false,
|
||||||
|
};
|
||||||
|
di.setSparkPts(pts);
|
||||||
|
_sparkUpdate(0.016);
|
||||||
|
|
||||||
|
// Even with no living sparks, the needsUpdate flags must have been set.
|
||||||
|
assert.ok(pts.geometry.attributes.position.needsUpdate === true ||
|
||||||
|
pts.geometry.attributes.color.needsUpdate === true ||
|
||||||
|
pts.visible === false,
|
||||||
|
'_sparkUpdate must reach the pts object provided via setSparkPts after factory init');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 8. _bloomEnsure write-back class-killer: setBloomLoad called ──────────────
|
||||||
|
test('_bloomEnsure calls setBloomLoad when ren/scene/cam are available', () => {
|
||||||
|
// Mutation that goes RED: if _bloomEnsure does `const bl = Promise.all(...)` (local)
|
||||||
|
// instead of `setBloomLoad(Promise.all(...))`, getBloomLoad() stays null after the
|
||||||
|
// call → every subsequent frame re-enters init → duplicate composers → visual glitch.
|
||||||
|
// This is the synchronously verifiable half of the write-back contract.
|
||||||
|
const di = makeDi();
|
||||||
|
// Provide non-null ren/scene/cam so the guard passes.
|
||||||
|
di._state.ren = {}; di._state.scene = {}; di._state.cam = {};
|
||||||
|
di._state.T = { WebGLRenderTarget() {}, HalfFloatType: 1, Vector2() {} };
|
||||||
|
const { _bloomEnsure } = loadFxModule(di);
|
||||||
|
|
||||||
|
const result = _bloomEnsure();
|
||||||
|
assert.equal(result, null, '_bloomEnsure returns null on first call (async init started)');
|
||||||
|
assert.ok(di.getBloomLoad() instanceof Promise,
|
||||||
|
'setBloomLoad must have been called with the init Promise; ' +
|
||||||
|
'if getBloomLoad() is null the assignment silently became a local (write-back broken)');
|
||||||
|
|
||||||
|
// A second call must short-circuit on the existing bloomLoad (not start a second init).
|
||||||
|
const result2 = _bloomEnsure();
|
||||||
|
assert.equal(result2, null, 'second call must return null (init still in flight, not re-started)');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 8b. _bloomEnsure getComposer live-accessor ────────────────────────────────
|
||||||
|
test('_bloomEnsure returns composer set via setComposer (reads live via getComposer)', () => {
|
||||||
|
// Mutation that goes RED: if _bloomEnsure caches `const _composer = getComposer()`
|
||||||
|
// at factory init time, a later setComposer(comp) is invisible → returns null forever.
|
||||||
|
const di = makeDi();
|
||||||
|
const { _bloomEnsure } = loadFxModule(di);
|
||||||
|
// Initially null.
|
||||||
|
assert.equal(_bloomEnsure(), null, 'must return null when composer not yet set');
|
||||||
|
// Simulate bloom async chain resolving.
|
||||||
|
const fakeComp = { render() {}, setSize() {} };
|
||||||
|
di.setComposer(fakeComp);
|
||||||
|
// Now must return the composer (reads via live getComposer(), not init-cached).
|
||||||
|
assert.equal(_bloomEnsure(), fakeComp,
|
||||||
|
'_bloomEnsure must return composer set via setComposer; ' +
|
||||||
|
'null means it cached the initial null value at factory init time');
|
||||||
|
});
|
||||||
@@ -120,6 +120,15 @@ function loadHighway3dStatics() {
|
|||||||
drawToneHud() { return 0; },
|
drawToneHud() { return 0; },
|
||||||
drawLyrics() { return 0; },
|
drawLyrics() { return 0; },
|
||||||
}),
|
}),
|
||||||
|
// h3d-carve-8: Q-helpers (lighting/FX) moved to src/fx.js.
|
||||||
|
createFx: () => ({
|
||||||
|
_h3dHexOrDefault() { return 0; },
|
||||||
|
_applyCinematic() {},
|
||||||
|
_timingHex() { return 0x22ff88; },
|
||||||
|
_sparkBurst() {},
|
||||||
|
_sparkUpdate() {},
|
||||||
|
_bloomEnsure() { return null; },
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
vm.createContext(sandbox);
|
vm.createContext(sandbox);
|
||||||
vm.runInContext(instrumented, sandbox, { filename: SCREEN_JS });
|
vm.runInContext(instrumented, sandbox, { filename: SCREEN_JS });
|
||||||
|
|||||||
Reference in New Issue
Block a user