220 lines
9.6 KiB
GDScript
220 lines
9.6 KiB
GDScript
class_name WorldGen
|
|
extends RefCounted
|
|
|
|
const PLANET_DEFINITIONS = preload("res://planets/PlanetDefinitions.gd")
|
|
|
|
# Deterministic procedural world generation: the world is divided into
|
|
# square chunks, and each chunk's biome is derived from a seeded
|
|
# low-frequency noise field sampled at the chunk's own coordinates - so
|
|
# revisiting the same chunk always yields the same biome, and biomes form
|
|
# smooth multi-chunk regions instead of a per-chunk random checkerboard.
|
|
|
|
enum Biome { SPACE, ASTEROID_FIELD, OCTOPUS }
|
|
|
|
const CHUNK_SIZE: float = 1200.0
|
|
const NOISE_SEED: int = 1337
|
|
const ASTEROID_FIELD_THRESHOLD: float = 0.15
|
|
# Planets used to sit one per ~29 chunks - 35,000 world units of empty space
|
|
# between them, which is a long flight to find anything at all. Lowered to
|
|
# about one per 12 chunks (measured over 14,641 chunks, not estimated).
|
|
#
|
|
# The two thresholds moved by DIFFERENT amounts on purpose. Every planet in a
|
|
# SPACE biome is a gas giant, so lowering both equally would have shifted the
|
|
# mix as well as the density - dropping these to 0.20/0.40 more than tripled
|
|
# the gas-giant share, from 10% of planets to 34%. 0.18/0.50 leaves it at 11%,
|
|
# so this is a change in how OFTEN you find a planet and not in what you find.
|
|
const PLANET_THRESHOLD: float = 0.18
|
|
const GAS_GIANT_THRESHOLD: float = 0.50
|
|
# A flat per-planet-chunk probability rather than another noise field: gas
|
|
# giants already cover every SPACE-biome planet chunk (see GAS_GIANT_THRESHOLD
|
|
# above), so making black holes noticeably rarer just needs a small, uniform
|
|
# chance independent of biome - not a second smooth region to tune against
|
|
# the first.
|
|
const BLACK_HOLE_CHANCE: float = 0.05
|
|
# #63/#74: findable without excessive exploration - research points gate
|
|
# mid/late skill-tree nodes and vessel research, so a player needs to run
|
|
# into one of these reasonably often, not just as a rare curiosity. Raised
|
|
# from 0.08 after playtesting found that rate too scarce for how central
|
|
# research points had become to progression.
|
|
const RESEARCH_LAB_CHANCE: float = 0.16
|
|
# The hostile region is authored into procedural space as one finite,
|
|
# deterministic island. A probability per chunk can only say "rare"; over an
|
|
# infinite world it inevitably creates infinitely many boss worlds. One
|
|
# anchor and one shape make the actual rule representable: one multi-chunk
|
|
# octopus biome, with its boss at the centre.
|
|
const OCTOPUS_BIOME_CENTER: Vector2i = Vector2i(12, -9)
|
|
const OCTOPUS_BIOME_OFFSETS: Array[Vector2i] = [
|
|
Vector2i(0, 0), Vector2i(-1, 0), Vector2i(1, 0),
|
|
Vector2i(0, -1), Vector2i(0, 1),
|
|
Vector2i(-1, -1), Vector2i(1, -1),
|
|
Vector2i(-1, 1), Vector2i(1, 1),
|
|
]
|
|
|
|
# DEV MODE ONLY: one of every planet type, parked in the chunks immediately
|
|
# around the spawn so all five can be reached in seconds.
|
|
#
|
|
# This replaces the guaranteed gas giant that used to sit in the origin
|
|
# chunk for every game. That was a play-testing convenience which had become
|
|
# part of the real world's shape - every player got the same landmark in the
|
|
# same place, and the surrounding space was generated as if it were normal.
|
|
# Play-testing wants ALL the planet types to hand, not just gas, and a
|
|
# normal game wants none of them handed over; those are different needs and
|
|
# they now have different mechanisms.
|
|
#
|
|
# The local positions keep each planet off the spawn point itself: a fresh
|
|
# fleet starts at world origin, which is the top-left corner of chunk (0,0).
|
|
const DEV_PLANET_CHUNKS: Dictionary = {
|
|
Vector2i(0, 0): PLANET_DEFINITIONS.GAS_GIANT_ID,
|
|
Vector2i(1, 0): PLANET_DEFINITIONS.ROCKY_ID,
|
|
Vector2i(0, 1): PLANET_DEFINITIONS.RESEARCH_LAB_ID,
|
|
Vector2i(1, 1): PLANET_DEFINITIONS.BLACK_HOLE_ID,
|
|
Vector2i(-1, 0): PLANET_DEFINITIONS.BOSS_ID,
|
|
}
|
|
const DEV_PLANET_LOCAL_POS: Vector2 = Vector2(600.0, 600.0)
|
|
|
|
|
|
# Dev mode plants a fixed set of planets; a normal game must not see them.
|
|
#
|
|
# PUSHED here by GameState.dev_mode's setter rather than read back out of the
|
|
# autoload. World generation is deliberately dependency-free - it is static,
|
|
# seeded purely by chunk coordinates, and callable from anywhere - and an
|
|
# autoload cannot be named from here anyway: `GameState` is not resolvable as
|
|
# a global identifier this early, the same load-order hazard PlanetSetups.gd
|
|
# and PlanetSurfaceController.gd already work around. GameState.dev_mode
|
|
# stays the single source of truth; this is its mirror.
|
|
static var dev_planets_enabled: bool = false
|
|
|
|
|
|
static func _dev_planets_active() -> bool:
|
|
return dev_planets_enabled
|
|
|
|
static var _noise: FastNoiseLite
|
|
static var _planet_noise: FastNoiseLite
|
|
|
|
|
|
static func _get_noise() -> FastNoiseLite:
|
|
if _noise == null:
|
|
_noise = FastNoiseLite.new()
|
|
_noise.seed = NOISE_SEED
|
|
_noise.frequency = 0.06
|
|
return _noise
|
|
|
|
|
|
static func _get_planet_noise() -> FastNoiseLite:
|
|
if _planet_noise == null:
|
|
_planet_noise = FastNoiseLite.new()
|
|
_planet_noise.seed = NOISE_SEED + 777
|
|
# Higher frequency than the biome noise so it spikes on individual
|
|
# chunks rather than forming its own smooth multi-chunk regions -
|
|
# "once or twice per asteroid biome [region]" needs a sparse, mostly
|
|
# independent chance per chunk, not another smooth blob.
|
|
_planet_noise.frequency = 0.35
|
|
return _planet_noise
|
|
|
|
|
|
static func chunk_coords(world_pos: Vector2) -> Vector2i:
|
|
return Vector2i(int(floor(world_pos.x / CHUNK_SIZE)), int(floor(world_pos.y / CHUNK_SIZE)))
|
|
|
|
|
|
static func chunk_origin(chunk: Vector2i) -> Vector2:
|
|
return Vector2(chunk.x * CHUNK_SIZE, chunk.y * CHUNK_SIZE)
|
|
|
|
|
|
static func biome_at_chunk(chunk: Vector2i) -> int:
|
|
if is_octopus_chunk(chunk):
|
|
return Biome.OCTOPUS
|
|
var n: float = _get_noise().get_noise_2d(float(chunk.x), float(chunk.y))
|
|
if n > ASTEROID_FIELD_THRESHOLD:
|
|
return Biome.ASTEROID_FIELD
|
|
return Biome.SPACE
|
|
|
|
|
|
static func has_planet_at_chunk(chunk: Vector2i) -> bool:
|
|
if _dev_planets_active() and DEV_PLANET_CHUNKS.has(chunk):
|
|
return true
|
|
if chunk == OCTOPUS_BIOME_CENTER:
|
|
return true
|
|
if is_octopus_chunk(chunk):
|
|
return false
|
|
var n: float = _get_planet_noise().get_noise_2d(float(chunk.x), float(chunk.y))
|
|
return (biome_at_chunk(chunk) == Biome.ASTEROID_FIELD and n > PLANET_THRESHOLD) or (biome_at_chunk(chunk) == Biome.SPACE and n > GAS_GIANT_THRESHOLD)
|
|
|
|
|
|
static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary:
|
|
# Single source of truth for a planet's identity within its chunk -
|
|
# seeded purely from the chunk's own coordinates (same pattern as the
|
|
# biome/planet noise fields above) so it's reproducible from anywhere,
|
|
# without needing an actual spawned Planet node. Both PlanetField (to
|
|
# actually spawn the planet) and the world map (to draw a marker for
|
|
# an explored-but-not-currently-loaded chunk) call this, so the two
|
|
# can never drift out of sync with each other.
|
|
var rng: RandomNumberGenerator = RandomNumberGenerator.new()
|
|
rng.seed = hash(chunk)
|
|
var forced_definition_id: String = String(DEV_PLANET_CHUNKS.get(chunk, "")) \
|
|
if _dev_planets_active() else ""
|
|
var local_pos: Vector2 = DEV_PLANET_LOCAL_POS if not forced_definition_id.is_empty() \
|
|
else Vector2(rng.randf() * CHUNK_SIZE, rng.randf() * CHUNK_SIZE)
|
|
var shape_seed: int = rng.randi()
|
|
# Drawn unconditionally, in the same fixed position every call,
|
|
# regardless of biome or the gas-giant roll below - so adding this
|
|
# roll doesn't shift which random values any LATER draw (planet_name,
|
|
# below) consumes for chunks that would have existed before it.
|
|
var black_hole_roll: float = rng.randf()
|
|
# Same principle, its own independent roll (#63).
|
|
var research_lab_roll: float = rng.randf()
|
|
# A stable per-chunk roll makes gas giants persistent landmarks rather
|
|
# than a different resource whenever the scene reloads.
|
|
var gas_giant: bool = biome_at_chunk(chunk) == Biome.SPACE
|
|
var definition_id: String = PLANET_DEFINITIONS.GAS_GIANT_ID if gas_giant else PLANET_DEFINITIONS.ROCKY_ID
|
|
# Rare authored outcomes, seeded deterministically like everything else.
|
|
if chunk == OCTOPUS_BIOME_CENTER:
|
|
definition_id = PLANET_DEFINITIONS.BOSS_ID
|
|
elif black_hole_roll < BLACK_HOLE_CHANCE:
|
|
definition_id = PLANET_DEFINITIONS.BLACK_HOLE_ID
|
|
elif research_lab_roll < RESEARCH_LAB_CHANCE:
|
|
definition_id = PLANET_DEFINITIONS.RESEARCH_LAB_ID
|
|
# Dev mode's fixed planting wins over every roll above. Applied last, and
|
|
# AFTER all the rng draws, so switching it on cannot shift which random
|
|
# values any other chunk consumes - a dev world and a normal one differ
|
|
# only in these five chunks.
|
|
if not forced_definition_id.is_empty():
|
|
definition_id = forced_definition_id
|
|
var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id)
|
|
var names: PackedStringArray = definition.names
|
|
# Guarded for the same reason as the hangar grid: an empty names array
|
|
# would be a modulo by zero, which traps on wasm and takes the whole
|
|
# engine down rather than raising a script error.
|
|
var planet_name: String = "Unnamed" if names.is_empty() \
|
|
else names[rng.randi() % names.size()]
|
|
var planet_id: String = "chunk:%d:%d" % [chunk.x, chunk.y]
|
|
return {"planet_id": planet_id, "local_pos": local_pos, "name": planet_name, "shape_seed": shape_seed,
|
|
"definition_id": definition_id}
|
|
|
|
|
|
static func biome_color(biome: int) -> Color:
|
|
match biome:
|
|
Biome.ASTEROID_FIELD:
|
|
return Color(0.55, 0.36, 0.18, 1.0)
|
|
Biome.OCTOPUS:
|
|
return Color(0.34, 0.07, 0.24, 1.0)
|
|
_:
|
|
return Color(0.10, 0.13, 0.28, 1.0)
|
|
|
|
|
|
static func biome_background_color(biome: int) -> Color:
|
|
match biome:
|
|
Biome.OCTOPUS:
|
|
return Color(0.075, 0.012, 0.065, 1.0)
|
|
_:
|
|
return Color(0.02, 0.02, 0.08, 1.0)
|
|
|
|
|
|
static func is_octopus_chunk(chunk: Vector2i) -> bool:
|
|
return chunk - OCTOPUS_BIOME_CENTER in OCTOPUS_BIOME_OFFSETS
|
|
|
|
|
|
static func octopus_biome_chunks() -> Array[Vector2i]:
|
|
var chunks: Array[Vector2i] = []
|
|
for offset in OCTOPUS_BIOME_OFFSETS:
|
|
chunks.append(OCTOPUS_BIOME_CENTER + offset)
|
|
return chunks
|