ajhahn.de
← Theria
GDScript 417 lines
class_name ConnectMenu
extends Control
## The in-game connect screen, shown on a windowed launch with no mode flag. It
## lets the player start a single-machine practice match, host a listen-server, or
## join one by address — the same three modes the command line selects with
## `--local`, `--host`, and `--join`, surfaced as UI so a player never needs flags.
##
## Pure presentation: it owns no networking and no simulation, only emitting a
## signal for the chosen mode. `main.gd` wires those signals to the existing
## `_start_*` paths, so the menu adds an entry point without touching authority or
## the wire. A headless run skips it — a menu cannot be driven without a display —
## and the command-line flags stay the automation path.

## The player chose to host a listen-server.
signal host_requested
## The player chose to join a server at `address` (already resolved to the default
## when the field was left blank).
signal join_requested(address: String)
## The player chose a single-machine practice match driving `hero` (a kit id) against
## bots of `difficulty` (a level name). The hero's tribe fields the player's team and the
## opposing tribe the bots, so the pick also chooses the match-up — the same roles
## `--hero` and `--bot-difficulty` fill on the command line.
signal practice_requested(hero: String, difficulty: String)

## Menu styling. An opaque backdrop covers the whole viewport so the debug map and its
## jungle camps — drawn behind the menu in world space — do not bleed through the otherwise
## transparent controls; the card sits on top as a framed panel drawn with the shared UiTheme,
## so the menu reads as one product with the boot screen rather than as floating text over the
## arena. The header is the Theria wordmark in place of a text title.
const CARD_MIN_WIDTH := 680.0
const WORDMARK_WIDTH := 520.0
const TITLE_FALLBACK_SIZE := 72
const FOOTER_FONT_SIZE := 18
const BUTTON_MIN_SIZE := Vector2(560, 76)
const ADDRESS_MIN_WIDTH := 380.0

## The bot difficulty choices, as `[label, level name]` pairs — the label shown in the
## picker, the level name carried as item metadata and emitted on Practice (the same
## names `--bot-difficulty` accepts). Self-contained so the menu stays pure presentation.
const DIFFICULTY_OPTIONS := [["Easy", "easy"], ["Normal", "normal"], ["Hard", "hard"]]

## The update-channel choices, as `[label, channel id]` pairs for the Settings picker. The
## ids mirror `UpdateManifest.CHANNEL_STABLE`/`CHANNEL_BETA`; `Settings` normalises whatever
## is selected, so a label change here can never write an unknown channel.
const CHANNEL_OPTIONS := [["Stable", "stable"], ["Beta (testing)", "beta"]]

## The address used when the player leaves the field blank. The driver injects its
## own default so the menu and the `--join` flag resolve to one value.
var default_address := "127.0.0.1"

## The hero the picker starts on (a kit id). The driver injects its own default — any
## `--hero` already parsed, else the default tribe's lead — so the menu reflects the
## command line. Empty selects the first hero in the list.
var default_hero := ""

## The bot difficulty the picker starts on (a level name). The driver injects its own
## default — any `--bot-difficulty` parsed, else "easy" — so the menu reflects the command
## line. An unknown name leaves the picker on its first option.
var default_difficulty := "easy"

var _address_field: LineEdit
## Picks the hero the player drives in a practice match. Populated from
## `AbilityData.TRIBE` so the roster cannot drift from the simulation's; each item
## carries its kit id as metadata.
var _hero_picker: OptionButton
## Picks the bot skill level for a practice match; each item carries its level name as
## metadata, emitted on the Practice choice.
var _difficulty_picker: OptionButton
## The Settings dialog, built on first open. Carries the update-channel toggle today;
## video/audio options join it as they land.
var _settings_dialog: AcceptDialog
## The network updater, created lazily on the first manual "Check now" so a player who never
## opens Settings never spins up an HTTPRequest. The boot scene runs its own at launch; this one
## drives the in-menu force-check that bypasses the launch-time throttle.
var _updater: Updater
## The Settings dialog's update-status line, written by the manual check as it runs.
var _check_status: Label
## The manual-check button, disabled while a check is in flight so it cannot be re-entered.
var _check_button: Button


