From 4507b6dc1ba8c00547d14a3687f6dc2f1e5be534 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:31:39 +0100 Subject: [PATCH] feat: add main-menu difficulty picker for Match mode Replace the raw checkpoint dropdown with curated Easy/Medium/Hard presets that drive GameSettings' bot model/reaction_ticks/action_noise overrides. Move raw-checkpoint testing and Spectate mode into a dev-only section hidden via OS.is_debug_build() so they disappear from release exports. --- Game/scenes/main_menu.tscn | 57 +++++++++++++++++++------ Game/scripts/game_settings.gd | 12 ++++++ Game/scripts/main_menu.gd | 79 ++++++++++++++++++++++++++++------- Game/scripts/match_mode.gd | 4 +- TODO.md | 4 +- 5 files changed, 125 insertions(+), 31 deletions(-) diff --git a/Game/scenes/main_menu.tscn b/Game/scenes/main_menu.tscn index d4bdf8d7..dac2804b 100644 --- a/Game/scenes/main_menu.tscn +++ b/Game/scenes/main_menu.tscn @@ -71,11 +71,11 @@ text = "A 2:30 match — you vs a trained bot" layout_mode = 2 theme_override_constants/separation = 10 -[node name="OpponentLabel" type="Label" parent="CenterContainer/VBoxContainer/MatchRow"] +[node name="DifficultyLabel" type="Label" parent="CenterContainer/VBoxContainer/MatchRow"] layout_mode = 2 -text = "Opponent" +text = "Difficulty" -[node name="BotDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/MatchRow"] +[node name="DifficultyDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/MatchRow"] unique_name_in_owner = true custom_minimum_size = Vector2(0, 40) layout_mode = 2 @@ -86,45 +86,78 @@ custom_minimum_size = Vector2(0, 56) layout_mode = 2 text = "Play Match" -[node name="SpectateSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"] +[node name="DevSection" type="VBoxContainer" parent="CenterContainer/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="DevSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer/DevSection"] layout_mode = 2 -[node name="SpectateHeader" type="Label" parent="CenterContainer/VBoxContainer"] +[node name="DevHeader" type="Label" parent="CenterContainer/VBoxContainer/DevSection"] +layout_mode = 2 +theme_override_font_sizes/font_size = 22 +text = "Developer" + +[node name="DevHint" type="Label" parent="CenterContainer/VBoxContainer/DevSection"] +modulate = Color(1, 1, 1, 0.55) +layout_mode = 2 +theme_override_font_sizes/font_size = 13 +text = "Dev-only — hidden in release builds" + +[node name="DevOpponentRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer/DevSection"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="DevOpponentLabel" type="Label" parent="CenterContainer/VBoxContainer/DevSection/DevOpponentRow"] +layout_mode = 2 +text = "Opponent override" + +[node name="DevBotDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/DevSection/DevOpponentRow"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 40) +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="SpectateSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer/DevSection"] +layout_mode = 2 + +[node name="SpectateHeader" type="Label" parent="CenterContainer/VBoxContainer/DevSection"] layout_mode = 2 theme_override_font_sizes/font_size = 22 text = "Spectate" -[node name="SpectateHint" type="Label" parent="CenterContainer/VBoxContainer"] +[node name="SpectateHint" type="Label" parent="CenterContainer/VBoxContainer/DevSection"] modulate = Color(1, 1, 1, 0.55) layout_mode = 2 theme_override_font_sizes/font_size = 13 text = "Watch two bots play each other" -[node name="SpectateRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"] +[node name="SpectateRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer/DevSection"] layout_mode = 2 theme_override_constants/separation = 10 -[node name="BotADropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/SpectateRow"] +[node name="BotADropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/DevSection/SpectateRow"] unique_name_in_owner = true custom_minimum_size = Vector2(0, 40) layout_mode = 2 size_flags_horizontal = 3 -[node name="VsLabel" type="Label" parent="CenterContainer/VBoxContainer/SpectateRow"] +[node name="VsLabel" type="Label" parent="CenterContainer/VBoxContainer/DevSection/SpectateRow"] layout_mode = 2 text = "vs" -[node name="BotBDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/SpectateRow"] +[node name="BotBDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/DevSection/SpectateRow"] unique_name_in_owner = true custom_minimum_size = Vector2(0, 40) layout_mode = 2 size_flags_horizontal = 3 -[node name="SpectateButton" type="Button" parent="CenterContainer/VBoxContainer"] +[node name="SpectateButton" type="Button" parent="CenterContainer/VBoxContainer/DevSection"] custom_minimum_size = Vector2(0, 56) layout_mode = 2 text = "Watch Match" [connection signal="pressed" from="CenterContainer/VBoxContainer/FreePlayButton" to="." method="_on_free_play_pressed"] [connection signal="pressed" from="CenterContainer/VBoxContainer/MatchButton" to="." method="_on_match_pressed"] -[connection signal="pressed" from="CenterContainer/VBoxContainer/SpectateButton" to="." method="_on_spectate_pressed"] +[connection signal="pressed" from="CenterContainer/VBoxContainer/DevSection/SpectateButton" to="." method="_on_spectate_pressed"] diff --git a/Game/scripts/game_settings.gd b/Game/scripts/game_settings.gd index 0548e6be..c33f9d9f 100644 --- a/Game/scripts/game_settings.gd +++ b/Game/scripts/game_settings.gd @@ -5,6 +5,18 @@ extends Node # Opponent policy chosen in the main menu; empty = use the mode's own export. var selected_bot_path: String = "" +# Difficulty handicaps chosen alongside selected_bot_path; -1/-1.0 = use the +# mode's own export. +var selected_bot_reaction_ticks: int = -1 +var selected_bot_action_noise: float = -1.0 +# Name of the chosen difficulty tier, kept only to reselect the right +# dropdown item when returning to the menu. +var selected_difficulty_name: String = "" +# Dev-only: raw checkpoint override for Match's opponent; empty = use the +# difficulty tier above instead. Kept separate from selected_bot_path so the +# dev dropdown's own selection survives independently of which difficulty is +# picked. +var dev_bot_override_path: String = "" # Spectate (bot vs bot) policies chosen in the main menu; empty = mode export. var spectate_bot_a_path: String = "" diff --git a/Game/scripts/main_menu.gd b/Game/scripts/main_menu.gd index 4ac2484c..c0263323 100644 --- a/Game/scripts/main_menu.gd +++ b/Game/scripts/main_menu.gd @@ -1,26 +1,54 @@ extends Control -# Main menu: one handler per game mode. Bot dropdowns are populated from -# res://bots at runtime so newly trained bots appear automatically — never -# maintain a hardcoded list. Selections are pushed into the GameSettings -# autoload for the target mode to read, and restored when returning to the -# menu within a session. +# 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" -@onready var bot_dropdown: OptionButton = %BotDropdown +const DIFFICULTIES := [ + {"name": "Easy", "model": "res://bots/promoted/easy.json", "reaction_ticks": 24, "action_noise": 0.35}, + {"name": "Medium", "model": "res://bots/promoted/easy.json", "reaction_ticks": 14, "action_noise": 0.15}, + {"name": "Hard", "model": "res://bots/promoted/easy.json", "reaction_ticks": 8, "action_noise": 0.0}, +] + +@onready var difficulty_dropdown: OptionButton = %DifficultyDropdown +@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 func _ready() -> void: - var bots := _list_bots() - _populate_dropdown(bot_dropdown, bots, GameSettings.selected_bot_path) - _populate_dropdown(bot_a_dropdown, bots, GameSettings.spectate_bot_a_path) - _populate_dropdown(bot_b_dropdown, bots, GameSettings.spectate_bot_b_path) + _populate_difficulty_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) $CenterContainer/VBoxContainer/FreePlayButton.grab_focus() +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) + + +# 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) @@ -28,20 +56,30 @@ func _list_bots() -> Array[String]: 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). 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) -> void: +# 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() + 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(dropdown.item_count - 1) + 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) @@ -59,7 +97,18 @@ func _on_free_play_pressed() -> void: func _on_match_pressed() -> void: - GameSettings.selected_bot_path = _selected_path(bot_dropdown) + 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 get_tree().change_scene_to_file("res://scenes/match.tscn") diff --git a/Game/scripts/match_mode.gd b/Game/scripts/match_mode.gd index 0143668b..c6aa5b49 100644 --- a/Game/scripts/match_mode.gd +++ b/Game/scripts/match_mode.gd @@ -55,8 +55,8 @@ func _make_opponent_controller() -> ShipController: if not path.is_empty() and FileAccess.file_exists(path): var bot := AIShipController.new() bot.model_path = path - bot.reaction_ticks = bot_reaction_ticks - bot.action_noise = bot_action_noise + bot.reaction_ticks = bot_reaction_ticks if GameSettings.selected_bot_reaction_ticks < 0 else GameSettings.selected_bot_reaction_ticks + bot.action_noise = bot_action_noise if GameSettings.selected_bot_action_noise < 0.0 else GameSettings.selected_bot_action_noise return bot if not path.is_empty(): push_warning("MatchMode: bot model not found at %s, spawning inert opponent" % path) diff --git a/TODO.md b/TODO.md index cc820341..0e5f9a15 100644 --- a/TODO.md +++ b/TODO.md @@ -11,7 +11,7 @@ The training pipeline is built — see `TRAINING.md` (self-play PPO via the vend - [x] Staged curriculum (score → defend → avoid draws → full mechanics) via `train.py`'s `--opponent-mode`/`--draw-penalty`/`--attack-goal-bias`/`--vertical-ramp`/`--pitch-roll-ramp` flags — see TRAINING.md's "Curriculum training" section. `--opponent-mode=frozen` is a single-fixed-model slice of the league idea below, not the full sampled pool. - [ ] Frozen-opponent league: train the live policy against a *pool* of past exported checkpoints, sampled per-episode (today's `--opponent-mode=frozen` only supports one fixed model per run) to prevent self-play strategy collapse on long runs. - [ ] Richer state setter / curriculum: aerial states, wall plays, rebound scenarios as skill grows (beyond the score/defend/draw staging already in place). -- [ ] Main-menu difficulty picker (Match already takes `bot_model_path`/`bot_reaction_ticks`/`bot_action_noise` exports). +- [x] Main-menu difficulty picker: Easy/Medium/Hard presets in `main_menu.gd` set `bot_model_path`/`bot_reaction_ticks`/`bot_action_noise` via `GameSettings`. Raw-checkpoint testing and Spectate mode moved into a `DevSection` hidden outside debug builds. All three tiers currently reuse `promoted/easy.json` with different handicaps until `medium`/`hard` checkpoints are promoted. ## Multiplayer (long term) @@ -22,5 +22,5 @@ The training pipeline is built — see `TRAINING.md` (self-play PPO via the vend ## General -- [ ] No autoloads yet by design — add a singleton only when cross-scene state is actually needed (e.g. passing match settings/results between menu, match, and results screens). +- [x] `GameSettings` autoload (`scripts/game_settings.gd`) added for exactly the cross-scene case this item anticipated — passing menu selections (bot/difficulty, spectate matchup) into match/spectate modes. Still the only autoload; keep it that way unless another cross-scene need shows up. - [ ] More arenas: `arena_01.tscn` is the template — an arena is a setting (sky, lighting, decoration) + two team-tagged goals on the standard goal lines + spawn markers + an instance of the shared `objects/arena_boundary.tscn`, with no rules or state. All arenas share the standard play volume defined by `ArenaBoundary`'s constants.