diff --git a/README.md b/README.md index 1780691..5226433 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,10 @@ python3 -m http.server 8080 ## Current state — v0.1 foundation - Main menu with a **New Game** button -- Game screen with a basic top-down ship: **click anywhere to fly there** +- Game screen with a basic top-down ship: **click anywhere to fly there** in + infinite, unbounded space (borders/sectors come later) +- Camera gently trails the ship; the **parallax starfield** streams past while it + flies and the view slowly recenters (≈1.5 s) once the ship comes to rest - Config-driven setup: every tunable value lives in `data/*.json` ## Project layout @@ -58,6 +61,7 @@ orbit/ ```sh node dev/ship-behavior.test.mjs # runs the real Ship.update() loop in Node +node dev/starfield.test.mjs # runs the real Starfield.create() in Node ``` `dev/test-game.html` boots straight into the GameScene (no menu click), diff --git a/data/game.json b/data/game.json index 1231ea4..2b22928 100644 --- a/data/game.json +++ b/data/game.json @@ -12,8 +12,12 @@ "debug": false, "starfield": { "enabled": true, - "count": 180, - "driftSpeed": 14, + "count": 200, + "parallax": [0.15, 0.85], "colors": ["#ffffff", "#b9c6ff", "#8090b8"] + }, + "camera": { + "followShip": true, + "followRate": 3.0 } } diff --git a/dev/ship-behavior.test.mjs b/dev/ship-behavior.test.mjs index 1cef8d3..c6d5b7e 100644 --- a/dev/ship-behavior.test.mjs +++ b/dev/ship-behavior.test.mjs @@ -5,8 +5,9 @@ * * Stubs just enough of Phaser + a scene to run the REAL Ship.update() * loop from js/entities/Ship.js, then asserts the flight feel: - * arrives and stops, respects maxSpeed, tracks its heading, can be - * re-targeted mid-flight, coasts to rest, hard-brakes on stop(). + * arrives and stops, respects maxSpeed, tracks its heading, stops facing + * the direction it was traveling, can be re-targeted mid-flight, coasts + * to rest, hard-brakes on stop(). * * The harness integrates acceleration→velocity→position the way the * Arcade physics world does (after the scene's update). @@ -172,5 +173,62 @@ const integrate = (ship) => { ship.target === null && ship.body.velocity.length() === 0); } +// --- Test 6: arrives facing the direction of travel ------------------------ +{ + const angDiff = (a, b) => { + let d = wrap(a - b); + if (d > Math.PI) d -= TAU; + return Math.abs(d); + }; + + // Straight trip from rest: must end facing the way it flew. + { + const ship = new Ship(scene, 100, 100); + const target = { x: 900, y: 100 }; + ship.setTarget(target.x, target.y); + for (let t = 0; t < 60 * 30; t++) { + ship.update(t * dt, dt); + integrate(ship); + if (ship.target === null) break; + } + const want = Math.atan2(target.y - 100, target.x - 100); + check(`straight trip: stops facing direction of travel (rot=${ship.rotation.toFixed(3)} rad, want ${want.toFixed(3)})`, + ship.target === null && angDiff(ship.rotation, want) < 0.25); + } + + // Diagonal trip from rest: must end facing the diagonal it flew. + { + const ship = new Ship(scene, 100, 100); + const target = { x: 700, y: 500 }; + ship.setTarget(target.x, target.y); + for (let t = 0; t < 60 * 30; t++) { + ship.update(t * dt, dt); + integrate(ship); + if (ship.target === null) break; + } + const want = Math.atan2(target.y - 100, target.x - 100); + check(`diagonal trip: stops facing direction of travel (rot=${ship.rotation.toFixed(3)} rad, want ${want.toFixed(3)})`, + ship.target === null && angDiff(ship.rotation, want) < 0.25); + } + + // Fast ship re-targeted close ahead: still ends facing its travel direction. + { + const ship = new Ship(scene, 100, 100); + ship.setTarget(2000, 100); + for (let t = 0; t < 40; t++) { ship.update(t * dt, dt); integrate(ship); } + const target = { x: 400, y: 100 }; + ship.setTarget(target.x, target.y); + for (let t = 40; t < 40 + 60 * 30; t++) { + ship.update(t * dt, dt); + integrate(ship); + if (ship.target === null) break; + } + const want = Math.atan2(target.y - 100, target.x - 100); + const d = Math.hypot(ship.x - target.x, ship.y - target.y); + check(`fast re-target: arrives (dist=${d.toFixed(2)}) facing direction of travel (rot=${ship.rotation.toFixed(3)} rad, want ${want.toFixed(3)})`, + ship.target === null && d <= 8.5 && angDiff(ship.rotation, want) < 0.25); + } +} + console.log(failures === 0 ? '\nAll ship behavior tests passed ✔' : `\n${failures} test(s) FAILED ✘`); process.exit(failures === 0 ? 0 : 1); diff --git a/dev/starfield.test.mjs b/dev/starfield.test.mjs new file mode 100644 index 0000000..48beeda --- /dev/null +++ b/dev/starfield.test.mjs @@ -0,0 +1,191 @@ +/** + * 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); diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 25ac829..8b8dd41 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -73,8 +73,9 @@ trading/economy, stations, quests — to be scoped as we go. ## Roadmap (working list, intentionally rough) - [x] v0.1 foundation — menu → New Game → click-to-fly ship -- [ ] Decide the world model: bounded sectors vs. infinite space (affects - camera, starfield, and world gen) +- [ ] Decide the world model: **infinite open space for now** (ship flies + unbounded; borders/sectors planned for later) — affects camera, + starfield, and world gen - [ ] Procedural star map / sector generation (driven by `data/sectors.json`) - [ ] Ship input beyond click-to-fly (throttle/brake keys, manual rotation) - [ ] HUD (speed, sector name, later: fuel/crew) diff --git a/js/entities/Ship.js b/js/entities/Ship.js index 815fd17..811fc58 100644 --- a/js/entities/Ship.js +++ b/js/entities/Ship.js @@ -46,7 +46,6 @@ export class Ship extends Phaser.Physics.Arcade.Sprite { scene.add.existing(this); scene.physics.add.existing(this); - this.setCollideWorldBounds(true); // Tuning (data/ship.json) ----------------------------------------- this.thrust = config.get('ship.thrust', 900); // px/s^2 @@ -89,6 +88,10 @@ export class Ship extends Phaser.Physics.Arcade.Sprite { this.target = null; // Clear acceleration too, so the physics step can't re-kick us. body.acceleration.set(0, 0); + // Come to rest facing the direction we were traveling. + if (speed > 1) { + this.rotation = Phaser.Math.Angle.Wrap(Math.atan2(body.velocity.y, body.velocity.x)); + } body.velocity.set(0, 0); return; } @@ -96,8 +99,15 @@ export class Ship extends Phaser.Physics.Arcade.Sprite { const nx = dx / dist; const ny = dy / dist; - // Ease off the throttle as we close in, so we arrive gently. - const throttle = Phaser.Math.Clamp(dist / this.brakeDistance, 0.15, 1); + // Braking: with the throttle cut, drag carries the ship a further + // speed / drag before it stops. While that still lands short of the + // target, keep easing the throttle with distance; once we are too + // fast to stop in time, cut the throttle and let drag bleed the + // speed off, so we reach the target with little or no speed to spare. + const canStopInTime = speed <= this.drag * dist; + const throttle = canStopInTime + ? Phaser.Math.Clamp(dist / this.brakeDistance, 0, 1) + : 0; body.acceleration.set(nx * this.thrust * throttle, ny * this.thrust * throttle); // Gentle drag so we never coast forever. @@ -109,9 +119,13 @@ export class Ship extends Phaser.Physics.Arcade.Sprite { body.velocity.scale(this.maxSpeed / v); } - // Rotate toward the direction of travel (shortest path). + // Heading: while the ship has speed it follows its actual direction + // of motion (so it never flies "backwards" as it brakes); when it + // (nearly) stands still it points at the target. + const wanted = speed > 10 + ? Phaser.Math.Angle.Wrap(Math.atan2(body.velocity.y, body.velocity.x)) + : Phaser.Math.Angle.Wrap(Math.atan2(ny, nx)); const current = Phaser.Math.Angle.Wrap(this.rotation); - const wanted = Phaser.Math.Angle.Wrap(Math.atan2(ny, nx)); const step = Math.min(Math.abs(wanted - current), this.rotSpeed * dt); this.rotation = Phaser.Math.Angle.RotateTo(current, wanted, step); } else { diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index d433cfa..1b6850c 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -16,14 +16,22 @@ export class GameScene extends Phaser.Scene { } create() { - // Background + // Camera: smoothly follows the ship (updateCamera below). This motion + // is what drives the parallax starfield — ship flies, view trails. + this.cameraFollowShip = config.get('game.camera.followShip', true); + this.cameraFollowRate = config.get('game.camera.followRate', 3.0); // 1/s + + // The ship — spawns at the world origin in open, unbounded space. + this.ship = new Ship(this, 0, 0); + this.ship.setDepth(10); + + // Center the camera on the ship from the very first frame. + this.cameras.main.setScroll(-this.scale.width / 2, -this.scale.height / 2); + + // Background (stars are placed around the current camera view). this.starfield = new Starfield(this); this.starfield.create(); - // The ship - this.ship = new Ship(this, this.scale.width / 2, this.scale.height / 2); - this.ship.setDepth(10); - // Hint this.hint = this.add .text(this.scale.width / 2, this.scale.height - 26, config.get('game.hintText', ''), { @@ -31,7 +39,8 @@ export class GameScene extends Phaser.Scene { fontSize: '14px', color: '#54608a', }) - .setOrigin(0.5); + .setOrigin(0.5) + .setScrollFactor(0); // UI: pinned to the screen, not the world // Input: click = fly there this.input.on('pointerdown', (pointer) => { @@ -42,8 +51,31 @@ export class GameScene extends Phaser.Scene { } update(_time, delta) { - this.starfield.update(delta); this.ship.update(_time, delta); + this.updateCamera(delta); + this.starfield.update(); // after the camera, so it sees this frame's motion + } + + /** + * The camera chases the ship with a frame-rate-independent ease: + * scroll += (shipCenter - scroll) * (1 - e^(-rate·dt)) + * While the ship flies, the camera trails it and the starfield streams + * past in the opposite direction (real parallax). When the ship stops, + * the camera keeps easing until it is centered on the ship again — a + * slow recenter (≈1.5 s at followRate 3) with the stars parallaxing + * along for the ride. + */ + updateCamera(delta) { + if (!this.cameraFollowShip || !this.ship) return; + const rate = this.cameraFollowRate; + if (!(rate > 0)) return; + + const cam = this.cameras.main; + const k = 1 - Math.exp(-rate * (Math.min(delta, 64) / 1000)); + cam.setScroll( + Phaser.Math.Linear(cam.scrollX, this.ship.x - this.scale.width / 2, k), + Phaser.Math.Linear(cam.scrollY, this.ship.y - this.scale.height / 2, k), + ); } showTargetMarker(x, y) { diff --git a/js/visuals/Starfield.js b/js/visuals/Starfield.js index 364d596..ae7a171 100644 --- a/js/visuals/Starfield.js +++ b/js/visuals/Starfield.js @@ -3,18 +3,35 @@ import { config } from '../config/Config.js'; import { toColor } from '../utils/Color.js'; const STAR_KEY = '__star'; +const MIN_SCALE = 0.4; // smallest star +const MAX_SCALE = 1.5; // largest star +const MARGIN = 8; // px of starfield beyond the camera view, each side /** - * Decorative parallax starfield. - * Tuned by data/game.json → starfield { enabled, count, driftSpeed, colors }. - * Purely visual: no physics, no input. + * Decorative parallax starfield for infinite space. + * + * All motion is camera-driven (the camera follows the ship — see + * GameScene.updateCamera). Each frame the field is shifted by (1 − p) of + * the camera delta, so on screen the stars stream opposite to the + * direction of travel, near ("bigger") stars faster than far ones + * (p = the star's parallax factor). Ship still → stars still. + * + * Because the world is unbounded, stars are kept in a window around the + * *current camera view* and wrapped back in when they leave it — the + * field stays full and evenly distributed no matter how far the ship + * flies. + * + * Tuned by data/game.json → starfield { enabled, count, parallax, colors }. */ export class Starfield { constructor(scene) { this.scene = scene; this.cfg = config.get('game.starfield', {}); - this.driftSpeed = this.cfg.driftSpeed ?? 14; // px/s + const p = this.cfg.parallax; + this.parallax = Array.isArray(p) && p.length === 2 ? p : [0.15, 0.85]; this.stars = []; + this.lastScrollX = 0; + this.lastScrollY = 0; } create() { @@ -23,6 +40,7 @@ export class Starfield { const scene = this.scene; const width = scene.scale.width; const height = scene.scale.height; + const cam = scene.cameras.main; // Tiny 4x4 white dot, shared by every star. if (!scene.textures.exists(STAR_KEY)) { @@ -33,37 +51,68 @@ export class Starfield { g.destroy(); } - const count = this.cfg.count ?? 160; + const count = this.cfg.count ?? 200; const colors = (this.cfg.colors ?? ['#ffffff']).map((c) => toColor(c)); + const [parMin, parMax] = this.parallax; + + // Stars start in a window around the current camera view; update() + // keeps them wrapped into it as the camera flies. + const left = cam.scrollX - MARGIN; + const top = cam.scrollY - MARGIN; for (let i = 0; i < count; i++) { const star = scene.add.image( - Phaser.Math.Between(0, width), - Phaser.Math.Between(0, height), + Phaser.Math.FloatBetween(left, left + width + 2 * MARGIN), + Phaser.Math.FloatBetween(top, top + height + 2 * MARGIN), STAR_KEY, ); - const s = Phaser.Math.FloatBetween(0.4, 1.5); + + // Bigger stars are "closer" → they parallax more. + const s = Phaser.Math.FloatBetween(MIN_SCALE, MAX_SCALE); + const t = (s - MIN_SCALE) / (MAX_SCALE - MIN_SCALE); + const parallax = Phaser.Math.Linear(parMin, parMax, t); + star.parallax = parallax; + star .setScale(s) .setAlpha(Phaser.Math.FloatBetween(0.2, 0.85)) .setTint(colors[Phaser.Math.Between(0, colors.length - 1)]) - .setDepth(i % 3); // slight layering + .setDepth(Math.round(2 * t)); // 0 = far … 2 = near (ship draws above, depth 10) this.stars.push(star); } + + this.lastScrollX = cam.scrollX; + this.lastScrollY = cam.scrollY; } - update(delta) { + /** + * Shift the field opposite to the camera motion and wrap it back into + * the current view. Call once per frame, after the camera has moved. + */ + update() { if (!this.cfg.enabled || this.stars.length === 0) return; - const dt = Math.min(delta, 64) / 1000; - const width = this.scene.scale.width; - const height = this.scene.scale.height; + + const cam = this.scene.cameras.main; + const dx = cam.scrollX - this.lastScrollX; + const dy = cam.scrollY - this.lastScrollY; + this.lastScrollX = cam.scrollX; + this.lastScrollY = cam.scrollY; + if (dx === 0 && dy === 0) return; + + const spanX = this.scene.scale.width + 2 * MARGIN; + const spanY = this.scene.scale.height + 2 * MARGIN; + const left = cam.scrollX - MARGIN; + const top = cam.scrollY - MARGIN; for (const star of this.stars) { - star.x -= this.driftSpeed * star.scale * dt; - if (star.x < -4) { - star.x = width + 4; - star.y = Phaser.Math.Between(0, height); - } + // Move by (1 − p) of the camera delta, so on screen the star drifts + // by exactly −p of it: opposite to travel, near stars faster. + star.x += dx * (1 - star.parallax); + star.y += dy * (1 - star.parallax); + + // Keep the field full: wrap into the current camera window. + star.x = wrapIn(star.x, left, spanX); + star.y = wrapIn(star.y, top, spanY); } } @@ -72,3 +121,8 @@ export class Starfield { this.stars = []; } } + +/** Maps any coordinate into [left, left + span) — safe for huge deltas too. */ +function wrapIn(v, left, span) { + return left + ((((v - left) % span) + span) % span); +}