class_name ShipObservations extends RefCounted # Canonical, team-relative observation builder. Shared by training # (ShipAIController) and in-game inference (AIShipController) so a trained # policy sees byte-identical inputs in both contexts — do not fork this logic. # # Self-play trick: observations for team 1 are rotated 180° about Y # (x → -x, z → -z), so every ship perceives itself attacking toward -Z # regardless of which side it spawned on. One policy can then play both teams. # The same rotation must be inverted when interpreting actions (see canon — # it is its own inverse). # Normalization scales. Arena bounds: goals at z ≈ ±15.56, ship spawns at # z = ±12; positions are soft-normalized to roughly [-1, 1]. const POSITION_SCALE := Vector3(20.0, 10.0, 20.0) const BALL_SPEED_SCALE := 30.0 const GOAL_DISTANCE_SCALE := 40.0 # Number of floats build() returns; the policy input size. const SIZE := 31 # 180° rotation about Y for team 1; identity for team 0. A proper rotation # (preserves handedness), and its own inverse — used for both observations # and mapping canonical-frame actions back to world intent. static func canon(v: Vector3, team: int) -> Vector3: return v if team == 0 else Vector3(-v.x, v.y, -v.z) # attack_goal_position: centre of the goal this ship is trying to score in # (the goal whose `team` == the opponent's team). static func build(ship: Ship, opponent: Ship, ball: RigidBody3D, attack_goal_position: Vector3) -> Array: var team := ship.team var obs := [] # Own kinematics _append(obs, canon(ship.global_position, team) / POSITION_SCALE) _append(obs, canon(-ship.global_transform.basis.z, team)) # forward _append(obs, canon(ship.global_transform.basis.y, team)) # up _append(obs, canon(ship.linear_velocity, team) / ship.max_speed) _append(obs, canon(ship.angular_velocity, team) / ship.max_angular_speed) # Ball, relative to self var ball_rel := ball.global_position - ship.global_position _append(obs, canon(ball_rel, team) / POSITION_SCALE) _append(obs, canon(ball.linear_velocity, team) / BALL_SPEED_SCALE) # Opponent, relative to self (zeros if absent, e.g. a 1-ship drill) if is_instance_valid(opponent): var opp_rel := opponent.global_position - ship.global_position _append(obs, canon(opp_rel, team) / POSITION_SCALE) _append(obs, canon(opponent.linear_velocity, team) / ship.max_speed) else: _append(obs, Vector3.ZERO) _append(obs, Vector3.ZERO) # Goal we are attacking, relative to self var goal_rel := attack_goal_position - ship.global_position _append(obs, canon(goal_rel, team) / POSITION_SCALE) obs.append(goal_rel.length() / GOAL_DISTANCE_SCALE) return obs static func _append(obs: Array, v: Vector3) -> void: obs.append(v.x) obs.append(v.y) obs.append(v.z)