spaceship/WorldGen.gd
Loïc Wisniewski 137e4e528d Fix the web build: a wasm trap on integer % 0, and buttons that never fired
The web export reached the main menu and went no further. Two separate bugs,
one hiding the other.

BUTTONS NEVER FIRED. A browser on a phone delivers the touch DOWN and never
the release. IconButton emits `pressed` only on release, deliberately, so
that dragging off cancels - so on the web it never emitted at all, on every
button in the game. fire_on_press (default OS.has_feature("web")) completes
the press there instead, and does not latch, since nothing is coming to
unlatch it. The cost is drag-off-to-cancel, which every other platform keeps.

Measured on the device rather than guessed: an on-screen probe reported
`touch=1 mouse=1 held=y` after one tap - one down, one emulated mouse down,
the button latched through ordinary GUI routing, then nothing.

THE ENGINE THEN TRAPPED. With the button working, pressing Play loaded and
built Main.tscn and the whole engine stopped mid-frame, page still alive.
An error overlay in a custom HTML shell finally showed why:

    Uncaught RuntimeError: remainder by zero

An integer % 0. Wasm TRAPS on that; native builds silently tolerate it. So
the bug was equally present on Android and in all 100 tests, doing nothing
in both - which is why nothing runnable here could ever have caught it, and
why reasoning from frozen screenshots got nowhere for several rounds. Every
variable modulo is now guarded: the hangar grid's `i % columns`, planet
naming's `% names.size()`, and two in the map editor.

WHAT IS KEPT, AND WHY

- web_shell.html mirrors console errors onto the page, hidden until one
  occurs. Godot's errors go to the browser console, which is unreachable on
  a phone; this found in one shot what inference had failed to find all
  evening. It costs nothing when there is nothing to report.
- MainMenu._start does load -> instantiate -> add_child by hand instead of
  change_scene_to_file. That call does all three opaquely, so a failure is
  indistinguishable from an unpressed button; split, each step reports.
- PlanetAtmosphereController defers the forced world populate out of the
  frame that builds Main.tscn. It did not fix the hang - the trap did - but
  25 chunks of asteroids and 49 of planets do not belong in the one frame
  that already constructs 197 nodes, and nothing can paint until it ends.

Removed the direct-input-routing experiment and its test: it was written on
the theory that touches were not reaching Controls, which `held=y` later
disproved, and its set_input_as_handled() swallowed the very events the
probe was reading.

Full suite green at 100/100.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 01:08:09 +02:00

170 lines
7.6 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
const PLANET_THRESHOLD: float = 0.33
const GAS_GIANT_THRESHOLD: float = 0.55
# 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)