Ship Travel and Orientation Updates

This commit is contained in:
Brian Fertig 2026-09-03 10:25:54 -06:00
parent eddbedbf1c
commit 7cf96bb237
8 changed files with 395 additions and 37 deletions

View File

@ -20,7 +20,10 @@ python3 -m http.server 8080
## Current state — v0.1 foundation ## Current state — v0.1 foundation
- Main menu with a **New Game** button - 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` - Config-driven setup: every tunable value lives in `data/*.json`
## Project layout ## Project layout
@ -58,6 +61,7 @@ orbit/
```sh ```sh
node dev/ship-behavior.test.mjs # runs the real Ship.update() loop in Node 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), `dev/test-game.html` boots straight into the GameScene (no menu click),

View File

@ -12,8 +12,12 @@
"debug": false, "debug": false,
"starfield": { "starfield": {
"enabled": true, "enabled": true,
"count": 180, "count": 200,
"driftSpeed": 14, "parallax": [0.15, 0.85],
"colors": ["#ffffff", "#b9c6ff", "#8090b8"] "colors": ["#ffffff", "#b9c6ff", "#8090b8"]
},
"camera": {
"followShip": true,
"followRate": 3.0
} }
} }

View File

@ -5,8 +5,9 @@
* *
* Stubs just enough of Phaser + a scene to run the REAL Ship.update() * Stubs just enough of Phaser + a scene to run the REAL Ship.update()
* loop from js/entities/Ship.js, then asserts the flight feel: * loop from js/entities/Ship.js, then asserts the flight feel:
* arrives and stops, respects maxSpeed, tracks its heading, can be * arrives and stops, respects maxSpeed, tracks its heading, stops facing
* re-targeted mid-flight, coasts to rest, hard-brakes on stop(). * the direction it was traveling, can be re-targeted mid-flight, coasts
* to rest, hard-brakes on stop().
* *
* The harness integrates accelerationvelocityposition the way the * The harness integrates accelerationvelocityposition the way the
* Arcade physics world does (after the scene's update). * Arcade physics world does (after the scene's update).
@ -172,5 +173,62 @@ const integrate = (ship) => {
ship.target === null && ship.body.velocity.length() === 0); 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 ✘`); console.log(failures === 0 ? '\nAll ship behavior tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1); process.exit(failures === 0 ? 0 : 1);

191
dev/starfield.test.mjs Normal file
View File

@ -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);

View File

@ -73,8 +73,9 @@ trading/economy, stations, quests — to be scoped as we go.
## Roadmap (working list, intentionally rough) ## Roadmap (working list, intentionally rough)
- [x] v0.1 foundation — menu → New Game → click-to-fly ship - [x] v0.1 foundation — menu → New Game → click-to-fly ship
- [ ] Decide the world model: bounded sectors vs. infinite space (affects - [ ] Decide the world model: **infinite open space for now** (ship flies
camera, starfield, and world gen) unbounded; borders/sectors planned for later) — affects camera,
starfield, and world gen
- [ ] Procedural star map / sector generation (driven by `data/sectors.json`) - [ ] Procedural star map / sector generation (driven by `data/sectors.json`)
- [ ] Ship input beyond click-to-fly (throttle/brake keys, manual rotation) - [ ] Ship input beyond click-to-fly (throttle/brake keys, manual rotation)
- [ ] HUD (speed, sector name, later: fuel/crew) - [ ] HUD (speed, sector name, later: fuel/crew)

View File

@ -46,7 +46,6 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
scene.add.existing(this); scene.add.existing(this);
scene.physics.add.existing(this); scene.physics.add.existing(this);
this.setCollideWorldBounds(true);
// Tuning (data/ship.json) ----------------------------------------- // Tuning (data/ship.json) -----------------------------------------
this.thrust = config.get('ship.thrust', 900); // px/s^2 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; this.target = null;
// Clear acceleration too, so the physics step can't re-kick us. // Clear acceleration too, so the physics step can't re-kick us.
body.acceleration.set(0, 0); 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); body.velocity.set(0, 0);
return; return;
} }
@ -96,8 +99,15 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
const nx = dx / dist; const nx = dx / dist;
const ny = dy / dist; const ny = dy / dist;
// Ease off the throttle as we close in, so we arrive gently. // Braking: with the throttle cut, drag carries the ship a further
const throttle = Phaser.Math.Clamp(dist / this.brakeDistance, 0.15, 1); // 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); body.acceleration.set(nx * this.thrust * throttle, ny * this.thrust * throttle);
// Gentle drag so we never coast forever. // 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); 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 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); const step = Math.min(Math.abs(wanted - current), this.rotSpeed * dt);
this.rotation = Phaser.Math.Angle.RotateTo(current, wanted, step); this.rotation = Phaser.Math.Angle.RotateTo(current, wanted, step);
} else { } else {

View File

@ -16,14 +16,22 @@ export class GameScene extends Phaser.Scene {
} }
create() { 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 = new Starfield(this);
this.starfield.create(); this.starfield.create();
// The ship
this.ship = new Ship(this, this.scale.width / 2, this.scale.height / 2);
this.ship.setDepth(10);
// Hint // Hint
this.hint = this.add this.hint = this.add
.text(this.scale.width / 2, this.scale.height - 26, config.get('game.hintText', ''), { .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', fontSize: '14px',
color: '#54608a', color: '#54608a',
}) })
.setOrigin(0.5); .setOrigin(0.5)
.setScrollFactor(0); // UI: pinned to the screen, not the world
// Input: click = fly there // Input: click = fly there
this.input.on('pointerdown', (pointer) => { this.input.on('pointerdown', (pointer) => {
@ -42,8 +51,31 @@ export class GameScene extends Phaser.Scene {
} }
update(_time, delta) { update(_time, delta) {
this.starfield.update(delta);
this.ship.update(_time, 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) { showTargetMarker(x, y) {

View File

@ -3,18 +3,35 @@ import { config } from '../config/Config.js';
import { toColor } from '../utils/Color.js'; import { toColor } from '../utils/Color.js';
const STAR_KEY = '__star'; 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. * Decorative parallax starfield for infinite space.
* Tuned by data/game.json starfield { enabled, count, driftSpeed, colors }. *
* Purely visual: no physics, no input. * 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 { export class Starfield {
constructor(scene) { constructor(scene) {
this.scene = scene; this.scene = scene;
this.cfg = config.get('game.starfield', {}); 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.stars = [];
this.lastScrollX = 0;
this.lastScrollY = 0;
} }
create() { create() {
@ -23,6 +40,7 @@ export class Starfield {
const scene = this.scene; const scene = this.scene;
const width = scene.scale.width; const width = scene.scale.width;
const height = scene.scale.height; const height = scene.scale.height;
const cam = scene.cameras.main;
// Tiny 4x4 white dot, shared by every star. // Tiny 4x4 white dot, shared by every star.
if (!scene.textures.exists(STAR_KEY)) { if (!scene.textures.exists(STAR_KEY)) {
@ -33,37 +51,68 @@ export class Starfield {
g.destroy(); 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 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++) { for (let i = 0; i < count; i++) {
const star = scene.add.image( const star = scene.add.image(
Phaser.Math.Between(0, width), Phaser.Math.FloatBetween(left, left + width + 2 * MARGIN),
Phaser.Math.Between(0, height), Phaser.Math.FloatBetween(top, top + height + 2 * MARGIN),
STAR_KEY, 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 star
.setScale(s) .setScale(s)
.setAlpha(Phaser.Math.FloatBetween(0.2, 0.85)) .setAlpha(Phaser.Math.FloatBetween(0.2, 0.85))
.setTint(colors[Phaser.Math.Between(0, colors.length - 1)]) .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.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; if (!this.cfg.enabled || this.stars.length === 0) return;
const dt = Math.min(delta, 64) / 1000;
const width = this.scene.scale.width; const cam = this.scene.cameras.main;
const height = this.scene.scale.height; 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) { for (const star of this.stars) {
star.x -= this.driftSpeed * star.scale * dt; // Move by (1 p) of the camera delta, so on screen the star drifts
if (star.x < -4) { // by exactly p of it: opposite to travel, near stars faster.
star.x = width + 4; star.x += dx * (1 - star.parallax);
star.y = Phaser.Math.Between(0, height); 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 = []; 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);
}