spaceship/world/WorldGen.gd
Claude 885f977768 #229, #234: one voice per volley, and gas within reach of the spawn
#229 - a volley got louder for every ship in it.

Every hull owns its gun and asks for its own shot sound, so eight ships
firing on the same cooldown started eight voices playing the SAME sample in
the same frame. Those amplitudes add, so a full fleet was the loudest thing
in the game by a wide margin.

play() now takes a per-sound coalescing window. A volley is one event to the
ear, so it gets one voice. 70ms, which the test checks is inside the fastest
gun's 500ms cooldown - it collapses "six guns at once", never "six shots in
a row". PICKUP and ASTEROID_BREAK get one too: ore arrives in bursts when a
rock breaks and that is one pickup to the ear. Deaths and the boss scream
are left alone deliberately - several things dying together IS several
events.

test_sfx's pool-saturation case was driven by shots, which can no longer
saturate anything by design. It is driven by deaths now, which is what the
pool actually has to absorb, and it gained a case asserting a volley takes
one voice - measured against the real pool rather than the gate.

#234 - gas was not reliably in reach of the opening.

Gas giants are gated behind two independent noise fields, the SPACE biome
AND the planet roll, so whether any sat near the spawn was luck. Measured,
this seed lost: ZERO gas planets within four chunks, the nearest five away
past five rocky ones. Gas is an early need - the burner, ammunition - so the
opening cannot depend on the noise being kind.

SPAWN_GAS_GIANT_CHUNK at (2, -2), authored like OCTOPUS_BIOME_CENTER rather
than found by searching outward: a fixed chunk is reproducible from anywhere
without walking the map, which is what the rest of WorldGen is built on.

The override is applied AFTER every rng draw, for the same reason the dev
planting is - forcing one chunk must not shift what any other chunk rolls.
Confirmed by probe: the five other planets within four chunks are identical
before and after, and the distant gas giants at (-5,-4) and (0,-5) are still
where they were.

Both verified in reverse: without the window, "8 ships firing together
started 8 shot sounds"; without the chunk, "no gas planet within 4 chunks of
the spawn".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-15 23:44:15 -04:00

240 lines
11 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)
# 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