func _ready() -> void:
	set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	# The shared theme styles every control — the wordmark header, the labels, the dropdowns,
	# the buttons, the address field — so the menu reads as one product with the boot screen.
	theme = UiTheme.make()

	# An opaque backdrop, behind everything, so the world drawn in screen space behind the
	# menu does not show through the transparent controls. Ignores the mouse so it never
	# eats a click meant for a button below it in the tree.
	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)

	# A framed card so the controls read against a solid panel rather than the arena, drawn
	# with the shared card style so it matches the boot screen.
	var card := PanelContainer.new()
	card.add_theme_stylebox_override("panel", UiTheme.card_style())
	card.custom_minimum_size = Vector2(CARD_MIN_WIDTH, 0)
	center.add_child(card)

	var box := VBoxContainer.new()
	box.add_theme_constant_override("separation", 18)
	card.add_child(box)

	box.add_child(_header())

	var pick_label := Label.new()
	pick_label.text = "Practice hero"
	box.add_child(pick_label)

	_hero_picker = OptionButton.new()
	_populate_heroes()
	box.add_child(_hero_picker)

	var difficulty_label := Label.new()
	difficulty_label.text = "Bot difficulty"
	box.add_child(difficulty_label)

	_difficulty_picker = OptionButton.new()
	_populate_difficulties()
	box.add_child(_difficulty_picker)

	var practice_button := Button.new()
	practice_button.text = "Practice (single machine)"
	practice_button.custom_minimum_size = BUTTON_MIN_SIZE
	practice_button.pressed.connect(_on_practice_pressed)
	box.add_child(practice_button)

	var host_button := Button.new()
	host_button.text = "Host a match"
	host_button.custom_minimum_size = BUTTON_MIN_SIZE
	host_button.pressed.connect(_on_host_pressed)
	box.add_child(host_button)

	var join_row := HBoxContainer.new()
	box.add_child(join_row)

	_address_field = LineEdit.new()
	_address_field.placeholder_text = default_address
	_address_field.custom_minimum_size = Vector2(ADDRESS_MIN_WIDTH, 0)
	_address_field.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	join_row.add_child(_address_field)

	var join_button := Button.new()
	join_button.text = "Join"
	join_button.pressed.connect(_on_join_pressed)
	join_row.add_child(join_button)

	box.add_child(_footer())


## The card header: the Theria wordmark texture, falling back to a large text title if the
## asset is somehow missing, so the menu always names itself.
func _header() -> Control:
	var mark := UiTheme.wordmark()
	if mark == null:
		var title := Label.new()
		title.text = "Theria"
		title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
		title.add_theme_font_size_override("font_size", TITLE_FALLBACK_SIZE)
		return title
	var logo := TextureRect.new()
	logo.texture = mark
	logo.custom_minimum_size = Vector2(WORDMARK_WIDTH, 0)
	logo.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL
	logo.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
	return logo


## The card footer: a muted build line on the left (so a tester can report exactly which
## build they are on) and the Settings affordance on the right, under a divider.
func _footer() -> Control:
	var wrap := VBoxContainer.new()
	wrap.add_theme_constant_override("separation", 14)
	wrap.add_child(HSeparator.new())
	var row := HBoxContainer.new()
	var build := Label.new()
	build.text = _build_id()
	build.add_theme_color_override("font_color", UiTheme.TEXT_MUTED)
	build.add_theme_font_size_override("font_size", FOOTER_FONT_SIZE)
	build.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	build.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
	row.add_child(build)
	var settings := Button.new()
	settings.text = "⚙ Settings"
	settings.pressed.connect(_open_settings)
	row.add_child(settings)
	wrap.add_child(row)
	return wrap


