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 continue_button: IconButton = $UI/ContinueButton @onready var continue_label: Label = $UI/ContinueLabel @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()) # Same moment, same reason: build the sfx voices before the first # sound needs them rather than during it. Sfx.warm(get_tree()) play_button.pressed.connect(_on_play_pressed) # Offered only when there is something to offer, so a first-time player # never sees a button that does nothing. #225. continue_button.pressed.connect(_on_continue_pressed) var save: Dictionary = SaveGame.peek() continue_button.visible = not save.is_empty() continue_label.visible = continue_button.visible if not save.is_empty() and not bool(save.get("loadable", false)): # A save that cannot be opened is still shown, greyed, with the # reason under it - silently hiding someone's run makes it look # lost rather than incompatible. continue_button.enabled = false status_label.text = String(save.get("refusal", "")) 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 [[continue_button, continue_label], [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_continue_pressed() -> void: if loading_path != "": return var result: Dictionary = SaveGame.load_into(GameState) if not bool(result.get("ok", false)): status_label.text = String(result.get("reason", "That run could not be opened.")) continue_button.enabled = false return # A resumed run never replays the prologue, whatever it was doing when # it was written. GameState.intro_active = false GameState.resumed_from_save = true _start("res://world/Main.tscn") 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"