mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +00:00
132 lines
5.1 KiB
GDScript
132 lines
5.1 KiB
GDScript
class_name ShipAIController
|
|
extends AIController3D
|
|
|
|
# Training-side bridge between godot_rl_agents and a ship. This is the only
|
|
# class that touches plugin types (AIController3D / the Sync node protocol) —
|
|
# everything else stays behind the ShipController seam: actions received from
|
|
# the trainer are written into an RLShipController, which the ship pulls like
|
|
# any other controller.
|
|
#
|
|
# Action space is ShipAction verbatim: 6 continuous axes (thrust xyz,
|
|
# rotation xyz, each -1..1) + binary turbo. ShipAction axes are ship-local
|
|
# (body frame), so they need no team mirroring — only observations do
|
|
# (see ShipObservations.canon).
|
|
|
|
# Reward shaping weights. Dense terms accrue per physics tick (60 sim-ticks
|
|
# per sim-second); event terms fire once. Exported so tuning needs no code
|
|
# edits. Goal rewards are added by TrainingMode, which owns goal events.
|
|
@export var ball_touch_reward := 0.25
|
|
@export var velocity_to_ball_weight := 0.002
|
|
@export var ball_velocity_to_goal_weight := 0.004
|
|
# Per-tick penalty while pressed against a side wall, end wall, or the
|
|
# ceiling — NOT the floor (run03 lesson: taxing floor contact punishes the
|
|
# ship's natural low flight and drowns every other signal). At 60 ticks per
|
|
# sim-second this is -0.3/s: parked on a wall for a full 30 s episode loses
|
|
# ~9 — comparable to conceding — while a brief graze costs almost nothing.
|
|
@export var wall_contact_penalty := 0.005
|
|
# Per-tick penalty for not being upright, scaled by tilt: 0 when flat, full
|
|
# value (-0.12/s) when inverted. A penalty rather than an upright bonus so a
|
|
# flat, idle ship farms nothing.
|
|
@export var tilt_penalty := 0.002
|
|
|
|
# A ship (1x1x4 box) touching a wall has its centre within ~2.05 of it;
|
|
# 2.5 adds slack for contact jitter without misreading mid-field contact.
|
|
const WALL_PROXIMITY_MARGIN := 2.5
|
|
|
|
var ship: Ship
|
|
var rl_controller: RLShipController
|
|
var ball: RigidBody3D
|
|
var opponent: Ship
|
|
var attack_goal_position: Vector3
|
|
|
|
|
|
# Wire up references after the ship is spawned. `attack_goal` is the goal
|
|
# this ship scores into (goal.team == opponent's team).
|
|
func setup(p_ship: Ship, p_rl_controller: RLShipController, p_ball: RigidBody3D, p_opponent: Ship, p_attack_goal_position: Vector3) -> void:
|
|
ship = p_ship
|
|
rl_controller = p_rl_controller
|
|
ball = p_ball
|
|
opponent = p_opponent
|
|
attack_goal_position = p_attack_goal_position
|
|
init(ship)
|
|
|
|
# Contact monitoring for the ball-touch reward (training-only cost;
|
|
# the shipped game leaves contact_monitor off).
|
|
ship.contact_monitor = true
|
|
ship.max_contacts_reported = 8
|
|
ship.body_entered.connect(_on_ship_body_entered)
|
|
|
|
|
|
func get_obs() -> Dictionary:
|
|
return {"obs": ShipObservations.build(ship, opponent, ball, attack_goal_position)}
|
|
|
|
|
|
func get_reward() -> float:
|
|
return reward
|
|
|
|
|
|
func get_action_space() -> Dictionary:
|
|
return {
|
|
"thrust": {"size": 3, "action_type": "continuous"},
|
|
"rotation": {"size": 3, "action_type": "continuous"},
|
|
"turbo": {"size": 2, "action_type": "discrete"},
|
|
}
|
|
|
|
|
|
func set_action(action) -> void:
|
|
var thrust: Array = action["thrust"]
|
|
var rot: Array = action["rotation"]
|
|
rl_controller.action.thrust = Vector3(thrust[0], thrust[1], thrust[2])
|
|
rl_controller.action.rotation = Vector3(rot[0], rot[1], rot[2])
|
|
rl_controller.action.turbo = int(action["turbo"]) == 1
|
|
|
|
|
|
func _physics_process(delta):
|
|
super(delta)
|
|
if not is_instance_valid(ship) or not is_instance_valid(ball):
|
|
return
|
|
|
|
# Dense shaping: own velocity toward the ball
|
|
var to_ball := ball.global_position - ship.global_position
|
|
if to_ball.length_squared() > 0.0001:
|
|
var closing_speed := ship.linear_velocity.dot(to_ball.normalized())
|
|
reward += velocity_to_ball_weight * closing_speed / ship.max_speed
|
|
|
|
# Dense shaping: ball velocity toward the goal we attack
|
|
var ball_to_goal := attack_goal_position - ball.global_position
|
|
if ball_to_goal.length_squared() > 0.0001:
|
|
var ball_progress := ball.linear_velocity.dot(ball_to_goal.normalized())
|
|
reward += ball_velocity_to_goal_weight * ball_progress / ShipObservations.BALL_SPEED_SCALE
|
|
|
|
# Dense penalty: every tick spent pressed against a wall or the ceiling
|
|
# (contact monitoring is already on for the ball-touch reward). Ships
|
|
# bumping each other, the ball, or the floor is fine. The boundary is one
|
|
# body, so position tells us which surface the contact is.
|
|
if wall_contact_penalty > 0.0 and _touching_boundary() and _near_wall_or_ceiling():
|
|
reward -= wall_contact_penalty
|
|
|
|
# Dense penalty: tilt away from upright (0 flat, max when inverted) —
|
|
# discourages ending up on a side or roof without rewarding idleness.
|
|
if tilt_penalty > 0.0:
|
|
var uprightness: float = ship.global_transform.basis.y.dot(Vector3.UP)
|
|
reward -= tilt_penalty * (1.0 - uprightness) * 0.5
|
|
|
|
|
|
func _touching_boundary() -> bool:
|
|
for body in ship.get_colliding_bodies():
|
|
if body is ArenaBoundary:
|
|
return true
|
|
return false
|
|
|
|
|
|
func _near_wall_or_ceiling() -> bool:
|
|
var p := ship.global_position
|
|
return absf(p.x) > ArenaBoundary.INNER_HALF_X - WALL_PROXIMITY_MARGIN \
|
|
or absf(p.z) > ArenaBoundary.INNER_HALF_Z - WALL_PROXIMITY_MARGIN \
|
|
or p.y > ArenaBoundary.INNER_HEIGHT - WALL_PROXIMITY_MARGIN
|
|
|
|
|
|
func _on_ship_body_entered(body: Node) -> void:
|
|
if body.is_in_group("ball"):
|
|
reward += ball_touch_reward
|