## The build line: the client version, the installed pck's short sha (or "seed" when running
## the bundled build), and the boot screen's update outcome when it left one. The footer is
## how a playtester names their build in a report, so it reads off the same sources the
## updater wrote.
func _build_id() -> String:
	var sha := UpdateManifest.local_sha()
	var build := sha.substr(0, 7) if not sha.is_empty() else "seed"
	# Name the version of the *content* the player is running — the installed pck's version, written
	# by the updater on swap — not the launcher's baked `config/version`, which is frozen at the build
	# they first downloaded and so reads as "never updates" even after the pck has marched several
	# versions on. Falls back to the launcher version when running the bundled seed (no payload yet).
	var version := UpdateManifest.payload_version()
	if version.is_empty():
		version = UpdateManifest.client_version()
	var parts := PackedStringArray(["v%s" % version, "build %s" % build])
	var status := str(Engine.get_meta(UiTheme.STATUS_META, ""))
	if not status.is_empty():
		parts.append(status)
	return " · ".join(parts)


## Opens the Settings dialog, building it on first use. Today it carries the update-channel
## toggle — Stable (tagged releases only) or Beta (every main build) — written straight to
## `user://settings.cfg` and applied on the next launch; video/audio options join it later.
func _open_settings() -> void:
	if _settings_dialog == null:
		_settings_dialog = _build_settings_dialog()
		add_child(_settings_dialog)
	_settings_dialog.popup_centered()


## Builds the Settings dialog: a labelled update-channel picker over a hint that the choice
## takes effect on the next launch. The picker starts on the saved channel (via `Settings`)
## and writes each new choice straight back, so closing the dialog needs no Save step. Themed
## with the shared UiTheme so the popup reads as the same product as the menu behind it.
func _build_settings_dialog() -> AcceptDialog:
	var dialog := AcceptDialog.new()
	dialog.title = "Settings"
	dialog.theme = UiTheme.make()

	var box := VBoxContainer.new()
	box.add_theme_constant_override("separation", 12)

	var label := Label.new()
	label.text = "Update channel"
	box.add_child(label)

	var picker := OptionButton.new()
	for option in CHANNEL_OPTIONS:
		picker.add_item(option[0])
		picker.set_item_metadata(picker.item_count - 1, option[1])
	var saved := Settings.update_channel()
	for i in picker.item_count:
		if picker.get_item_metadata(i) == saved:
			picker.select(i)
			break
	picker.item_selected.connect(_on_channel_selected.bind(picker))
	box.add_child(picker)

	var hint := Label.new()
	hint.text = "Stable: tagged releases only. Beta: every new build. Applies on next launch."
	hint.add_theme_color_override("font_color", UiTheme.TEXT_MUTED)
	hint.add_theme_font_size_override("font_size", FOOTER_FONT_SIZE)
	hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	hint.custom_minimum_size = Vector2(ADDRESS_MIN_WIDTH, 0)
	box.add_child(hint)

	# A force-check that bypasses the launch-time throttle, so a player who hears a new build is out
	# can pull it now instead of waiting out the cold-start window. Its result lands in the status
	# line below rather than in a hand-off, since the menu stays up.
	_check_button = Button.new()
	_check_button.text = "Check for updates now"
	_check_button.pressed.connect(_on_check_now_pressed)
	box.add_child(_check_button)

	_check_status = Label.new()
	_check_status.add_theme_color_override("font_color", UiTheme.TEXT_MUTED)
	_check_status.add_theme_font_size_override("font_size", FOOTER_FONT_SIZE)
	_check_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	_check_status.custom_minimum_size = Vector2(ADDRESS_MIN_WIDTH, 0)
	box.add_child(_check_status)

	dialog.add_child(box)
	return dialog


## Persists the picked update channel. `Settings` normalises the id, so the metadata carried
## by the selected item is written verbatim; the boot scene reads it on the next launch.
func _on_channel_selected(index: int, picker: OptionButton) -> void:
	Settings.set_update_channel(picker.get_item_metadata(index))


## Runs a manual update check that bypasses the launch-time throttle, so a player who just heard a
## new build is out can pull it without relaunching. Creates the updater on first use and points it
## at the saved channel each time (the picker may have just changed it). The downloaded pck loads
## only at the next launch (`load_resource_pack` runs in the boot scene), so a successful apply asks
## for a restart rather than swapping the running game out from under the player.
func _on_check_now_pressed() -> void:
	if _updater == null:
		_updater = Updater.new()
		add_child(_updater)
		_updater.check_done.connect(_on_check_now_done)
		_updater.download_progress.connect(_on_check_now_progress)
		_updater.applied.connect(_on_check_now_applied)
	_updater.channel = Settings.update_channel()
	_check_button.disabled = true
	_check_status.text = "Checking…"
	_updater.check()


