diff --git a/README.md b/README.md index 8333866..a3b61a7 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/assets/images/planets.png b/assets/images/planets.png new file mode 100644 index 0000000..637a86c Binary files /dev/null and b/assets/images/planets.png differ diff --git a/assets/images/planets.psd b/assets/images/planets.psd new file mode 100644 index 0000000..d305227 Binary files /dev/null and b/assets/images/planets.psd differ diff --git a/data/manifest.json b/data/manifest.json index 2162fc4..b1c727b 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -3,6 +3,7 @@ "game.json", "menu.json", "ship.json", + "planets.json", "galaxy.json", "systems.json", "naming.json" diff --git a/data/planets.json b/data/planets.json new file mode 100644 index 0000000..a205f5d --- /dev/null +++ b/data/planets.json @@ -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 +} diff --git a/dev/planet.test.mjs b/dev/planet.test.mjs new file mode 100644 index 0000000..a8ec670 --- /dev/null +++ b/dev/planet.test.mjs @@ -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); diff --git a/dev/test-game.html b/dev/test-game.html index 60f4b1e..1b632c4 100644 --- a/dev/test-game.html +++ b/dev/test-game.html @@ -3,14 +3,18 @@ Orbit — dev smoke test (GameScene) + + - +
- + diff --git a/js/entities/Planet.js b/js/entities/Planet.js new file mode 100644 index 0000000..bccff64 --- /dev/null +++ b/js/entities/Planet.js @@ -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 }; + } +} diff --git a/js/entities/Ship.js b/js/entities/Ship.js index 811fc58..0127cb6 100644 --- a/js/entities/Ship.js +++ b/js/entities/Ship.js @@ -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; } diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 96cadcd..c261bde 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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 0–2), 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 }