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
+90
View File
@@ -0,0 +1,90 @@
class_name AIShipController
extends ShipController
# Drives a ship from a trained self-play policy (see TRAINING.md). Builds the
# same canonical observation as training (ShipObservations) and runs the
# policy MLP in GDScript (PolicyNetwork) — the shipped bot has no Python,
# .NET, or network dependency.
#
# Difficulty is (model, reaction_ticks, action_noise): weaker checkpoints make
# easier bots outright, and the two knobs handicap a given model further —
# slower reactions and noisier execution. Models live in res://bots/.
@export_file("*.json") var model_path: String = ""
# Decide a new action every N physics ticks, holding the last one between
# decisions. 8 matches the training action_repeat; larger = slower reactions.
@export_range(1, 60) var reaction_ticks: int = 8
# Uniform noise magnitude added to each action axis (0 = play at full skill).
@export_range(0.0, 1.0) var action_noise: float = 0.0
var _policy: PolicyNetwork
var _action := ShipAction.new()
var _ticks_until_decision := 0
var _ship: Ship
var _opponent: Ship
var _ball: RigidBody3D
var _attack_goal_position: Vector3
var _scene_refs_ready := false
func _ready():
if not model_path.is_empty():
_policy = PolicyNetwork.load_from_file(model_path)
func get_action() -> ShipAction:
if _policy == null:
return _action # unloaded model: behaves like the inert placeholder
if not _scene_refs_ready and not _discover_scene_refs():
return _action
_ticks_until_decision -= 1
if _ticks_until_decision <= 0:
_ticks_until_decision = reaction_ticks
_decide()
return _action
func _decide() -> void:
var obs := ShipObservations.build(_ship, _opponent, _ball, _attack_goal_position)
var out := _policy.forward(obs)
# Output layout matches the flattened training action space (Box(7)):
# thrust xyz, rotation xyz, turbo (> 0 means on).
_action.thrust = Vector3(
_axis(out[0]),
_axis(out[1]),
_axis(out[2])
)
_action.rotation = Vector3(
_axis(out[3]),
_axis(out[4]),
_axis(out[5])
)
_action.turbo = out[6] > 0.0
func _axis(value: float) -> float:
if action_noise > 0.0:
value += randf_range(-action_noise, action_noise)
return clampf(value, -1.0, 1.0)
# Find ship/ball/opponent/goal once everything is spawned. ShipAction axes
# are body-frame so only observations need team context (ShipObservations).
func _discover_scene_refs() -> bool:
_ship = get_parent() as Ship
if _ship == null or not is_inside_tree():
return false
_ball = get_tree().get_first_node_in_group("ball")
for node in get_tree().get_nodes_in_group("ship"):
if node != _ship:
_opponent = node
break
for goal in get_tree().get_nodes_in_group("goal"):
if goal.team == 1 - _ship.team:
_attack_goal_position = goal.global_position
if _ball == null:
return false
_scene_refs_ready = true
return true