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