## The manual check finished: the boot screen's branches — offline, a client too old for the build,
## a newer build (download it), or already current — but staying on the menu instead of handing off,
## and re-enabling the button on every terminal branch.
func _on_check_now_done(available: bool, info: Dictionary) -> void:
	if info.get("offline", false):
		_finish_check("Offline — check your connection")
		return
	if info.get("needs_client_upgrade", false):
		_finish_check("A new Theria is out — please re-download the client")
		return
	if available:
		_check_status.text = "Downloading %s…" % _check_label(info)
		_updater.apply(info)
		return
	_finish_check("Up to date")


## Mirrors the download fraction into the status line while a manual-check pck is being fetched.
func _on_check_now_progress(ratio: float) -> void:
	_check_status.text = "Downloading… %d%%" % int(ratio * 100.0)


## A manual-check apply finished. On success the new pck is staged live, but the running client
## still holds the one it booted with (the pck loads at boot, not mid-session), so ask for a
## restart; on failure the install was left untouched.
func _on_check_now_applied(ok: bool) -> void:
	if ok:
		_finish_check("Updated — restart Theria to apply")
	else:
		_finish_check("Update failed — try again")


## Single exit for every manual-check branch: shows the message and re-enables the button.
func _finish_check(message: String) -> void:
	_check_status.text = message
	_check_button.disabled = false


## A human label for the build a manual check is installing: its version when the manifest carried
## one, else a short sha — the same fallback the boot screen uses.
func _check_label(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)


## Fills the hero picker from the tribe rosters — one item per hero, labelled
## "Tribe — Hero", carrying its kit id as metadata — and selects `default_hero` (or the
## first hero when none was injected). Reading `AbilityData.TRIBE` keeps the menu's roster
## in lockstep with the simulation's: a new hero appears here the moment it joins a tribe.
func _populate_heroes() -> void:
	for tribe in AbilityData.TRIBE:
		for hero in AbilityData.TRIBE[tribe]:
			_hero_picker.add_item("%s — %s" % [tribe.capitalize(), (hero as String).capitalize()])
			_hero_picker.set_item_metadata(_hero_picker.item_count - 1, hero)
	if default_hero.is_empty():
		return
	for i in _hero_picker.item_count:
		if _hero_picker.get_item_metadata(i) == default_hero:
			_hero_picker.select(i)
			return


## The kit id of the selected hero, falling back to `default_hero` if nothing is
## selected (an empty roster — never the case while a tribe is defined).
func _selected_hero() -> String:
	if _hero_picker.selected < 0:
		return default_hero
	return _hero_picker.get_item_metadata(_hero_picker.selected)


## Fills the difficulty picker from `DIFFICULTY_OPTIONS` — one item per level, carrying
## its level name as metadata — and selects `default_difficulty` (or the first option
## when the injected name is unknown).
func _populate_difficulties() -> void:
	for option in DIFFICULTY_OPTIONS:
		_difficulty_picker.add_item(option[0])
		_difficulty_picker.set_item_metadata(_difficulty_picker.item_count - 1, option[1])
	for i in _difficulty_picker.item_count:
		if _difficulty_picker.get_item_metadata(i) == default_difficulty:
			_difficulty_picker.select(i)
			return


## The level name of the selected difficulty, falling back to `default_difficulty` if
## nothing is selected (never the case while options are defined).
func _selected_difficulty() -> String:
	if _difficulty_picker.selected < 0:
		return default_difficulty
	return _difficulty_picker.get_item_metadata(_difficulty_picker.selected)


func _on_practice_pressed() -> void:
	practice_requested.emit(_selected_hero(), _selected_difficulty())


func _on_host_pressed() -> void:
	host_requested.emit()


## Resolves the typed address — falling back to `default_address` when blank — and
## emits `join_requested`. Trimmed so stray whitespace is not taken as a host name.
func _on_join_pressed() -> void:
	var address := _address_field.text.strip_edges()
	if address.is_empty():
		address = default_address
	join_requested.emit(address)