extends Control ## The client's entry point: a small update screen that runs before the game. On a plain ## windowed launch it checks the player's chosen update channel, pulls a newer `game.pck` if ## one is published, then loads that payload over the bundled seed and hands off to the match. It ## is the Godot-native form of the-way-out's launcher loop — the client *is* the launcher, so ## "author pushes, player gets it" needs no second app. ## ## It deliberately gets out of the way for every non-player launch. Headless runs, the ## flag-driven modes (`--host`/`--join`/`--local`), and an explicit `--no-update` all skip the ## check and the UI entirely and go straight to the match, so the CI smokes, the GUT suite, ## and a developer launching a mode directly are untouched by the updater. Only a bare ## double-click of the app sees the update screen. ## ## The hand-off is the load-order contract that makes the overriding pck safe: this scene ## touches only the update/launcher classes, so no game class is cached before ## `load_resource_pack` overrides the bundled files; the game's scripts and scenes are loaded ## fresh from the pck the moment `change_scene_to_file` runs. ## The mode flags `main.gd` reads. Their presence means "a specific run was requested" — skip ## the updater and let `main.gd` pick the mode up itself from the same command line. const MODE_FLAGS: PackedStringArray = ["--host", "--join", "--local"] const MAIN_SCENE := "res://scenes/main.tscn" ## How long a transient status (up to date, offline, failed) lingers before the hand-off, so ## the player can read it instead of seeing a flash. Kept short — this is a launch, not a wait. const STATUS_LINGER_S := 0.7 var _status: Label var _bar: ProgressBar var _updater: Updater func _ready() -> void: _build_ui() if _should_skip(): _hand_off() return _updater = Updater.new() # Point the updater at the player's saved channel (Stable or Beta) before it probes; an # unset or corrupt choice falls back to the default inside Settings. _updater.channel = Settings.update_channel() add_child(_updater) if not _updater.should_check(): # Within the throttle window: run the installed payload without a network probe, so a # slow or captive link does not stall the launch by the request timeout. _hand_off() return _updater.check_done.connect(_on_check_done) _updater.download_progress.connect(func(r: float): _bar.value = r) _updater.applied.connect(_on_applied) _status.text = "Checking for updates…" _updater.check() ## True when this launch should bypass the updater: an editor run (the developer path — F5 or ## `godot --path .` — never self-updates), a headless run (no display, the automated smokes), an ## explicit `--no-update`, or any mode flag (a deliberate flag-driven launch that wants the match ## now). Only a plain launch of an exported build reaches the update check. func _should_skip() -> bool: if OS.has_feature("editor"): return true if DisplayServer.get_name() == "headless": return true var args := OS.get_cmdline_user_args() if args.has("--no-update"): return true for flag in MODE_FLAGS: if args.has(flag): return true return false ## The check finished. Offline or a client too old to load the build runs the installed ## payload; a newer, loadable build is applied (with a progress bar); otherwise we are up to ## date. Each terminal branch records a one-word status for the title-screen footer and hands ## off; the apply branch waits for `applied` instead. func _on_check_done(available: bool, info: Dictionary) -> void: if info.get("offline", false): _finish("offline", "Offline — starting installed build") return if info.get("needs_client_upgrade", false): _finish("client update required", "A new Theria is out — please re-download the client") return if available: _status.text = "Updating to %s…" % _label_for(info) _bar.visible = true _updater.apply(info) return _finish("up to date", "Up to date") ## An apply attempt finished. On success the freshly swapped pck is live and the footer reads ## "updated"; on failure the install is untouched (the updater left it as it was) and we start ## what we have. Either way the hand-off loads whatever pck is now installed. func _on_applied(ok: bool) -> void: if ok: _finish("updated", "Updated — starting Theria") else: _finish("update failed", "Update failed — starting installed build") ## Records the footer status, shows the message briefly, then hands off — the single exit for ## every terminal branch so the screen always lingers the same beat before the match. func _finish(status: String, message: String) -> void: Engine.set_meta(UiTheme.STATUS_META, status) _status.text = message _bar.visible = false await get_tree().create_timer(STATUS_LINGER_S).timeout _hand_off() ## Loads the installed payload over the bundled seed, then changes to the match scene. With no ## payload (a fresh install that has not updated, or an offline first run) the client simply ## runs the seed it shipped with. A failed pack load is non-fatal for the same reason — the ## bundled scene is still there to fall back to. func _hand_off() -> void: # Only an exported player build runs the installed payload. An editor/source run (F5, or # `godot --path .`, including the `--local`/`--host`/`--join` dev launches) must play its own # `res://` source, never the last-downloaded pck — otherwise it silently shadows uncommitted # changes with the shipped build, which is exactly the trap that hid the HUD during playtests. if UpdateManifest.should_load_payload(OS.has_feature("editor"), UpdateManifest.has_payload()): ProjectSettings.load_resource_pack(UpdateManifest.PCK_PATH) # Deferred: a skip-path hand-off runs inside `_ready`, when the tree is mid-add and a # synchronous scene change is refused; deferring runs it on the next idle frame instead. get_tree().change_scene_to_file.call_deferred(MAIN_SCENE) ## A human label for the build being installed: its version when the manifest carried one, ## else a short sha, so the "Updating to …" line always names something. func _label_for(info: Dictionary) -> String: var version: String = info.get("version", "") if not version.is_empty(): return version return (info.get("sha", "") as String).substr(0, 7) ## Lays out the update screen: a full-bleed jungle backdrop, the wordmark, a status line, and ## a hidden download bar that only shows while a pck is being fetched. Built in code with the ## shared theme so it reads as the same product as the title screen behind it. func _build_ui() -> void: set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) theme = UiTheme.make() var backdrop := ColorRect.new() backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) backdrop.color = UiTheme.BG backdrop.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(backdrop) var center := CenterContainer.new() center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) add_child(center) var box := VBoxContainer.new() box.add_theme_constant_override("separation", 28) box.alignment = BoxContainer.ALIGNMENT_CENTER center.add_child(box) var wordmark := UiTheme.wordmark() if wordmark != null: var logo := TextureRect.new() logo.texture = wordmark logo.custom_minimum_size = Vector2(520, 0) logo.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL logo.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED box.add_child(logo) _status = Label.new() _status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _status.add_theme_color_override("font_color", UiTheme.TEXT_MUTED) box.add_child(_status) _bar = ProgressBar.new() _bar.custom_minimum_size = Vector2(420, 0) _bar.min_value = 0.0 _bar.max_value = 1.0 _bar.show_percentage = false _bar.visible = false box.add_child(_bar)