Files
SpaceWanderer/lib/game/world.ts
T

228 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Pure, seeded world generation — no DB, no Date, no Math.random. The same seed always
// yields the same world, which is what makes unit tests (and save regeneration) deterministic.
export interface WorldSystem {
name: string
x: number // 0–100 coordinate space, rendered directly by NodeGraph
y: number
}
export const START_X = 50
export const START_Y = 50
const START_RING = 6 // neighbours around the starting system on a new game
const EXPAND_RING = 4 // new neighbours generated when jumping into an undiscovered system
const MIN_SPACING = 16 // minimum distance between any two systems in the 0–100 space
// mulberry32 — small fast seeded PRNG; plenty for name/position picking.
export function mulberry32(seed: number): () => number {
let a = seed >>> 0
return () => {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
const FIRST = [
'KEPLER', 'VESPER', 'HOLLOW', 'CINDER', 'LANTERN', 'SABLE', 'ORPHEUS', 'MIRE',
'THALASSA', 'KUIPER', 'AURELLE', 'BRIMSTONE', 'EMBER', 'FROST', 'GALE', 'IRON',
]
const SECOND = ['DRIFT', 'REACH', 'GATE', 'FIELD', 'VOID', 'DEEP', 'BEND', 'SHARD']
function title(word: string): string {
return word.charAt(0) + word.slice(1).toLowerCase()
}
// Picks a name not in `taken` and adds it, so callers can't accidentally reuse one.
export function makeName(rng: () => number, taken: Set<string>): string {
for (;;) {
const name = `${title(FIRST[Math.floor(rng() * FIRST.length)])} ${title(SECOND[Math.floor(rng() * SECOND.length)])}`
if (!taken.has(name)) {
taken.add(name)
return name
}
}
}
function clamp(v: number): number {
return Math.min(96, Math.max(4, v))
}
// Places `count` systems on a ring around (cx,cy), nudging each candidate until it is at
// least MIN_SPACING away from every known position. After 12 tries it accepts the last
// candidate anyway — ponytail: a slightly crowded node beats an infinite loop; with ≤6
// rings per region and spacing 16 in a 0–100 box this never triggers in practice.
function placeRing(
rng: () => number,
cx: number,
cy: number,
count: number,
takenNames: Set<string>,
takenPositions: WorldSystem[],
): WorldSystem[] {
const placed: WorldSystem[] = []
for (let i = 0; i < count; i++) {
const baseAngle = (i / count) * Math.PI * 2 + (rng() - 0.5) * 0.6
let x = cx
let y = cy
for (let attempt = 0; attempt < 12; attempt++) {
const angle = baseAngle + attempt * 0.45
const radius = 26 + rng() * 8
x = clamp(cx + Math.cos(angle) * radius)
y = clamp(cy + Math.sin(angle) * radius)
if ([...takenPositions, ...placed].every((p) => Math.hypot(p.x - x, p.y - y) >= MIN_SPACING)) break
}
placed.push({ name: makeName(rng, takenNames), x, y })
}
return placed
}
// Edges for a ring of n members around an anchor at index 0: anchor↔each member plus the
// cycle connecting consecutive members (so every system has ≥2 routes and you can always
// get back). Pairs are indices into [anchor, ...members].
function ringEdges(n: number): [number, number][] {
const edges: [number, number][] = []
for (let i = 0; i < n; i++) {
edges.push([0, i + 1])
edges.push([i + 1, ((i + 1) % n) + 1])
}
return edges
}
export interface StartRegion {
start: WorldSystem
neighbors: WorldSystem[]
edges: [number, number][] // indices into [start, ...neighbors]
}
// New game: the starting system at chart centre plus a ring of undiscovered neighbours.
export function generateStartRegion(rng: () => number): StartRegion {
const taken = new Set<string>()
const start: WorldSystem = { name: makeName(rng, taken), x: START_X, y: START_Y }
const neighbors = placeRing(rng, START_X, START_Y, START_RING, taken, [start])
return { start, neighbors, edges: ringEdges(neighbors.length) }
}
export interface RingRegion {
systems: WorldSystem[]
edges: [number, number][] // indices into [anchor, ...systems] — index 0 is the anchor itself
}
// Jumping into an undiscovered system grows the chart: a fresh ring of (undiscovered)
// neighbours around it. `takenNames`/`takenPositions` are every system already in the save.
export function generateRing(
rng: () => number,
anchorX: number,
anchorY: number,
takenNames: Set<string>,
takenPositions: WorldSystem[],
): RingRegion {
const systems = placeRing(rng, anchorX, anchorY, EXPAND_RING, takenNames, takenPositions)
return { systems, edges: ringEdges(systems.length) }
}
// --- Bodies (planets + moons) and exploration -------------------------------
export interface WorldMoon {
name: string
type: string
hazard: boolean
}
export interface WorldPlanet {
name: string
type: string
hazard: boolean
moons: WorldMoon[]
}
// Derive an independent PRNG stream for one entity from the save seed, so a system's bodies
// and a planet's exploration haul are reproducible offline (tests recompute them directly).
export function bodyRng(seed: string, systemId: number): () => number {
return mulberry32((Number(seed) + Math.imul(systemId, 0x9e3779b1)) >>> 0)
}
export function explorationRng(seed: string, planetId: number): () => number {
return mulberry32((Number(seed) + Math.imul(planetId, 0x85ebca6b)) >>> 0)
}
const BODY_NAMES = [
'AURELLE', 'BRIMSTONE', 'CINDERROCK', 'DRIFTBAND', 'PALEMOON', 'ASHFALL', 'VESPERINE', 'KORR',
'MIRELLA', 'SABLET', 'EMBERLING', 'FROSTHOLD', 'GALEN', 'IRONVEIN', 'JUNO', 'KESTREL',
'LUMEN', 'NADIR', 'OPALINE', 'PYRE', 'QUILL', 'RUSTBARK', 'SIROCCO', 'TARN',
'UMBRA', 'VELVETINE', 'WYRM', 'ZEPHYR', 'HALCYON', 'MORROW', 'ONYX', 'THALASSA',
]
const PLANET_TYPES = ['TERRESTRIAL', 'GAS GIANT', 'ROCKY', 'ICE WORLD', 'VOLCANIC', 'OCEAN', 'ASTEROID FIELD']
const MOON_TYPES = ['MOON', 'ICE MOON', 'VOLCANIC MOON']
const HAZARD_CHANCE = 0.25
const MOON_CHANCE = 0.6 // a planet has any moons at all
const PLANET_COUNT_MIN = 2
const PLANET_COUNT_SPREAD = 4 // 2..5 planets per system
// A body name unique within its system (planets and moons share one pool). Max bodies per
// system is 5 + 5*3 = 20 < BODY_NAMES.length, so the loop always terminates.
function makeBodyName(rng: () => number, taken: Set<string>): string {
for (;;) {
const name = title(BODY_NAMES[Math.floor(rng() * BODY_NAMES.length)])
if (!taken.has(name)) {
taken.add(name)
return name
}
}
}
function pick<T>(rng: () => number, arr: T[]): T {
return arr[Math.floor(rng() * arr.length)]
}
// The bodies of one system: 2–5 planets, some with 1–3 moons. Pure — the caller derives the
// rng from (save seed, system id) so a given system always gets the same bodies.
export function generatePlanets(rng: () => number): WorldPlanet[] {
const taken = new Set<string>()
const planets: WorldPlanet[] = []
for (let i = 0; i < PLANET_COUNT_MIN + Math.floor(rng() * PLANET_COUNT_SPREAD); i++) {
const name = makeBodyName(rng, taken)
const type = pick(rng, PLANET_TYPES)
const hazard = rng() < HAZARD_CHANCE
const moons: WorldMoon[] = []
if (rng() < MOON_CHANCE) {
for (let m = 0; m < 1 + Math.floor(rng() * 3); m++) {
moons.push({ name: makeBodyName(rng, taken), type: pick(rng, MOON_TYPES), hazard: rng() < HAZARD_CHANCE })
}
}
planets.push({ name, type, hazard, moons })
}
return planets
}
export interface FoundItem {
kind: 'resource' | 'part'
name: string
qty: number
}
const RESOURCE_NAMES = ['IRON ORE', 'CRYSTAL SHARDS', 'GAS CELLS', 'WATER ICE', 'RARE EARTH']
const PART_NAMES = ['NAV MODULE', 'HULL PLATE', 'REACTOR CORE', 'COMMS ARRAY', 'ENGINE TUNING']
const RESOURCE_CHANCE = 0.7
const PART_CHANCE = 0.3 // parts are rarer than resources
// The haul from scanning one body: a resource and/or a part, or nothing (~21% of scans).
// Pure — the caller derives the rng from (save seed, planet id), so the same save+planet
// always yields the same outcome.
export function rollExploration(rng: () => number): FoundItem[] {
const found: FoundItem[] = []
if (rng() < RESOURCE_CHANCE) {
found.push({ kind: 'resource', name: pick(rng, RESOURCE_NAMES), qty: 1 + Math.floor(rng() * 4) }) // 1..4
}
if (rng() < PART_CHANCE) {
found.push({ kind: 'part', name: pick(rng, PART_NAMES), qty: 1 })
}
return found
}