Add the home planet: a solid world the ship can approach but not enter

Adds the player's home planet to the starting system, rendered from the new
planets.png spritesheet. The planet is a solid disc: the ship may come within
shipClearance of its rim but can never cross it, and clicks on the planet
clamp to the keep-out rim so the ship always has a reachable destination.

- js/entities/Planet.js: solid-disc entity with a circle keep-out constraint
  (constrainShip/resolve, pure and scene-free) plus edgePoint/aimPoint helpers
- data/planets.json (+ manifest entry): texture, frame size, scale, homePlanet,
  shipClearance, spawnDistanceFromEdge
- Ship.js: exposes a radius (half the hull width) for the keep-out math
- GameScene.js: loads the spritesheet, spawns the home world at the origin,
  starts the ship ~150 px off its rim in a seed-derived direction, and applies
  the constraint each frame after the ship moves
- dev/planet.test.mjs: Node tests of the constraint against real config;
  dev/test-game.html gains a <base href> so asset paths resolve from /dev
This commit is contained in:
Brian Fertig 2026-09-03 14:44:01 -06:00
parent 9d40103cce
commit e7b52cc33d
10 changed files with 392 additions and 14 deletions

View File

@ -41,7 +41,13 @@ python3 -m http.server 8080
The current system's dossier (name, identity, what's there) shows
top-left in the game scene.
- Game screen with a basic top-down ship: **click anywhere to fly there**
in infinite, unbounded space (system boundaries/jumps come next)
in the current system's open space (system boundaries/jumps come next)
- **The home planet** — the player's Terran world — in the system you
start in: a 1024 px disc rendered 1:1 from frame 0 of
`assets/images/planets.png`. The ship spawns ~150 px (edge-to-edge) off
its rim in a seed-derived direction (same galaxy ⇒ same start), may fly
in as close as 50 px from the rim, and can never cross it — a planet is
solid (tuning in `data/planets.json`)
- 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
@ -61,12 +67,13 @@ orbit/
│ ├── systems.json # system archetypes: theme, attributes, distribution
│ ├── settlements.json # the lived-in layer: settlement kinds & populations
│ └── naming.json # syllable pools for names
├── assets/images/ # art: planets.png (1024×1024 spritesheet frames)
├── lib/ # vendored third-party libs (Phaser 4.2.1)
├── js/
│ ├── main.js # entry point: load config → boot Phaser
│ ├── config/ # Config singleton, ConfigLoader, game config
│ ├── scenes/ # MenuScene, GameScene (thin, orchestration)
│ ├── entities/ # Ship (own behavior)
│ ├── entities/ # Ship (own behavior), Planet (home world, solid)
│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport
│ ├── ui/ # MenuButton (reusable)
│ ├── visuals/ # Starfield (decorative)

BIN
assets/images/planets.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

BIN
assets/images/planets.psd Normal file

Binary file not shown.

View File

@ -3,6 +3,7 @@
"game.json",
"menu.json",
"ship.json",
"planets.json",
"galaxy.json",
"systems.json",
"naming.json"

13
data/planets.json Normal file
View File

@ -0,0 +1,13 @@
{
"_comment": "Planet visuals + home-planet rules. texture is a spritesheet of frameWidth×frameHeight frames (frame 0 = top-left); frames maps a planet name to its frame index. homePlanet is the player's world — always present in the system the player starts in. scale = world pixels per sheet pixel (1.0 = 1:1, so a Terran world is 1024 px across on screen). A planet is a solid disc: the ship may approach to shipClearance px (edge-to-edge) from its rim but can never cross it. spawnDistanceFromEdge = how far (edge-to-edge) the ship starts from the home world's rim.",
"texture": "assets/images/planets.png",
"frameWidth": 1024,
"frameHeight": 1024,
"scale": 1.0,
"frames": {
"terran": 0
},
"homePlanet": "terran",
"shipClearance": 50,
"spawnDistanceFromEdge": 150
}

181
dev/planet.test.mjs Normal file
View File

