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) # Forgejo #234: a gas giant the opening is guaranteed to be able to reach. # # Gas is gated behind two independent noise fields - the SPACE biome and the # planet roll - so whether any is in reach of the spawn was luck, and on this # seed it lost: measured, there were ZERO gas planets within four chunks and # the nearest sat five away, past five rocky ones. Gas is needed early (the # burner, ammunition), so the opening cannot depend on the noise being kind. # # Authored like OCTOPUS_BIOME_CENTER above rather than found by searching # outward: a fixed chunk is reproducible from anywhere without walking the # map, which is the property everything in this file is built on. const SPAWN_GAS_GIANT_CHUNK: Vector2i = Vector2i(2, -2) 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 chunk == SPAWN_GAS_GIANT_CHUNK: 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 # #234, applied after the rolls for the same reason the dev planting is: # forcing this chunk must not shift which random values any OTHER chunk # consumes, or one guaranteed planet would reshuffle the whole map. if chunk == SPAWN_GAS_GIANT_CHUNK: definition_id = PLANET_DEFINITIONS.GAS_GIANT_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