feat(*): Add self-play RL training pipeline with PPO trainer, in-game GDScript policy inference, and bot opponent support in Match mode

This commit is contained in:
Josh Creek
2026-07-18 19:32:51 +01:00
parent 328831df1f
commit 85f96eb15e
81 changed files with 3934 additions and 10 deletions
+23 -4
View File
@@ -1,15 +1,22 @@
extends GameMode
# Timed match: two teams, score tracking, kickoff resets after each goal.
# The opponent ship is currently inert (base ShipController, zero action) —
# it becomes the AI opponent once an AIShipController exists (see TODO.md),
# and additional player ships once multiplayer lands.
# The opponent is a trained AI bot when a policy model is configured
# (see TRAINING.md for training and promoting models into res://bots/),
# otherwise an inert placeholder ship.
signal timer_updated(minutes: int, seconds: int)
signal score_changed(score: Dictionary)
@export var match_length_seconds := 150.0
@export_group("AI opponent")
# Trained policy for the opponent; empty = inert placeholder ship.
@export_file("*.json") var bot_model_path: String = ""
# Difficulty handicaps, applied on top of the model (see AIShipController).
@export_range(1, 60) var bot_reaction_ticks: int = 8
@export_range(0.0, 1.0) var bot_action_noise: float = 0.0
var score := {0: 0, 1: 0}
var match_timer: Timer
@@ -18,7 +25,7 @@ func _start() -> void:
spawn_ball()
var player_ship := spawn_ship(0, 0, PlayerShipController.new())
spawn_camera_rig(player_ship)
spawn_ship(1, 0, ShipController.new()) # inert placeholder opponent
spawn_ship(1, 0, _make_opponent_controller())
match_timer = Timer.new()
match_timer.one_shot = true
@@ -28,6 +35,18 @@ func _start() -> void:
match_timer.start()
func _make_opponent_controller() -> ShipController:
if not bot_model_path.is_empty() and FileAccess.file_exists(bot_model_path):
var bot := AIShipController.new()
bot.model_path = bot_model_path
bot.reaction_ticks = bot_reaction_ticks
bot.action_noise = bot_action_noise
return bot
if not bot_model_path.is_empty():
push_warning("MatchMode: bot model not found at %s, spawning inert opponent" % bot_model_path)
return ShipController.new() # inert placeholder
func _process(_delta):
if match_timer and match_timer.time_left > 0:
var remaining := ceili(match_timer.time_left)