/** * Starfield behavior test (dev tool, run with Node — no browser needed): * * node dev/starfield.test.mjs * * Runs the REAL Starfield.create()/update() from js/visuals/Starfield.js * against a stubbed Phaser/scene, then asserts the infinite parallax setup: * - star count comes from data/game.json; * - stars start inside the initial camera window; * - when the camera moves, every star drifts on screen by exactly * −parallax × camera delta (opposite to travel, near stars faster) — * checked modulo the wrap span so wrap-around doesn't fool the test; * - after any camera move (even a huge one) the field is still full: * every star sits inside the current camera window; * - a still camera leaves the field untouched; * - bigger ("closer") stars always parallax more than smaller ones; * - destroy() cleans up. */ import { pathToFileURL } from 'node:url'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); // --- Stub just enough of Phaser + a scene for Starfield ------------------- class Star { constructor(x, y) { this.x = x; this.y = y; this.scale = 1; this.alpha = 1; this.tint = null; this.depth = 0; this.parallax = 1; } setScale(s) { this.scale = s; return this; } setAlpha(a) { this.alpha = a; return this; } setTint(t) { this.tint = t; return this; } setDepth(d) { this.depth = d; return this; } destroy() { this.destroyed = true; } } const PhaserStub = { Math: { Between: (min, max) => min + Math.floor(Math.random() * (max - min + 1)), FloatBetween: (min, max) => min + Math.random() * (max - min), Linear: (v0, v1, t) => v0 + (v1 - v0) * t, }, Display: { Color: { ValueToColor: (v) => ({ color: parseInt(v.slice(1), 16) }) } }, }; globalThis.window = { Phaser: PhaserStub }; // js/vendor/phaser.js reads this const W = 1280, H = 720; const makeScene = () => ({ scale: { width: W, height: H }, // Start where GameScene puts the camera: ship at the world origin, // view centered on it. cameras: { main: { scrollX: -W / 2, scrollY: -H / 2 } }, textures: { exists: () => false }, make: { graphics: () => ({ fillStyle() {}, fillCircle() {}, generateTexture() {}, destroy() {} }) }, add: { image: (x, y) => new Star(x, y) }, }); // Use the real data/*.json config (starfield feel comes from data/game.json). const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href); const fs = await import('node:fs'); const dataDir = join(__dirname, '../data'); const configData = {}; for (const f of fs.readdirSync(dataDir)) { if (!f.endsWith('.json') || f === 'manifest.json') continue; configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8')); } config.init(configData); const { Starfield } = await import( pathToFileURL(join(__dirname, '../js/visuals/Starfield.js')).href ); let failures = 0; const check = (label, cond) => { console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); if (!cond) failures++; }; // a's distance to the nearest multiple of span (span > 0) const mod = (a, span) => { const r = ((a % span) + span) % span; return Math.min(r, span - r); }; const screenX = (star, cam) => star.x - cam.scrollX; const screenY = (star, cam) => star.y - cam.scrollY; const MARGIN = 8; // must match Starfield.js // --- Test 1: count, initial placement, parallax mapping ------------------- { const scene = makeScene(); const sf = new Starfield(scene); sf.create(); const cam = scene.cameras.main; const count = config.get('game.starfield.count', 200); check(`star count matches config (${sf.stars.length} / ${count})`, sf.stars.length === count); const inView = sf.stars.every( (s) => screenX(s, cam) >= -MARGIN - 1e-9 && screenX(s, cam) <= W + MARGIN + 1e-9 && screenY(s, cam) >= -MARGIN - 1e-9 && screenY(s, cam) <= H + MARGIN + 1e-9, ); check('all stars start inside the initial camera window', inView); // Bigger ("closer") stars must always parallax at least as much. const sorted = [...sf.stars].sort((a, b) => a.scale - b.scale); let monotonic = true; for (let i = 1; i < sorted.length && monotonic; i++) { if (sorted[i - 1].parallax > sorted[i].parallax + 1e-9) monotonic = false; } check('bigger stars parallax more (parallax monotonic in scale)', monotonic); const alphas = sf.stars.every((s) => s.alpha >= 0.2 - 1e-9 && s.alpha <= 0.85 + 1e-9); check('star alphas stay in [0.2, 0.85]', alphas); sf.destroy(); check('destroy() clears the star list', sf.stars.length === 0); } // --- Test 2: parallax direction & wrap keeps the field full --------------- { const scene = makeScene(); const sf = new Starfield(scene); sf.create(); const cam = scene.cameras.main; const step = (dx, dy) => { cam.scrollX += dx; cam.scrollY += dy; sf.update(); }; // Fly a long diagonal course, a few camera steps at a time. let allOk = true; for (let i = 0; i < 6 && allOk; i++) { const dx = 300 + i * 137; // irrational-ish steps to hit wrap edges const dy = -220 + i * 91; const before = sf.stars.map((s) => ({ x: screenX(s, cam), y: screenY(s, cam), p: s.parallax })); step(dx, dy); const spanX = W + 2 * MARGIN, spanY = H + 2 * MARGIN; const now = sf.stars.map((s) => screenX(s, cam)); const nowY = sf.stars.map((s) => screenY(s, cam)); const beforeY = before.map((b) => b.y); const beforeX = before.map((b) => b.x); const p = before.map((b) => b.p); // On-screen shift must be exactly −p·delta, modulo the wrap span. allOk = allOk && p.every((pi, i) => Math.abs(mod(now[i] - beforeX[i] + pi * dx, spanX)) < 1e-6 && Math.abs(mod(nowY[i] - beforeY[i] + pi * dy, spanY)) < 1e-6) && // and the field must still be full… now.every((x) => x >= -MARGIN - 1e-9 && x <= W + MARGIN + 1e-9) && nowY.every((y) => y >= -MARGIN - 1e-9 && y <= H + MARGIN + 1e-9); } check('stars drift opposite to camera by exactly parallax × delta, field stays full', allOk); // A huge single jump (fast travel / tab switch) must still leave the // field full, thanks to the wrap. const far = 50000; step(far, -far / 2); const cam2 = scene.cameras.main; const filled = sf.stars.every( (s) => screenX(s, cam2) >= -MARGIN - 1e-6 && screenX(s, cam2) <= W + MARGIN + 1e-6 && screenY(s, cam2) >= -MARGIN - 1e-6 && screenY(s, cam2) <= H + MARGIN + 1e-6, ); check('field is still full after a huge camera jump (50,000 px)', filled); sf.destroy(); } // --- Test 3: still camera → still stars ------------------------------------ { const scene = makeScene(); const sf = new Starfield(scene); sf.create(); const before = sf.stars.map((s) => [s.x, s.y]); sf.update(); // camera hasn't moved sf.update(); const still = before.every(([x, y], i) => sf.stars[i].x === x && sf.stars[i].y === y); check('a still camera leaves the starfield untouched', still); sf.destroy(); } console.log(failures === 0 ? '\nAll starfield tests passed ✔' : `\n${failures} test(s) FAILED ✘`); process.exit(failures === 0 ? 0 : 1);