192 lines
7.1 KiB
JavaScript
192 lines
7.1 KiB
JavaScript
/**
|
||
* 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);
|