diff --git a/Game/scripts/game_mode.gd b/Game/scripts/game_mode.gd index b880f7e6..103617d9 100644 --- a/Game/scripts/game_mode.gd +++ b/Game/scripts/game_mode.gd @@ -85,15 +85,36 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig: return rig +# Tiny per-reset randomization, well below anything a player would notice as +# "not a real kickoff" — just enough that two ships running the identical +# deterministic AI policy (action_noise = 0) don't start every kickoff from a +# bit-for-bit mirror-symmetric state. A perfectly symmetric state feeds both +# controllers identical (canonicalized) observations, so they emit mirrored +# actions and can lock into a repetitive, non-scoring stalemate — much more +# visible bot-vs-bot (same model both sides) than bot-vs-human, since a human +# never satisfies "identical policy" in the first place. See training_mode.gd's +# _end_eval_episode, which randomizes eval episode states for the same reason. +const KICKOFF_POSITION_JITTER := 0.3 +const KICKOFF_YAW_JITTER := deg_to_rad(15.0) + + func reset_ball() -> void: if is_instance_valid(ball): - _reset_body(ball, arena.get_ball_spawn()) + _reset_body(ball, _jittered(arena.get_ball_spawn(), KICKOFF_POSITION_JITTER, 0.0)) func reset_ships() -> void: for ship in ships: if is_instance_valid(ship): - _reset_body(ship, _ship_spawn_transforms[ship]) + _reset_body(ship, _jittered(_ship_spawn_transforms[ship], KICKOFF_POSITION_JITTER, KICKOFF_YAW_JITTER)) + + +func _jittered(to: Transform3D, position_jitter: float, yaw_jitter: float) -> Transform3D: + var offset := Vector3(randf_range(-position_jitter, position_jitter), 0.0, randf_range(-position_jitter, position_jitter)) + var basis := to.basis + if yaw_jitter > 0.0: + basis = basis.rotated(Vector3.UP, randf_range(-yaw_jitter, yaw_jitter)) + return Transform3D(basis, to.origin + offset) func _reset_body(body: RigidBody3D, to: Transform3D) -> void: