spaceship/MainMenu.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

134 lines
4.7 KiB
GDScript

extends Node2D
# The menu stacks whatever entries are actually available, rather than
# trusting the positions authored in the scene.
#
# Two of the three entries only exist when the game is run from Godot, so a
# shipped build shows one entry and an editor run shows three. There is no
# Quit: a web build has nothing to quit TO, and on Android the system back
# gesture already does it. Fixed
# offsets would leave holes where the hidden entries used to be - the same
# failure the HUD's button columns had, where a literal y meant "below the
# thing above me" and stopped being true when that thing moved. Here the
# stack is measured instead, and centred on whatever it ends up containing.
const ROW_BUTTON_HEIGHT: float = 128.0
const ROW_LABEL_OFFSET: float = 134.0
const ROW_LABEL_HEIGHT: float = 56.0
const ROW_HEIGHT: float = ROW_LABEL_OFFSET + ROW_LABEL_HEIGHT
const ROW_GAP: float = 20.0
const ROW_PITCH: float = ROW_HEIGHT + ROW_GAP
# The stack is centred on this rather than on the screen, to leave the title
# above it room to breathe.
const STACK_CENTER_Y: float = 40.0
const STATUS_GAP: float = 20.0
const STATUS_HEIGHT: float = 70.0
@onready var play_button: IconButton = $UI/PlayButton
@onready var play_label: Label = $UI/PlayLabel
@onready var editor_button: IconButton = $UI/EditorButton
@onready var editor_label: Label = $UI/EditorLabel
@onready var dev_button: IconButton = $UI/DevModeButton
@onready var dev_label: Label = $UI/DevModeLabel
@onready var status_label: Label = $UI/StatusLabel
func _ready() -> void:
play_button.pressed.connect(_on_play_pressed)
editor_button.pressed.connect(_on_editor_pressed)
editor_button.enabled = true
dev_button.pressed.connect(_on_dev_mode_pressed)
dev_button.enabled = true
# Dev mode and the map editor are development tools, not features of the
# game: they are for building and play-testing it, and a player who
# installed a build has no use for either. `editor` is the feature tag
# Godot sets when running from the editor and clears in an export, which
# is exactly the "started from Godot" distinction wanted here.
var developing: bool = is_developer_build()
editor_button.visible = developing
editor_label.visible = developing
dev_button.visible = developing
dev_label.visible = developing
_layout_stack()
# Wrapped so tests can state the rule they are checking rather than
# duplicating the feature-tag string.
func is_developer_build() -> bool:
return OS.has_feature("editor")
func _layout_stack() -> void:
var rows: Array = []
for row in [[play_button, play_label], [editor_button, editor_label],
[dev_button, dev_label]]:
if row[0].visible:
rows.append(row)
if rows.is_empty():
return
# The last row carries no trailing gap, so the block is one pitch per row
# minus that gap.
var block_height: float = float(rows.size()) * ROW_PITCH - ROW_GAP
var top: float = STACK_CENTER_Y - block_height * 0.5
for row in rows:
var button: Control = row[0]
var label: Label = row[1]
button.offset_top = top
button.offset_bottom = top + ROW_BUTTON_HEIGHT
label.offset_top = top + ROW_LABEL_OFFSET
label.offset_bottom = top + ROW_LABEL_OFFSET + ROW_LABEL_HEIGHT
top += ROW_PITCH
# Status sits under the whole stack, where a message about any entry can
# be read without it shifting the entries themselves.
var status_top: float = top - ROW_GAP + STATUS_GAP
status_label.offset_top = status_top
status_label.offset_bottom = status_top + STATUS_HEIGHT
func _on_play_pressed() -> void:
GameState.reset_new_game()
_start("res://Main.tscn")
# Loaded, built and added by hand rather than through change_scene_to_file().
# That call does all three in one opaque step, so when the web build hung
# there was no way to tell which part failed. Split out, each step can say so
# - and a scene change that fails otherwise leaves the menu up, which looks
# exactly like an unpressed button.
func _start(scene_path: String) -> void:
var packed: PackedScene = load(scene_path)
if packed == null:
status_label.text = "Could not load %s" % scene_path.get_file()
return
var scene: Node = packed.instantiate()
if scene == null:
status_label.text = "Could not build %s" % scene_path.get_file()
return
get_tree().root.add_child(scene)
get_tree().current_scene = scene
queue_free()
func _on_dev_mode_pressed() -> void:
# Guarded as well as hidden. Visibility is presentation; this is the rule.
if not is_developer_build():
status_label.text = "Dev mode only runs from Godot"
return
GameState.start_dev_game()
_start("res://Main.tscn")
func _on_editor_pressed() -> void:
if not is_developer_build():
status_label.text = "Map editor only runs from Godot"
return
if editor_button.enabled:
_start("res://PlanetSetupEditor.tscn")
return
status_label.text = "Map editor coming soon"