@ -0,0 +1,181 @@
/**
* Planet test (dev tool, run with Node no browser needed):
*
* node dev/planet.test.mjs
*
* Stubs just enough of Phaser to construct the REAL Planet from
* js/entities/Planet.js against the real data/planets.json + ship.json
* config, then asserts the home-world rules:
* - the Terran world is 1024 px across at 1:1 (rim 512 px from center);
* - the ship can come to shipClearance px (edge-to-edge) from the rim,
* but never closer, and never moves through the planet;
* - a graze keeps its tangential velocity (slides along the rim);
* - edgePoint() places the spawn exactly spawnDistanceFromEdge off.
*/
import { pathToFileURL, fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
// --- Phaser stub: just the Sprite base class -----------------------------
class Sprite {
constructor(scene, x, y, key, frame) {
this.scene = scene; this.x = x; this.y = y;
this.key = key; this.frame = frame;
this.scaleX = 1; this.scaleY = 1;
}
setScale(s) { this.scaleX = s; this.scaleY = s; return this; }
}
globalThis.window = { Phaser: { GameObjects: { Sprite } } }; // js/vendor/phaser.js reads this
// --- Load the real config (data/*.json) into the config singleton --------
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);
console.log('config loaded from data/:', Object.keys(configData).join(', '));
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
const { Planet } = await import(pathToFileURL(join(__dirname, '../js/entities/Planet.js')).href);
// Off-origin on purpose: proves nothing is baked to (0, 0).
const planet = new Planet({ add: { existing: (o) => o } }, 100, -50, 0);
const shipRadius = (config.get('ship.size', 46) * config.get('ship.scale', 1)) / 2;
const clearance = planet.clearance;
const minDist = planet.minCenterDistance(shipRadius);
const ship = (x, y, vx = 0, vy = 0) => ({ x, y, body: { velocity: { x: vx, y: vy }, acceleration: { x: 0, y: 0 } } });
const dist = (p, c = planet) => Math.hypot(p.x - c.x, p.y - c.y);
// --- 1. Real config: 1:1 Terran world + keep-out distances ---------------
check(`1:1 world: planet radius is 512 px (got ${planet.radius})`, planet.radius === 512);
check(`frame 0 is the Terran world (got frame ${planet.frame})`, planet.frame === 0);
check(`texture key is the spritesheet (got "${planet.key}")`, planet.key === 'planets');
check(`keep-out = radius + clearance + ship radius (got ${minDist}, want ${512 + clearance + shipRadius})`,
minDist === 512 + clearance + shipRadius);
// --- 2. Outside the keep-out circle: untouched ----------------------------
{
const s = ship(planet.x + minDist + 10, planet.y);
planet.constrainShip(s, shipRadius);
check('outside the keep-out circle: ship untouched',
s.x === planet.x + minDist + 10 && s.y === planet.y);
}
// Exactly on the boundary: allowed ("within 50 px" includes 50 px).
{
const s = ship(planet.x + minDist, planet.y, -100, 0); // on the line, moving in
planet.constrainShip(s, shipRadius);
const vn = s.body.velocity.x; // normal here is +x
check('on the boundary with inward velocity: position held, inward velocity removed',
dist(s) === minDist && vn >= 0);
}
// --- 3. Inside: pushed out to exactly the keep-out line -------------------
{
const s = ship(planet.x + 100, planet.y);
planet.constrainShip(s, shipRadius);
check(`inside: pushed out to the keep-out line (dist=${dist(s).toFixed(2)}, want ${minDist})`,
Math.abs(dist(s) - minDist) < 1e-9);
}
// --- 4. Inward velocity removed, tangential kept (a graze slides) ---------
{
const s = ship(planet.x + 200, planet.y, -100, -300); // inside, heading into the planet
planet.constrainShip(s, shipRadius);
check('inside + inward velocity: pushed out, inward component removed (vx=0)',
Math.abs(dist(s) - minDist) < 1e-9 && s.body.velocity.x === 0);
check('tangential velocity kept (vy=-300)', s.body.velocity.y === -300);
}
// --- 4b. Inward ACCELERATION removed too (the world integrates it after) --
{
// The arcade world steps AFTER this constraint runs, so an inward
// acceleration would push the ship back inside a fraction of a pixel on
// the very next step. It must be stripped, tangential kept.
const s = ship(planet.x + 200, planet.y, 0, 0);
s.body.acceleration.x = -500; // throttling into the planet
s.body.acceleration.y = -300; // …and a tangential component
planet.constrainShip(s, shipRadius);
check('inward acceleration stripped (ax=0), tangential kept (ay=-300)',
s.body.acceleration.x === 0 && s.body.acceleration.y === -300);
// …so the next physics step can only carry the ship along the rim.
const step = 16.67 / 1000;
s.x += s.body.velocity.x * step + s.body.acceleration.x * step * step;
s.y += s.body.velocity.y * step + s.body.acceleration.y * step * step;
check('next physics step keeps the ship at the clearance or farther',
dist(s) >= minDist - 1e-9);
}
// --- 4c. aimPoint(): clicks inside the planet clamp to the keep-out rim ---
{
// Inside → projected out onto the rim, along the ray from the center.
const inAim = planet.aimPoint(planet.x + 100, planet.y + 300, shipRadius);
check('aimPoint inside: lands exactly on the keep-out line',
Math.abs(dist(inAim) - minDist) < 1e-9);
const dirOk = Math.abs((inAim.x - planet.x) / 100 - (inAim.y - planet.y) / 300) < 1e-12;
check('aimPoint inside: stays on the ray from the center', dirOk);
// Outside → unchanged.
const outAim = planet.aimPoint(planet.x + minDist + 5, planet.y - 20, shipRadius);
check('aimPoint outside: passes through unchanged',
outAim.x === planet.x + minDist + 5 && outAim.y === planet.y - 20);
// Dead center → pushed out along +x, no NaN.
const cAim = planet.aimPoint(planet.x, planet.y, shipRadius);
check('aimPoint at the center: +x on the rim, no NaN',
Number.isFinite(cAim.x) && Math.abs(dist(cAim) - minDist) < 1e-9 && cAim.x === planet.x + minDist);
}
// --- 5. Full approach at max ship speed, frame by frame ------------------
{
// One 60 fps frame at the ship's maxSpeed (data/ship.json), closing in
// dead-on: the ship must never end a frame closer than the clearance,
// and must come to rest exactly on the keep-out line.
let s = ship(planet.x + 1500, planet.y, -480, 0);
let worst = Infinity;
for (let i = 0; i < 200 && s.body.velocity.x < 0; i++) {
s.x += s.body.velocity.x * (16.67 / 1000); // world integration
s.y += s.body.velocity.y * (16.67 / 1000);
planet.constrainShip(s, shipRadius);
worst = Math.min(worst, dist(s) - planet.radius - shipRadius);
}
check(`frame-by-frame approach at max speed: never closer than the clearance (worst edge gap ${worst.toFixed(2)} px)`,
worst >= clearance - 1e-9);
check('...and comes to rest on the keep-out line with no inward speed',
Math.abs(dist(s) - minDist) < 1e-9 && s.body.velocity.x === 0);
}
// --- 6. Degenerate: ship dead at the planet's center ----------------------
{
const s = ship(planet.x, planet.y);
planet.constrainShip(s, shipRadius);
check('at the center: pushed out along +x, no NaN',
Number.isFinite(s.x) && Number.isFinite(s.y) && Math.abs(dist(s) - minDist) < 1e-9
&& s.x === planet.x + minDist && s.y === planet.y);
}
// --- 7. edgePoint(): spawn is exactly spawnDistanceFromEdge off the rim ---
{
const gap = config.get('planets.spawnDistanceFromEdge', 150);
let ok = true;
for (const a of [0, 0.7, 2.4, 4.2]) {
const p = planet.edgePoint(a, gap, shipRadius);
const edgeGap = dist(p) - planet.radius - shipRadius;
if (Math.abs(edgeGap - gap) > 1e-9) ok = false;
}
check(`edgePoint puts the ship exactly ${gap} px (edge-to-edge) off the rim, any direction`, ok);
}
console.log(failures === 0 ? '\nAll planet tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -3,14 +3,18 @@
<head>
<meta charset="utf-8" />
<title>Orbit — dev smoke test (GameScene)</title>
<!-- This page lives in /dev, but the game's relative asset paths are rooted
at the project root — resolve them against it. ("../" keeps this
working even if the project is served from a subdirectory.) -->
<base href="../" />
<style>
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
</style>
<script src="../lib/phaser.min.js"></script>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<div id="game"></div>
<script type="module" src="smoke-game.mjs"></script>
<script type="module" src="dev/smoke-game.mjs"></script>
</body>
</html>

131
js/entities/Planet.js Normal file
View File

@ -0,0 +1,131 @@
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
/**
* A planet: a static world rendered from the shared spritesheet
* (data/planets.json texture; `frameWidth`×`frameHeight` frames, frame 0
* is the top-left, and `frames` maps names like "terran" to frame indices).
*
* A planet is a SOLID DISC. Its collision radius is half the scaled frame
* (the world fills its frame a Terran world is 1024 px across at
* scale 1.0), and the ship is kept `shipClearance` px (edge-to-edge) off
* the rim: it can come that close, but it can never move through the
* planet or any closer.
*
* The keep-out rule is a plain circle test (constrainShip static
* resolve) applied to the ship after it moves, so it never interferes
* with the ship's own flight model and stays testable in Node
* (dev/planet.test.mjs) without a scene.
*/
export class Planet extends Phaser.GameObjects.Sprite {
static TEXTURE_KEY = 'planets';
/**
* @param {Phaser.Scene} scene
* @param {number} x world x of the planet's center
* @param {number} y world y
* @param {number} [frame=0] spritesheet frame index (data/planets.json frames)
*/
constructor(scene, x, y, frame = 0) {
super(scene, x, y, Planet.TEXTURE_KEY, frame);
scene.add.existing(this);
const scale = config.get('planets.scale', 1);
this.setScale(scale);
// Collision circle: frames are square and the world fills its frame,
// so the rim is half the (scaled) frame width from the center.
this.radius = (config.get('planets.frameWidth', 1024) * scale) / 2;
// Edge-to-edge gap the ship may close in on the rim (never less).
this.clearance = config.get('planets.shipClearance', 50);
}
/** Minimum allowed center-to-center distance for a ship of `shipRadius`. */
minCenterDistance(shipRadius = 0) {
return this.radius + this.clearance + shipRadius;
}
/**
* A world point `gap` px (edge-to-edge) off this planet's rim at
* `angle` radians e.g. where to spawn the ship near the home world.
*/
edgePoint(angle, gap, shipRadius = 0) {
const d = this.radius + gap + shipRadius;
return { x: this.x + Math.cos(angle) * d, y: this.y + Math.sin(angle) * d };
}
/**
* A world point the ship may be sent to: a point inside the keep-out
* circle (e.g. a click on the planet itself, or through it) is projected
* out onto the rim, along the ray from the center so the ship always
* has a reachable destination and is never told to go inside. Points
* already outside pass through unchanged.
*/
aimPoint(wx, wy, shipRadius = 0) {
const minDist = this.minCenterDistance(shipRadius);
const dx = wx - this.x;
const dy = wy - this.y;
const dist = Math.hypot(dx, dy);
if (dist >= minDist) return { x: wx, y: wy };
if (dist === 0) return { x: this.x + minDist, y: this.y }; // dead center: +x
return { x: this.x + (dx / dist) * minDist, y: this.y + (dy / dist) * minDist };
}
/**
* Keep a ship (anything with x, y and body.velocity) out of the planet:
* if it is inside the keep-out circle its center is moved out to
* `minCenterDistance` and the inward part of its velocity AND
* acceleration is removed the tangential part is kept, so a near-miss
* slides along the rim instead of sticking. Stripping the acceleration
* matters: the arcade world integrates it AFTER this runs, so without
* it the ship would be pushed back inside a fraction of a pixel on the
* very next physics step. A ship already outside is untouched; one
* riding exactly on the circle keeps its position but loses its inward
* speed, so contact is clean (no in/out jitter).
*/
constrainShip(ship, shipRadius = 0) {
const minDist = this.minCenterDistance(shipRadius);
const body = ship.body;
const r = Planet.resolve(
this.x, this.y, minDist,
ship.x, ship.y,
body.velocity.x, body.velocity.y,
body.acceleration ? body.acceleration.x : 0,
body.acceleration ? body.acceleration.y : 0,
);
ship.x = r.x;
ship.y = r.y;
body.velocity.x = r.vx;
body.velocity.y = r.vy;
if (body.acceleration) {
body.acceleration.x = r.ax;
body.acceleration.y = r.ay;
}
}
/**
* The pure circle constraint (static so it can be tested without a
* scene): clamps a point at least `minDist` from (cx, cy) and removes
* the components of velocity and acceleration pointing into the circle.
*/
static resolve(cx, cy, minDist, x, y, vx, vy, ax = 0, ay = 0) {
const dx = x - cx;
const dy = y - cy;
const dist = Math.hypot(dx, dy);
if (dist > minDist) return { x, y, vx, vy, ax, ay };
let nx;
let ny;
if (dist === 0) { nx = 1; ny = 0; } // dead center: push out along +x
else { nx = dx / dist; ny = dy / dist; }
const ox = cx + nx * minDist;
const oy = cy + ny * minDist;
// Strip any component pointing into the circle (keep the tangential).
const strip = (v) => {
const vn = v[0] * nx + v[1] * ny;
return vn < 0 ? [v[0] - vn * nx, v[1] - vn * ny] : [v[0], v[1]];
};
const [rvx, rvy] = strip([vx, vy]);
const [rax, ray] = strip([ax, ay]);
return { x: ox, y: oy, vx: rvx, vy: rvy, ax: rax, ay: ray };
}
}

View File

@ -56,6 +56,9 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
this.arriveRadius = config.get('ship.arriveRadius', 8); // px
this.arriveSpeed = config.get('ship.arriveSpeed', 50); // px/s
this.setScale(config.get('ship.scale', 1));
// Collision radius (half the hull's width, at scale) — planets keep
// the ship this far off their rims (see Planet.constrainShip).
this.radius = (config.get('ship.size', 46) * config.get('ship.scale', 1)) / 2;
this.target = null;
}

