Files
CosmicClash/Game/scripts/ship_ai_controller.gd
T
Josh Creek 88591e031f chore(training): add air_approach_weight and restart stage-5 intercepts
Stage 5 blocked all three attempts on productive_air_touch_fraction
stuck exactly at 0.0 across a continuous 180M-step lineage, while
goal_rate/upright_fraction/forward_motion_fraction kept improving on
the same budget. forward_velocity_to_ball_weight (the term that solved
Stage 4's ground pursuit) is hard-gated below GROUND_HANDLING_HEIGHT
and does nothing in the air, so Stage 5's air_intercept_chance had no
matching aerial incentive to learn from. air_approach_weight adds the
airborne mirror (nose-first 3D closing speed, no uprightness
multiplier) and folds into HANDLING_REWARD_FLAGS so Stage 6 inherits
it too. Deleted the three blocked attempts and reset state to resume
Stage 5 from the Stage-4 checkpoint with the new term.
2026-08-18 16:03:56 +01:00

520 lines
28 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/layout is owned by ShipActionCodec (get_action_space/
# set_action just delegate to it) — see that file for the per-axis
# MultiDiscrete design and why. 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.4
# Ball touches pay out at most once per this many physics ticks (1 sim-
# second at 60). Run07 lesson: body_entered re-fires on every micro-
# separation, so pinning the ball against a surface farmed ~2 touches/s —
# outearning every other term while the goal rate fell. The cooldown keeps
# touches a stepping-stone signal instead of the objective. Halved again
# after run01-vs-run02 eval (training/eval_history.json) came back 87.5%
# draws: even at 1 touch/s, a full episode's worth of touches could still
# outweigh TrainingMode's goal_reward, so scoring and ending the episode
# early was never worth it. See goal_reward's comment for the other half of
# this fix.
@export var ball_touch_cooldown_ticks := 60
# A touch pays out scaled by how goal-directed it was — full ball_touch_reward
# when the post-touch ball velocity points straight at the attack goal, down
# to this floor when it doesn't (0 = only goal-directed touches pay at all).
# Without this, any contact paid the same regardless of direction, so batting
# the ball anywhere counted the same as an actual shot on goal — reinforcing
# possession, not scoring. The floor keeps a purely defensive touch (e.g.
# clearing a shot away from your own goal) worth something as a stepping
# stone, matching ball_touch_cooldown_ticks's existing "stepping-stone, not
# the objective" framing.
@export_range(0.0, 1.0) var ball_touch_direction_floor := 0.3
@export var velocity_to_ball_weight := 0.02
# Dense reward for approaching the ball *nose first* near the floor. Unlike
# velocity_to_ball_weight, sideways/reverse closing velocity earns nothing:
# the planar ship-forward vector must face the ball and planar velocity must
# have a positive component along it. Default off so existing curricula and
# frozen checkpoints keep their original objective; generation 5 handling
# turns it on while reducing the orientation-agnostic term.
@export var forward_velocity_to_ball_weight := 0.0
# Aerial mirror of forward_velocity_to_ball_weight: nose-first closing speed
# on the ball, active above GROUND_HANDLING_HEIGHT instead of below it (the
# two are mutually exclusive by altitude, never both active on the same
# tick). Generation 5's intercepts stage added air_intercept_chance without
# an airborne equivalent of the term that actually solved ground handling;
# above 3m the only remaining approach incentive was the generic, orientation
# -agnostic velocity_to_ball_weight (0.02-0.04), which three consecutive
# 60M-step attempts (180M cumulative, resuming each time) showed produces
# zero learnable gradient toward touching an aerial ball at all —
# productive_air_touch_fraction stayed exactly 0.0 the whole time while every
# other metric kept improving on the same budget. Uses the full 3D nose
# vector rather than the planar-only one, since a real aerial requires
# pitching away from level.
@export var air_approach_weight := 0.0
@export var ball_velocity_to_goal_weight := 0.004
# Per-tick penalty scaled by distance to the ball (full value at the arena's
# far diagonal, 0 on top of the ball). Run04 lesson: with idling worth a flat
# 0, camping in a corner strictly dominated risking the wall/tilt penalties
# to chase the ball — this makes "do nothing far from the ball" the worst
# option instead of the safest. A penalty, not a proximity bonus, so orbiting
# the ball farms nothing.
@export var ball_distance_penalty := 0.002
# 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.15/s. Halved for run05: the ball lives near walls,
# and the old -0.3/s made the productive region of the pitch aversive
# relative to the (then far weaker) ball-seeking shaping.
@export var wall_contact_penalty := 0.0025
# Per-tick penalty for not being upright, scaled by tilt: 0 when flat, full
# value when inverted. A penalty rather than an upright bonus so a flat, idle
# ship farms nothing. Lowered 4x for curriculum generation 4 (was 0.002,
# -0.12/s): a genuine aerial approach to a high ball requires pitching, and
# the old value quietly opposed the exact behaviour generation 4 is trying
# to teach. Not removed outright — an always-inverted bot still looks bad in
# a shipped game.
@export var tilt_penalty := 0.0005
# Additional tilt cost that fades to zero over the first few metres above the
# floor. This can teach readable, upright ground handling without opposing
# pitch/roll during a real aerial. Generation 5 uses this instead of raising
# the global tilt_penalty back to its pre-flight value.
@export var ground_tilt_penalty := 0.0
# Per-tick penalty on the planar-velocity component not pointed along the
# nose (sideways or reverse), independent of the ball — the mirror image of
# forward_velocity_to_ball_weight's ball-conditioned bonus. Same
# GROUND_HANDLING_HEIGHT altitude fade as ground_tilt_penalty.
@export var non_forward_penalty := 0.0
# Per-tick bonus for genuinely resting on the floor (ShipObservations.
# is_floor_contact, real contact — not just being below
# GROUND_HANDLING_HEIGHT) while upright. The positive counterpart to
# ground_tilt_penalty/non_forward_penalty: without it, staying above
# GROUND_HANDLING_HEIGHT is reward-neutral relative to grounding, so a
# policy that's still bad at ground handling could "solve" those penalties
# by just avoiding the floor rather than by getting better at handling on
# it — worsening Stage 3's already-airborne-heavy baseline instead of
# fixing it. An initial 0.015 overshot this: it's a *guaranteed* per-tick
# reward, so it needs to stay below ball_distance_penalty's worst case
# (idling at the arena's far corner), not just "comparable" to it — at
# 0.015 (above ball_distance_penalty's 0.01 ceiling) a Stage-4 run
# converged on sitting pinned upright and farming this instead of chasing
# the ball, cratering goal_rate. Keep this term's episode-long ceiling
# (value * ~1800 ticks) below ball_distance_penalty's worst-case episode
# cost, not just below ball_touch_reward/goal_reward.
#
# SUPERSEDED (2026-08-12), kept at 0 for older curricula that set it: the
# magnitude was never the real problem. Retuning it 0.015 -> 0.004 only
# moved along a tradeoff — at 0.015 upright_fraction climbed while
# goal_rate sagged, at 0.004 goal_rate climbed while upright_fraction went
# flat — because an *additive* uprightness reward is an alternative to
# playing well, so the policy just picks whichever is cheaper. Uprightness
# is now a multiplier inside the forward-approach term below instead, which
# makes it conjunctive with (not competing against) moving forward at the
# ball. Prefer that pattern for any future posture shaping; only reach for
# a standalone additive posture bonus if there is genuinely nothing to
# condition it on.
@export var grounded_upright_reward := 0.0
# Per-tick bonus for own speed: 0 stationary, full value (+0.24/s) at
# max_speed. Run07 lesson: after the kickoff flurry both ships parked next to
# a cornered ball — with every other dense term near zero there, standing
# still was a rest state. Sized well below velocity_to_ball_weight so flying
# fast toward the ball still beats flying fast anywhere else.
@export var speed_reward_weight := 0.004
# Flat per-tick cost (-0.06/s, -1.8 over a full 30s episode) applied
# regardless of position or behaviour. Every other dense term can be farmed
# indefinitely by an episode that never ends in a goal; this one can't — it
# only stops accruing once the episode does, via a goal or the timeout. That
# makes running the clock out strictly worse than scoring as soon as a
# chance appears, instead of a free way to keep collecting dense reward.
@export var time_penalty := 0.001
# Per-tick penalty scaled by height above the floor (0 on the floor, full
# value at the arena's ceiling) — distinct from the locomotion mask, which
# only discards *thrust*-driven vertical/pitch-roll input; a masked ship can
# still be launched airborne by collisions (ball impacts, ship-vs-ship
# knockback, the wall/ceiling surface-pull field), and nothing previously
# penalized time spent up there. Default 0 (off) so ordinary runs are
# unaffected; the floor-lock curriculum stage turns it on.
@export var airborne_penalty := 0.0
# Height above which a touch counts toward air_touch_fraction telemetry
# (see get_info) — not a reward term itself, see set_action/get_info's
# comments on why generation 4 deliberately does not add a standalone
# air-touch reward.
const AIR_TOUCH_HEIGHT := 5.0
# Generation-5 ground-handling telemetry/reward thresholds. Fixed constants
# keep the logged metrics comparable across stages; changing one starts a new
# metric definition and therefore requires a fresh baseline.
const GROUND_HANDLING_HEIGHT := 3.0
const UPRIGHT_DOT_THRESHOLD := 0.7
const FORWARD_MOTION_DOT_THRESHOLD := 0.7
const MIN_HANDLING_SPEED := 1.0
const PRODUCTIVE_AIR_TOUCH_ALIGNMENT := 0.5
# Contact normals with y above this are floor contact (exempt from the wall
# penalty); below it they read as wall (sideways) or ceiling (downward).
# Mirrors ShipObservations.FLOOR_NORMAL_MIN_Y (see that file's comment).
const FLOOR_NORMAL_MIN_Y := 0.7
# Longest possible ship-to-ball separation: the enclosure's interior diagonal.
# Normalizes ball_distance_penalty so its export is the worst-case per-tick cost.
const MAX_BALL_DISTANCE := sqrt(
(2.0 * ArenaBoundary.INNER_HALF_X) ** 2
+ (2.0 * ArenaBoundary.INNER_HALF_Z) ** 2
+ ArenaBoundary.INNER_HEIGHT ** 2
)
var ship: Ship
var rl_controller: RLShipController
var ball: RigidBody3D
var teammates: Array[Ship] = []
var opponents: Array[Ship] = []
var attack_goal_position: Vector3
# Set directly by TrainingMode (_on_goal_scored / the timeout branch in
# _physics_process) at the same time as `done = true`. Deliberately NOT
# cleared in reset(): TrainingMode's _reset_episode() (which calls reset())
# runs synchronously, immediately after done is set, before the Sync node
# ever reads get_info()/get_done() for that terminal tick — clearing it here
# would wipe the value that read needs. Both call sites always overwrite
# (true on goal, false on timeout) rather than toggle, so no reset is needed.
var goal_scored_this_episode := false
# Set only in the timeout branch (TrainingMode._physics_process), never on a
# goal — a goal is a genuine terminal (V(s)=0 is correct there); a timeout
# is an artificial episode boundary the value function should be bootstrapped
# through instead (see get_info). Same "always overwritten by both call
# sites, never cleared in reset()" pattern as goal_scored_this_episode above,
# for the same reason.
var truncated_this_episode := false
var terminal_obs: Array = []
var _ticks_since_ball_touch := 1 << 30 # large so the first touch always pays
# Flight telemetry (see get_info) — leading indicators for whether the
# policy is actually using its vertical/pitch-roll authority, visible from
# the first rollout instead of only in a win-rate number measured a full
# training run later. Accumulated per-tick, reset() zeroes them each episode;
# get_info() reports the running fraction/mean so the *final* tick of an
# episode (the one VecMonitor's info_keywords captures) holds the whole
# episode's aggregate.
const AIRBORNE_ALTITUDE_THRESHOLD := 3.0
var _telemetry_ticks := 0
var _airborne_ticks := 0
var _altitude_sum := 0.0
var _thrust_y_sum := 0.0
var _touches := 0
var _air_touches := 0
var _productive_air_touches := 0
var _ground_ticks := 0
var _upright_ground_ticks := 0
var _moving_ground_ticks := 0
var _forward_moving_ground_ticks := 0
# Diagnostic (non-gating) counterpart to _ground_ticks/_upright_ground_ticks.
# Those use altitude (< GROUND_HANDLING_HEIGHT) as a proxy for "on the
# ground", but with airborne_fraction ~0.45 and mean_altitude ~4.4m a large
# share of sub-3m ticks are really ballistic transit — climbing, descending,
# or tumbling after contact — where attitude is neither controllable nor
# meaningful, so upright_fraction systematically understates how upright the
# ship is when it is actually driving. These count only ticks with genuine
# floor contact, which is the thing "keep the belly on the floor" actually
# means. Kept separate from (not a replacement for) upright_fraction so the
# gated metric's definition stays comparable across every past stage.
var _floor_contact_ticks := 0
var _upright_floor_contact_ticks := 0
# Wire up references after the ship is spawned. `attack_goal` is the goal
# this ship scores into (goal.team == the opposing team's team).
func setup(
p_ship: Ship, p_rl_controller: RLShipController, p_ball: RigidBody3D,
p_teammates: Array[Ship], p_opponents: Array[Ship], p_attack_goal_position: Vector3
) -> void:
ship = p_ship
rl_controller = p_rl_controller
ball = p_ball
teammates = p_teammates
opponents = p_opponents
attack_goal_position = p_attack_goal_position
init(ship)
# ship.contact_monitor is on unconditionally (see ship.gd) since
# ShipObservations now reads it for every ship, not just training agents.
ship.body_entered.connect(_on_ship_body_entered)
func get_obs() -> Dictionary:
return {"obs": ShipObservations.build(ship, teammates, opponents, ball, attack_goal_position)}
func get_reward() -> float:
return reward
# Symmetric across both self-play agents. "goal_scored": whether this
# episode ended in a goal at all (not which team) — a clean "goal rate"
# signal distinct from rollout/ep_rew_mean, which mixes this with dense
# shaping (ball chasing/touching); see train.py's GoalRateCallback.
# "truncated"/"terminal_obs": only present on a timeout tick — remapped by
# cosmic_env.py into SB3's expected "TimeLimit.truncated"/
# "terminal_observation" keys so PPO bootstraps V(s) through episode
# timeouts instead of treating every 30s draw as a true terminal state (a
# real, previously-unnoticed bug independent of the action-space work — see
# TRAINING.md). Flight telemetry fields are leading indicators for
# generation 4's core hypothesis (see train.py's FlightTelemetryCallback).
func get_info() -> Dictionary:
# The four telemetry keys must ALWAYS be present (not just when their
# denominator is nonzero) — VecMonitor's info_keywords does a bare
# info[key] lookup on whatever info dict is attached to a completed
# episode's terminal step (see train.py's VecMonitor(...,
# info_keywords=(...))) and raises KeyError, crashing the whole training
# run, if a key is ever missing. 0.0 is a reasonable default for "no
# touches/no ticks yet" (in practice _telemetry_ticks is >0 by the time
# any episode ends; _touches often legitimately is 0).
var info := {"goal_scored": goal_scored_this_episode}
if truncated_this_episode:
info["truncated"] = true
info["terminal_obs"] = terminal_obs
info["airborne_fraction"] = float(_airborne_ticks) / _telemetry_ticks if _telemetry_ticks > 0 else 0.0
info["mean_altitude"] = _altitude_sum / _telemetry_ticks if _telemetry_ticks > 0 else 0.0
info["vertical_thrust_mean"] = _thrust_y_sum / _telemetry_ticks if _telemetry_ticks > 0 else 0.0
info["air_touch_fraction"] = float(_air_touches) / _touches if _touches > 0 else 0.0
info["productive_air_touch_fraction"] = float(_productive_air_touches) / _touches if _touches > 0 else 0.0
info["upright_fraction"] = float(_upright_ground_ticks) / _ground_ticks if _ground_ticks > 0 else 0.0
info["forward_motion_fraction"] = float(_forward_moving_ground_ticks) / _moving_ground_ticks if _moving_ground_ticks > 0 else 0.0
# Diagnostic only — deliberately NOT in any stage's telemetry_floors (see
# generation5.py). Unlike the counters above, _floor_contact_ticks can
# legitimately be 0 for a whole episode (a policy that never touches down),
# so the 0.0 default here is load-bearing, not just defensive.
info["grounded_upright_fraction"] = \
float(_upright_floor_contact_ticks) / _floor_contact_ticks if _floor_contact_ticks > 0 else 0.0
return info
func get_action_space() -> Dictionary:
return ShipActionCodec.action_space_dict()
func set_action(action) -> void:
rl_controller.action = ShipActionCodec.apply_team_frame(
ShipActionCodec.from_indices(action), ship.team
)
func reset():
super()
_ticks_since_ball_touch = 1 << 30
_telemetry_ticks = 0
_airborne_ticks = 0
_altitude_sum = 0.0
_thrust_y_sum = 0.0
_touches = 0
_air_touches = 0
_productive_air_touches = 0
_ground_ticks = 0
_upright_ground_ticks = 0
_moving_ground_ticks = 0
_forward_moving_ground_ticks = 0
_floor_contact_ticks = 0
_upright_floor_contact_ticks = 0
func _physics_process(delta):
super(delta)
if not is_instance_valid(ship) or not is_instance_valid(ball):
return
_ticks_since_ball_touch += 1
# Flat time cost — see time_penalty.
reward -= time_penalty
# 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
# Ground-handling shaping: upright, forward planar motion while the nose
# faces the ball. It fades out with altitude so an aerial remains free to
# approach a ball using whatever body attitude is effective.
#
# Uprightness is a *multiplier* here rather than a separate additive term,
# and that is the whole point. Stage 4's earlier rounds paid uprightness
# additively (grounded_upright_reward): because additive terms let a
# policy collect whichever one is cheapest, it could either play well
# (tilted, scoring) or sit parked upright (still, not scoring) — and it
# picked one or the other depending purely on that term's magnitude, so
# upright_fraction and goal_rate moved in opposite directions at every
# value tried. As a multiplier, uprightness pays only while the ship is
# also moving forward and nose-on to the ball, so no subset of the three
# behaviours can be farmed in isolation: parked pays zero (forward_speed
# is zero), on-its-side pays zero (uprightness is zero), and only doing
# all three at once pays full.
if forward_velocity_to_ball_weight > 0.0 and ship.global_position.y < GROUND_HANDLING_HEIGHT:
var planar_forward := Vector3(-ship.global_transform.basis.z.x, 0.0, -ship.global_transform.basis.z.z)
var planar_velocity := Vector3(ship.linear_velocity.x, 0.0, ship.linear_velocity.z)
var planar_to_ball := Vector3(to_ball.x, 0.0, to_ball.z)
if planar_forward.length_squared() > 0.0001 and planar_to_ball.length_squared() > 0.0001:
planar_forward = planar_forward.normalized()
var facing_ball: float = maxf(planar_forward.dot(planar_to_ball.normalized()), 0.0)
var forward_speed: float = maxf(planar_velocity.dot(planar_forward), 0.0) / ship.max_speed
var approach_uprightness: float = maxf(ship.global_transform.basis.y.dot(Vector3.UP), 0.0)
var handling_ground_factor: float = 1.0 - clampf(ship.global_position.y / GROUND_HANDLING_HEIGHT, 0.0, 1.0)
reward += forward_velocity_to_ball_weight * forward_speed * facing_ball \
* approach_uprightness * handling_ground_factor
# Aerial shaping: nose-first 3D closing speed on the ball (see
# air_approach_weight). Mirrors the ground block above but with the full
# nose vector instead of the planar one, and no uprightness multiplier —
# a genuine aerial approach requires pitching away from level, so paying
# only while upright would oppose the exact behaviour this rewards.
if air_approach_weight > 0.0 and ship.global_position.y >= GROUND_HANDLING_HEIGHT \
and to_ball.length_squared() > 0.0001:
var nose_forward := -ship.global_transform.basis.z
if nose_forward.length_squared() > 0.0001:
nose_forward = nose_forward.normalized()
var to_ball_dir := to_ball.normalized()
var air_facing_ball: float = maxf(nose_forward.dot(to_ball_dir), 0.0)
var air_closing_speed: float = maxf(ship.linear_velocity.dot(to_ball_dir), 0.0) / ship.max_speed
reward += air_approach_weight * air_closing_speed * air_facing_ball
# Dense penalty: distance to the ball, so idling far away bleeds reward
# instead of scoring a safe zero (see ball_distance_penalty).
if ball_distance_penalty > 0.0:
reward -= ball_distance_penalty * to_ball.length() / MAX_BALL_DISTANCE
# Dense bonus: own speed, so hovering in place is never a rest state
# (see speed_reward_weight).
if speed_reward_weight > 0.0:
reward += speed_reward_weight * ship.linear_velocity.length() / 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
# Low-altitude-only posture pressure (see ground_tilt_penalty).
if ground_tilt_penalty > 0.0 and ship.global_position.y < GROUND_HANDLING_HEIGHT:
var ground_uprightness: float = ship.global_transform.basis.y.dot(Vector3.UP)
var tilt_ground_factor: float = 1.0 - clampf(ship.global_position.y / GROUND_HANDLING_HEIGHT, 0.0, 1.0)
reward -= ground_tilt_penalty * (1.0 - ground_uprightness) * 0.5 * tilt_ground_factor
# Dense penalty: any planar velocity component not pointed along the nose
# (sideways or reverse), independent of the ball — the mirror image of
# forward_velocity_to_ball_weight's ball-conditioned bonus. Fades out with
# altitude via the same GROUND_HANDLING_HEIGHT ramp as ground_tilt_penalty.
# non_forward_speed is the true lateral magnitude (Pythagorean, not the
# cruder planar_speed - forward_component, which under-charges diagonal
# motion — e.g. at 45 degrees off the nose that gave ~29% of full-speed
# penalty instead of the correct ~71%) for any forward-facing component;
# a backward-facing component (dot product below zero) is fully
# penalized regardless of angle, same as pure sideways motion.
if non_forward_penalty > 0.0 and ship.global_position.y < GROUND_HANDLING_HEIGHT:
var non_forward_planar_velocity := Vector3(ship.linear_velocity.x, 0.0, ship.linear_velocity.z)
var non_forward_planar_speed := non_forward_planar_velocity.length()
var non_forward_planar_forward := Vector3(-ship.global_transform.basis.z.x, 0.0, -ship.global_transform.basis.z.z)
if non_forward_planar_speed > 0.0001 and non_forward_planar_forward.length_squared() > 0.0001:
var forward_component: float = non_forward_planar_velocity.dot(non_forward_planar_forward.normalized())
var non_forward_speed: float
if forward_component >= 0.0:
non_forward_speed = sqrt(maxf(
non_forward_planar_speed * non_forward_planar_speed - forward_component * forward_component, 0.0
))
else:
non_forward_speed = non_forward_planar_speed
var non_forward_ground_factor: float = 1.0 - clampf(ship.global_position.y / GROUND_HANDLING_HEIGHT, 0.0, 1.0)
reward -= non_forward_penalty * (non_forward_speed / ship.max_speed) * non_forward_ground_factor
# Dense bonus: genuinely resting on the floor while upright (see
# grounded_upright_reward) — the positive counterpart to
# ground_tilt_penalty/non_forward_penalty, so grounding is worth
# pursuing, not just less punished than staying airborne.
if grounded_upright_reward > 0.0 and ShipObservations.is_floor_contact(ship):
var grounded_uprightness: float = ship.global_transform.basis.y.dot(Vector3.UP)
reward += grounded_upright_reward * maxf(grounded_uprightness, 0.0)
# Dense penalty: height above the floor (see airborne_penalty). The
# floor sits at world y = 0 (see training_mode.gd's FIELD_MIN_Y/
# _escaped bounds); normalized so the worst case is pinned at the
# ceiling.
if airborne_penalty > 0.0:
var height := maxf(ship.global_position.y, 0.0)
reward -= airborne_penalty * height / ArenaBoundary.INNER_HEIGHT
# Flight telemetry accumulation (see get_info) — not reward, just
# observation of what the policy is actually doing this tick.
_telemetry_ticks += 1
_altitude_sum += ship.global_position.y
if ship.global_position.y > AIRBORNE_ALTITUDE_THRESHOLD:
_airborne_ticks += 1
# Diagnostic: uprightness measured only while genuinely touching the floor
# (see _floor_contact_ticks). Same UPRIGHT_DOT_THRESHOLD as the altitude-
# based metric so the two are directly comparable.
if ShipObservations.is_floor_contact(ship):
_floor_contact_ticks += 1
if ship.global_transform.basis.y.dot(Vector3.UP) >= UPRIGHT_DOT_THRESHOLD:
_upright_floor_contact_ticks += 1
_thrust_y_sum += rl_controller.action.thrust.y
if ship.global_position.y < GROUND_HANDLING_HEIGHT:
_ground_ticks += 1
if ship.global_transform.basis.y.dot(Vector3.UP) >= UPRIGHT_DOT_THRESHOLD:
_upright_ground_ticks += 1
var planar_velocity := Vector3(ship.linear_velocity.x, 0.0, ship.linear_velocity.z)
if planar_velocity.length() >= MIN_HANDLING_SPEED:
_moving_ground_ticks += 1
var planar_forward := Vector3(-ship.global_transform.basis.z.x, 0.0, -ship.global_transform.basis.z.z)
if planar_forward.length_squared() > 0.0001 \
and planar_velocity.normalized().dot(planar_forward.normalized()) >= FORWARD_MOTION_DOT_THRESHOLD:
_forward_moving_ground_ticks += 1
func _wall_or_ceiling_contact() -> bool:
return ShipObservations.contact_normal(ship) != Vector3.ZERO
func _on_ship_body_entered(body: Node) -> void:
if not body.is_in_group("ball") or _ticks_since_ball_touch < ball_touch_cooldown_ticks:
return
# Contact-signal ordering means ball.linear_velocity here already reflects
# the collision impulse from this touch, not the pre-touch velocity.
var alignment := 0.0
var to_goal := attack_goal_position - ball.global_position
if to_goal.length_squared() > 0.0001 and ball.linear_velocity.length_squared() > 0.0001:
alignment = clampf(ball.linear_velocity.normalized().dot(to_goal.normalized()), 0.0, 1.0)
reward += ball_touch_reward * lerpf(ball_touch_direction_floor, 1.0, alignment)
_ticks_since_ball_touch = 0
# Telemetry only (see get_info's air_touch_fraction) — not a reward term.
# Generation 4 deliberately doesn't reward high touches directly (see
# TRAINING.md's "why no air-touch reward" note); this just measures
# whether the air-drill state setter is producing genuine aerial
# contests, so a future decision to add one is data-driven.
_touches += 1
if ball.global_position.y > AIR_TOUCH_HEIGHT:
_air_touches += 1
if alignment >= PRODUCTIVE_AIR_TOUCH_ALIGNMENT:
_productive_air_touches += 1