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 # Contact normals with y above this are floor contact (exempt from the wall # penalty); below it they read as wall (sideways) or ceiling (downward). const FLOOR_NORMAL_MIN_Y := 0.7 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 the contact normal tells us which surface: floor contact # pushes the ship up (+Y), walls push sideways, the ceiling down. if wall_contact_penalty > 0.0 and _wall_or_ceiling_contact(): 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 _wall_or_ceiling_contact() -> bool: var state := PhysicsServer3D.body_get_direct_state(ship.get_rid()) if state == null: return false for i in state.get_contact_count(): if not state.get_contact_collider_object(i) is ArenaBoundary: continue # Normal points from the surface into the ship: floor ≈ +Y (exempt), # anything flatter or downward is a wall or the ceiling. if state.get_contact_local_normal(i).y < FLOOR_NORMAL_MIN_Y: return true return false func _on_ship_body_entered(body: Node) -> void: if body.is_in_group("ball"): reward += ball_touch_reward