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)