Add seed-deterministic planet frame pools

- Allow planet kinds to specify multiple sheet frames and pick one with a seed-derived Rng so the same galaxy always gets the same home world.
- Add Ethnocentric and Centauri font assets.
This commit is contained in:
Brian Fertig 2026-09-03 15:10:24 -06:00
parent e7b52cc33d
commit 02d689e1bb
7 changed files with 62 additions and 15 deletions

View File

@ -43,11 +43,12 @@ python3 -m http.server 8080
- 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 the current system's open 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 - **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 start in: a 1024 px disc rendered 1:1 from the `assets/images/planets.png`
`assets/images/planets.png`. The ship spawns ~150 px (edge-to-edge) off spritesheet. Which Terran face it shows (frames 02) is a
its rim in a seed-derived direction (same galaxy ⇒ same start), may fly seed-deterministic pick, so the same galaxy always yields the same home
in as close as 50 px from the rim, and can never cross it — a planet is world. The ship spawns ~150 px (edge-to-edge) off its rim in a
solid (tuning in `data/planets.json`) seed-derived direction, 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 - Camera gently trails the ship; the **parallax starfield** streams past
while it flies and the view slowly recenters (≈1.5 s) once the ship while it flies and the view slowly recenters (≈1.5 s) once the ship
comes to rest comes to rest

Binary file not shown.

Binary file not shown.

View File

