extends Control # Main menu: one handler per game mode. Match's opponent is a curated # Easy/Medium/Hard difficulty picker (DIFFICULTIES) rather than a raw # checkpoint list — see TRAINING.md for promoting new tiers into # res://bots/promoted/. Raw-checkpoint testing and Spectate (bot vs bot) are # dev-only tools, grouped under DevSection and hidden outside debug builds so # they disappear automatically from release exports. Selections are pushed # into the GameSettings autoload for the target mode to read, and restored # when returning to the menu within a session. const BOTS_DIR := "res://bots" # Every tier runs its promoted checkpoint at full trained capability — # difficulty is a genuinely different policy, never the same policy # handicapped with reaction delay or action noise. All three tiers are now # distinct models: medium.json beats easy.json 65-22-13, and hard.json (the # generation-5 Stage-5 policy) beats medium.json 47-32-21. Keep the tiers # monotonic: never leave a lower tier pointing at a stronger model than the # one above it. const DIFFICULTIES := [ {"name": "Easy", "model": "res://bots/promoted/easy.json", "reaction_ticks": 8, "action_noise": 0.0}, {"name": "Medium", "model": "res://bots/promoted/medium.json", "reaction_ticks": 8, "action_noise": 0.0}, {"name": "Hard", "model": "res://bots/promoted/hard.json", "reaction_ticks": 8, "action_noise": 0.0}, ] @onready var difficulty_dropdown: OptionButton = %DifficultyDropdown @onready var arena_dropdown: OptionButton = %ArenaDropdown @onready var dev_section: Control = %DevSection @onready var dev_bot_dropdown: OptionButton = %DevBotDropdown @onready var bot_a_dropdown: OptionButton = %BotADropdown @onready var bot_b_dropdown: OptionButton = %BotBDropdown @onready var join_address_edit: LineEdit = %JoinAddressEdit @onready var multiplayer_error_label: Label = %MultiplayerErrorLabel @onready var connecting_overlay: Control = %ConnectingOverlay @onready var connecting_status_label: Label = %ConnectingStatusLabel func _ready() -> void: AudioManager.bind_tree_buttons(self) # An idle menu has no reason to render past the display's own refresh # rate; gameplay scenes are uncapped again by _leave_to_gameplay below. var refresh_rate := DisplayServer.screen_get_refresh_rate() Engine.max_fps = int(refresh_rate) if refresh_rate > 0 else 0 _populate_difficulty_dropdown() _populate_arena_dropdown() dev_section.visible = OS.is_debug_build() if dev_section.visible: var bots := _list_bots() _populate_dropdown(dev_bot_dropdown, bots, GameSettings.dev_bot_override_path, true) _populate_dropdown(bot_a_dropdown, bots, GameSettings.spectate_bot_a_path) _populate_dropdown(bot_b_dropdown, bots, GameSettings.spectate_bot_b_path) NetworkManager.connected_to_server.connect(_on_connected_to_server) NetworkManager.connection_failed.connect(_on_connection_failed) %FreePlayButton.grab_focus() # main_menu.gd's first async flow (task 1.7): Host is synchronous # (NetworkManager.host() either succeeds immediately or fails immediately), # but Join is not — it can take anywhere from a clean local-network round # trip to ENet's own ~5s connect timeout to resolve, so unlike every other # handler in this file (GameSettings.x = y; change_scene_to_file(...)) it # needs a loading state (ConnectingOverlay), a cancel path, and a failure # path that returns the player to a sane, retryable menu state rather than # just hanging with no feedback. func _process(_delta: float) -> void: NetworkManager.poll() func _physics_process(_delta: float) -> void: NetworkManager.poll() func _populate_difficulty_dropdown() -> void: difficulty_dropdown.clear() for tier in DIFFICULTIES: difficulty_dropdown.add_item(tier["name"]) var selected := 0 for i in DIFFICULTIES.size(): if DIFFICULTIES[i]["name"] == GameSettings.selected_difficulty_name: selected = i break difficulty_dropdown.select(selected) # Free Play only — Match/Spectate pick a random arena in code instead. func _populate_arena_dropdown() -> void: arena_dropdown.clear() for tier in ArenaRegistry.ARENAS: arena_dropdown.add_item(tier["name"]) var selected := 0 for i in ArenaRegistry.ARENAS.size(): if ArenaRegistry.ARENAS[i]["path"] == GameSettings.selected_arena_path: selected = i break arena_dropdown.select(selected) # Lists bot checkpoints as paths relative to BOTS_DIR, including the # top-level curriculum checkpoints and anything under promoted/. func _list_bots() -> Array[String]: var files: Array[String] = [] var dir := DirAccess.open(BOTS_DIR) if dir: for f in dir.get_files(): if f.get_extension() == "json": files.append(f) var promoted_dir := DirAccess.open(BOTS_DIR + "/promoted") if promoted_dir: for f in promoted_dir.get_files(): if f.get_extension() == "json": files.append("promoted/" + f) files.sort() return files # Fills a dropdown with bot names (metadata = full model path). When # include_none is true, prepends a "(Use difficulty)" sentinel (metadata "") # and defaults selection to it. Reselects `preferred_path` if it's still on # disk, else the newest (last) bot. func _populate_dropdown(dropdown: OptionButton, bots: Array[String], preferred_path: String, include_none: bool = false) -> void: dropdown.clear() # These are filled from whatever checkpoints happen to be in res://bots, so # a long filename would otherwise widen the OptionButton (size_flags_h = # EXPAND_FILL) and drag the whole menu past its 420px minimum width. dropdown.clip_text = true dropdown.fit_to_longest_item = false if include_none: dropdown.add_item("(Use difficulty)") dropdown.set_item_metadata(0, "") for f in bots: dropdown.add_item(f.get_basename()) dropdown.set_item_metadata(dropdown.item_count - 1, BOTS_DIR + "/" + f) if dropdown.item_count == 0: return dropdown.select(0 if include_none else dropdown.item_count - 1) for i in dropdown.item_count: if dropdown.get_item_metadata(i) == preferred_path: dropdown.select(i) break func _selected_path(dropdown: OptionButton) -> String: if dropdown.item_count > 0 and dropdown.selected >= 0: return dropdown.get_item_metadata(dropdown.selected) return "" # The menu's own refresh-rate fps cap (see _ready) is a menu-only concern; # gameplay scenes respect the player's own VideoSettings fps cap instead # (task 0.17), which only actually caps anything when vsync is Disabled and a # divisor is chosen — otherwise this uncaps exactly like the old hardcoded 0. func _leave_to_gameplay(scene_path: String) -> void: VideoSettings.apply_fps_cap() get_tree().change_scene_to_file(scene_path) func _on_free_play_pressed() -> void: var chosen: Dictionary = ArenaRegistry.ARENAS[0] if arena_dropdown.selected < 0 else ArenaRegistry.ARENAS[arena_dropdown.selected] GameSettings.selected_arena_path = chosen["path"] _leave_to_gameplay("res://scenes/free_play.tscn") func _on_match_pressed() -> void: var tier: Dictionary = DIFFICULTIES[difficulty_dropdown.selected] if difficulty_dropdown.selected >= 0 else DIFFICULTIES[0] var override_path := _selected_path(dev_bot_dropdown) if dev_section.visible else "" GameSettings.dev_bot_override_path = override_path GameSettings.selected_difficulty_name = tier["name"] if override_path.is_empty(): GameSettings.selected_bot_path = tier["model"] GameSettings.selected_bot_reaction_ticks = tier["reaction_ticks"] GameSettings.selected_bot_action_noise = tier["action_noise"] else: GameSettings.selected_bot_path = override_path GameSettings.selected_bot_reaction_ticks = -1 GameSettings.selected_bot_action_noise = -1.0 _leave_to_gameplay("res://scenes/match.tscn") func _on_settings_pressed() -> void: get_tree().change_scene_to_file(ScenePaths.SETTINGS) func _on_spectate_pressed() -> void: GameSettings.spectate_bot_a_path = _selected_path(bot_a_dropdown) GameSettings.spectate_bot_b_path = _selected_path(bot_b_dropdown) _leave_to_gameplay("res://scenes/spectate.tscn") func _on_host_pressed() -> void: _clear_multiplayer_error() var err := NetworkManager.host() if err != OK: _show_multiplayer_error("Could not host: %s" % error_string(err)) return _leave_to_lobby() func _on_find_match_pressed() -> void: get_tree().change_scene_to_file("res://scenes/matchmaking.tscn") func _on_join_pressed() -> void: _start_join() func _on_join_address_submitted(_new_text: String) -> void: _start_join() # ENet's own give-up-and-fire-connection_failed schedule is not bounded to # anything a menu should make a player wait for — verified empirically # (tests/main_menu_test_hooks.gd's join_refused case) against a genuinely # refused loopback connection: connection_failed never fired within 14s. # This timer is what actually guarantees "connection-refused reaches a sane # UI state" rather than leaving the overlay up indefinitely. const CONNECT_TIMEOUT_SECONDS := 6.0 var _connect_timeout_token := 0 # bumped on every new attempt/cancel/resolution so a stale timer callback is a no-op func _start_join() -> void: _clear_multiplayer_error() var address := join_address_edit.text.strip_edges() if address.is_empty(): _show_multiplayer_error("Enter an IP address to join") return var err := NetworkManager.join(address) if err != OK: _show_multiplayer_error("Could not join: %s" % error_string(err)) return connecting_status_label.text = "Connecting to %s..." % address connecting_overlay.visible = true _connect_timeout_token += 1 var my_token := _connect_timeout_token get_tree().create_timer(CONNECT_TIMEOUT_SECONDS).timeout.connect(func(): _on_connect_timeout(my_token)) func _on_connect_timeout(token: int) -> void: if token != _connect_timeout_token or not connecting_overlay.visible: return # a newer attempt (or Cancel, or a real success/failure) already resolved this NetworkManager.shutdown() connecting_overlay.visible = false _show_multiplayer_error("Connection timed out — check the address and that a server is hosting on that port") func _on_connecting_cancel_pressed() -> void: _connect_timeout_token += 1 NetworkManager.shutdown() connecting_overlay.visible = false func _on_connected_to_server() -> void: if not connecting_overlay.visible: return # e.g. a stray/late signal after Cancel already shut the peer down _connect_timeout_token += 1 connecting_overlay.visible = false _leave_to_lobby() func _on_connection_failed() -> void: if not connecting_overlay.visible: return _connect_timeout_token += 1 connecting_overlay.visible = false _show_multiplayer_error("Connection failed — check the address and that a server is hosting on that port") func _leave_to_lobby() -> void: get_tree().change_scene_to_file("res://scenes/lobby.tscn") func _show_multiplayer_error(message: String) -> void: multiplayer_error_label.text = message multiplayer_error_label.visible = true func _clear_multiplayer_error() -> void: multiplayer_error_label.visible = false