orbit/dev/starfield.test.mjs

192 lines
7.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);