mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
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:
@@ -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
|
||||
Reference in New Issue
Block a user