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.
This commit is contained in:
Josh Creek
2026-08-03 14:31:39 +01:00
parent 20f6be7e28
commit 4507b6dc1b
5 changed files with 125 additions and 31 deletions
+45 -12
View File
@@ -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"]
+12
View File
@@ -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 = ""
+64 -15
View File
@@ -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")
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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.