The fleet had a second of inertia in it: every vessel's acceleration sat at roughly its own top speed, so flipping the stick while cruising took 0.58s to take effect and let go coasted 222 units, while the formation anchor the ships chase turns instantly. Acceleration is now ~3x top speed, chosen by measuring rather than taste - past 3x the reversal time stops improving because max_speed becomes the limit. Measured after: 0.18s and 23 units. test_fleet_agility guards all three numbers and proves the bounds discriminate by re-running the same measurement at the old value. The laser reaches 1450 instead of 950 and strikes 38 units either side instead of 22. Its drawn width is now derived from the hit width: the two were independent numbers that had drifted, so the beam killed things it visibly missed. LaserController's aim range also stopped hardcoding a second copy of 950. Tapping the laser button without dragging used to do nothing at all - the press was claimed, the aim begun, then thrown away on release. It now fires at the nearest enemy in range; dragging still aims precisely. That lives in the shared aimed-ability controller, so the gas bomb gains it too. Planets sat one per ~29 chunks - some 35,000 units of nothing between them. Now one per ~12, measured over 14,641 chunks. The two thresholds moved by different amounts deliberately: every SPACE-biome planet is a gas giant, so lowering both equally would have tripled the gas-giant share as well. This changes how often you find a planet, not what you find. Extractors produce half as fast to match, so expanding is what grows the economy. The research screen gave its tree 386px of 1280 and the text below it 520. The text is now at the bottom, the tree has 550, and the resource table has room and legible type - and its LOCAL column is no longer hidden behind the close button. Two map-editor bugs turned out to be one cause: a setup's id and its FILENAME are not the same thing, and both save and delete rebuilt the path from the id. ResearchLabSetup.tres holds setup_id "research_lab_1", so deleting it looked for a file that does not exist and silently refused - which read as "the first item in the list cannot be deleted" - while saving it wrote a second file under the derived name and left the original behind, so the pool served the stale twin. A third bug hid behind those: rescan() re-read through Godot's resource cache and handed back pre-save values even when the file on disk was correct. All three present to a player as "my change was not saved". Research labs no longer grant a point of their own; what conquering one is worth comes entirely from its authored map, as asked. The shipped lab map carries 1 so the balance is unchanged - only where it is configured moved. Suite 109/109. Four tests changed because the behaviour they pinned changed: the joystick coast is now derived from cruise_speed^2/(2*acceleration) rather than a constant tuned to one acceleration; the extractor wait derives from EXTRACTOR_RATE; the research-point test asserts the delegation; and the black hole test no longer assumes nobody will ever author a void-profile map. test_planet_setups and test_planet_map_assignment stopped depending on which maps happen to be in the content folder, which was making them fail when maps were added or deleted elsewhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
179 lines
8.2 KiB
GDScript
179 lines
8.2 KiB
GDScript
class_name WorldGen
|
|
extends RefCounted
|
|
|
|
const PLANET_DEFINITIONS = preload("res://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 }
|
|
|
|
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
|
|
|
|
# DEV MODE ONLY: one of every planet type, parked in the chunks immediately
|
|
# around the spawn so all four 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,
|
|
}
|
|
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:
|
|
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
|
|
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
|
|
# A rare third outcome, seeded the same deterministic way as everything
|
|
# else here.
|
|
if 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 four 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)
|
|
_:
|
|
return Color(0.10, 0.13, 0.28, 1.0)
|