@ -1,11 +1,11 @@
{ {
"_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.", "_comment": "Planet visuals + home-planet rules. texture is a spritesheet of frameWidth×frameHeight frames (frame 0 = top-left); frames maps a planet name to the sheet frames it may be drawn as — the generator picks one per planet (seed-deterministic). 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", "texture": "assets/images/planets.png",
"frameWidth": 1024, "frameWidth": 1024,
"frameHeight": 1024, "frameHeight": 1024,
"scale": 1.0, "scale": 1.0,
"frames": { "frames": {
"terran": 0 "terran": [0, 1, 2]
}, },
"homePlanet": "terran", "homePlanet": "terran",
"shipClearance": 50, "shipClearance": 50,

View File

@ -47,6 +47,7 @@ const check = (label, cond) => {
}; };
const { Planet } = await import(pathToFileURL(join(__dirname, '../js/entities/Planet.js')).href); const { Planet } = await import(pathToFileURL(join(__dirname, '../js/entities/Planet.js')).href);
const { Rng } = await import(pathToFileURL(join(__dirname, '../js/utils/Rng.js')).href);
// Off-origin on purpose: proves nothing is baked to (0, 0). // Off-origin on purpose: proves nothing is baked to (0, 0).
const planet = new Planet({ add: { existing: (o) => o } }, 100, -50, 0); const planet = new Planet({ add: { existing: (o) => o } }, 100, -50, 0);
@ -64,6 +65,33 @@ check(`texture key is the spritesheet (got "${planet.key}")`, planet.key === 'pl
check(`keep-out = radius + clearance + ship radius (got ${minDist}, want ${512 + clearance + shipRadius})`, check(`keep-out = radius + clearance + ship radius (got ${minDist}, want ${512 + clearance + shipRadius})`,
minDist === 512 + clearance + shipRadius); minDist === 512 + clearance + shipRadius);
// --- 1b. Frame pools: terran has 3 faces, pick is seed-deterministic ------
{
const pool = config.get('planets.frames.terran', []);
check('terran frame pool is [0, 1, 2] (got ' + JSON.stringify(pool) + ')',
JSON.stringify(pool) === JSON.stringify([0, 1, 2]));
// Same seed ⇒ same pick (worldgen determinism), and every pick is a
// member of the pool.
const a = Planet.frameFor('terran', Rng.derive('seed-x', 'planet', 'home'));
const b = Planet.frameFor('terran', Rng.derive('seed-x', 'planet', 'home'));
check(`frameFor is deterministic per seed (got ${a} and ${b})`, a === b && pool.includes(a));
// Across seeds, all three faces actually get picked (uniform pool).
const seen = new Set();
for (let i = 0; i < 90; i++) {
const f = Planet.frameFor('terran', Rng.derive('seed' + i, 'planet', 'home'));
if (!pool.includes(f)) { check(`frameFor stays in the pool (seed${i} got ${f})`, false); break; }
seen.add(f);
}
check(`frameFor covers the whole pool over seeds (got ${[...seen].sort().join(',')})`,
seen.size === pool.length);
// Unknown kinds / bad pools fall back to frame 0.
const fallback = Planet.frameFor('gasGiant', Rng.derive('seed-x', 'planet', 'home'));
check(`frameFor unknown kind falls back to 0 (got ${fallback})`, fallback === 0);
}
// --- 2. Outside the keep-out circle: untouched ---------------------------- // --- 2. Outside the keep-out circle: untouched ----------------------------
{ {
const s = ship(planet.x + minDist + 10, planet.y); const s = ship(planet.x + minDist + 10, planet.y);

View File

@ -4,7 +4,8 @@ import { config } from '../config/Config.js';
/** /**
* A planet: a static world rendered from the shared spritesheet * A planet: a static world rendered from the shared spritesheet
* (data/planets.json texture; `frameWidth`×`frameHeight` frames, frame 0 * (data/planets.json texture; `frameWidth`×`frameHeight` frames, frame 0
* is the top-left, and `frames` maps names like "terran" to frame indices). * is the top-left, and `frames` maps names like "terran" to the sheet
* frames that kind may be drawn as the generator picks one per planet).
* *
* A planet is a SOLID DISC. Its collision radius is half the scaled frame * 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 * (the world fills its frame a Terran world is 1024 px across at
@ -20,15 +21,30 @@ import { config } from '../config/Config.js';
export class Planet extends Phaser.GameObjects.Sprite { export class Planet extends Phaser.GameObjects.Sprite {
static TEXTURE_KEY = 'planets'; static TEXTURE_KEY = 'planets';
/**
* Pick a sheet frame for a planet of `name` from its pool in
* data/planets.json (`frames`), using the given Rng so the choice is
* seed-deterministic (same seed same world). A pool that is just a
* single number (or a missing/empty pool) falls back to that number,
* else 0.
*/
static frameFor(name, rng) {
const pool = config.get(`planets.frames.${name}`);
if (Array.isArray(pool) && pool.length > 0) return rng.pick(pool);
return Number.isFinite(pool) ? pool : 0;
}
/** /**
* @param {Phaser.Scene} scene * @param {Phaser.Scene} scene
* @param {number} x world x of the planet's center * @param {number} x world x of the planet's center
* @param {number} y world y * @param {number} y world y
* @param {number} [frame=0] spritesheet frame index (data/planets.json frames) * @param {number} [frame=0] spritesheet frame index (usually from Planet.frameFor)
* @param {string} [name=''] planet kind, e.g. 'terran' (data/planets.json frames)
*/ */
constructor(scene, x, y, frame = 0) { constructor(scene, x, y, frame = 0, name = '') {
super(scene, x, y, Planet.TEXTURE_KEY, frame); super(scene, x, y, Planet.TEXTURE_KEY, frame);
scene.add.existing(this); scene.add.existing(this);
this.name = name;
const scale = config.get('planets.scale', 1); const scale = config.get('planets.scale', 1);
this.setScale(scale); this.setScale(scale);

View File

@ -21,8 +21,8 @@ export class GameScene extends Phaser.Scene {
} }
preload() { preload() {
// The planet spritesheet: frameWidth×frameHeight frames, frame 0 = the // The planet spritesheet: frameWidth×frameHeight frames; the first
// Terran home world (more worlds slot into later frames). // frames are Terran worlds (see data/planets.json → frames).
this.load.spritesheet( this.load.spritesheet(
Planet.TEXTURE_KEY, Planet.TEXTURE_KEY,
config.get('planets.texture', 'assets/images/planets.png'), config.get('planets.texture', 'assets/images/planets.png'),
@ -44,10 +44,12 @@ export class GameScene extends Phaser.Scene {
this.createSystemHud(); this.createSystemHud();
// The home planet — the player's Terran world, always present in the // The home planet — the player's Terran world, always present in the
// system they start in. It sits at the world origin. // system they start in. It sits at the world origin. Which Terran face
// it shows is a seed-deterministic pick from the terran pool, so the
// same galaxy always yields the same home world.
const homeName = config.get('planets.homePlanet', 'terran'); const homeName = config.get('planets.homePlanet', 'terran');
const homeFrame = config.get(`planets.frames.${homeName}`, 0); const homeRng = Rng.derive(this.galaxy.seed, 'planet', 'home');
this.planet = new Planet(this, 0, 0, homeFrame); this.planet = new Planet(this, 0, 0, Planet.frameFor(homeName, homeRng), homeName);
this.planet.setDepth(5); // above the starfield (depths 02), below the ship (10) 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 // The ship — a short hop (~150 px, edge-to-edge) from the home world's