Ship Travel and Orientation Updates
This commit is contained in:
parent
eddbedbf1c
commit
7cf96bb237
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 acceleration→velocity→position the way the
|
* The harness integrates acceleration→velocity→position 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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue