ajhahn.de
← Theria
GDScript 179 lines
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)