View File

@ -5,12 +5,14 @@ import { Rng } from '../utils/Rng.js';
import { Galaxy } from '../galaxy/Galaxy.js';
import { formatSystemReport } from '../galaxy/SystemReport.js';
import { Ship } from '../entities/Ship.js';
import { Planet } from '../entities/Planet.js';
import { Starfield } from '../visuals/Starfield.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
/**
* The game world (v0.2: one ship in the current system's open space).
* The game world (v0.3: the home planet the player's Terran world
* in the current system's open space).
* Click anywhere to fly there.
*/
export class GameScene extends Phaser.Scene {
@ -18,21 +20,52 @@ export class GameScene extends Phaser.Scene {
super({ key: 'GameScene' });
}
preload() {
// The planet spritesheet: frameWidth×frameHeight frames, frame 0 = the
// Terran home world (more worlds slot into later frames).
this.load.spritesheet(
Planet.TEXTURE_KEY,
config.get('planets.texture', 'assets/images/planets.png'),
{
frameWidth: config.get('planets.frameWidth', 1024),
frameHeight: config.get('planets.frameHeight', 1024),
},
);
}
create() {
// 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);
// We are, after all, in a system. Show the player which one.
// We are, after all, in a system. Show the player which one. (This
// also establishes the galaxy — and its seed — for what follows.)
this.createSystemHud();
// The home planet — the player's Terran world, always present in the
// system they start in. It sits at the world origin.
const homeName = config.get('planets.homePlanet', 'terran');
const homeFrame = config.get(`planets.frames.${homeName}`, 0);
this.planet = new Planet(this, 0, 0, homeFrame);
this.planet.setDepth(5); // above the starfield (depths 02), below the ship (10)
// The ship — a short hop (~150 px, edge-to-edge) from the home world's
// rim, in a seed-derived direction: same galaxy ⇒ same start.
this.ship = new Ship(this, 0, 0);
this.ship.setDepth(10);
const spawn = this.planet.edgePoint(
Rng.derive(this.galaxy.seed, 'spawn', 'ship').range(0, Math.PI * 2),
config.get('planets.spawnDistanceFromEdge', 150),
this.ship.radius,
);
this.ship.setPosition(spawn.x, spawn.y);
// Center the camera on the ship from the very first frame.
this.cameras.main.setScroll(-this.scale.width / 2, -this.scale.height / 2);
this.cameras.main.setScroll(
this.ship.x - this.scale.width / 2,
this.ship.y - this.scale.height / 2,
);
// Background (stars are placed around the current camera view).
this.starfield = new Starfield(this);
@ -48,10 +81,12 @@ export class GameScene extends Phaser.Scene {
.setOrigin(0.5)
.setScrollFactor(0); // UI: pinned to the screen, not the world
// Input: click = fly there
// Input: click = fly there. A click inside the planet clamps to the
// keep-out rim — the ship can stop at the clearance, never inside.
this.input.on('pointerdown', (pointer) => {
this.showTargetMarker(pointer.worldX, pointer.worldY);
this.ship.setTarget(pointer.worldX, pointer.worldY);
const aim = this.planet.aimPoint(pointer.worldX, pointer.worldY, this.ship.radius);
this.showTargetMarker(aim.x, aim.y);
this.ship.setTarget(aim.x, aim.y);
this.hideHint();
});
}
@ -112,6 +147,9 @@ export class GameScene extends Phaser.Scene {
update(_time, delta) {
this.ship.update(_time, delta);
// The home world is solid: the ship may come within the clearance in
// data/planets.json of its rim, but never closer (or through it).
this.planet.constrainShip(this.ship, this.ship.radius);
this.updateCamera(delta);
this.starfield.update(); // after the camera, so it sees this frame's motion
}