spaceship/ui/MainMenu.gd
Loïc Wisniewski 46196a1adb tmp
2026-08-30 16:53:08 +02:00

209 lines
8.1 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
# #169: what is being opened, and the cover that says so. Empty path means
# nothing is loading, which is also what stops a second press starting a
# second scene - on the web a button fires on PRESS (see IconButton), and an
# impatient double tap on Play would otherwise build the world twice.
const LOADING_SCREEN = preload("res://ui/LoadingScreen.gd")
# How the player is told what they are waiting for. Keyed by scene path so a
# new entry cannot go in without a name to put on the screen.
const SCENE_NAMES: Dictionary = {
"res://world/Main.tscn": "Entering the convoy lane",
"res://surface/PlanetSetupEditor.tscn": "Opening the map editor",
}
# The load only starts once the cover has been up for a frame or two. One
# would do; two is cheap and removes any argument about whether the first one
# reached the screen.
const COVER_FRAMES: int = 2
var loading_path: String = ""
var loading_screen: Control = null
func _ready() -> void:
# #148: the theme starts here, at the first screen, and keeps playing
# through every scene after it.
Music.ensure(get_tree())
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:
# Checked here as well as in _start: this one has a side effect, and a
# second press while the first is still loading must not wipe the game
# that first press already set up (#169).
if loading_path != "":
return
GameState.reset_new_game()
_start("res://world/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.
#
# #169: and now it does not look like anything else either. The press raises a
# loading screen straight away and the load itself happens over the following
# frames, driven from _process below, so the wait is something the player is
# watching rather than a menu that has stopped responding.
func _start(scene_path: String) -> void:
if loading_path != "":
return
loading_path = scene_path
status_label.text = ""
loading_screen = LOADING_SCREEN.new()
$UI.add_child(loading_screen)
loading_screen.begin(String(SCENE_NAMES.get(scene_path, scene_path.get_file())))
# The load itself is still the ordinary blocking one, deliberately.
#
# ResourceLoader.load_threaded_request() was the obvious alternative and it
# does not work here: asked for these scenes it comes back FAILED, with the
# text-scene parser reporting errors ("Parse Error ... Rock.tscn:9", "Could
# not resolve external class member PROFILES") that the same files do not
# produce through load(). The project's scripts and scenes reach each other
# through global class names and preloads, and that resolution is not
# available off the loading thread. A progress bar is not worth a menu that
# sometimes refuses to start the game - and the web build could not have used
# the threaded path anyway, since it ships with thread support off.
#
# So what the cover buys is honest and worth having on its own: the press is
# answered instantly, the player is told what is opening, and the frame that
# blocks happens with something on the screen rather than behind a menu that
# has stopped responding.
func _process(_delta: float) -> void:
if loading_path == "":
return
# Nothing blocking until the cover is actually up - see LoadingScreen's
# own note. This is the whole reason the load waits for a frame at all.
if loading_screen == null or loading_screen.frames_shown < COVER_FRAMES:
return
var scene_path: String = loading_path
loading_path = ""
var packed: PackedScene = load(scene_path)
if packed == null:
_abandon_loading("Could not load %s" % scene_path.get_file())
return
var scene: Node = packed.instantiate()
if scene == null:
_abandon_loading("Could not build %s" % scene_path.get_file())
return
get_tree().root.add_child(scene)
get_tree().current_scene = scene
queue_free()
# A failed load must leave a working menu behind, not a cover over a screen
# with nothing coming.
func _abandon_loading(message: String) -> void:
if loading_screen != null:
loading_screen.queue_free()
loading_screen = null
status_label.text = message
func _on_dev_mode_pressed() -> void:
if loading_path != "":
return
# 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://world/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://surface/PlanetSetupEditor.tscn")
return
status_label.text = "Map editor coming soon"