feat(input): full controller support, rebindable controls, and rotation fixes

Playing with a gamepad did not work: all six move_* actions had no joypad
event at all, so a pad could yaw/pitch/roll/turbo but could not translate.
Nothing caught it because every action existed and the game booted fine —
no assertion checked that an action is reachable on *both* devices.

Controller layout, on the 6DOF convention (left stick aims, right stick
translates), using all six of the pad's analog axes for the ship's six
degrees of freedom:

  left stick   yaw + pitch        right stick  strafe + vertical
  LB / RB      roll               RT / LT      forward / back
  L3           turbo              R3           ball camera

Input is now read with Input.get_axis instead of is_action_pressed, so
triggers and sticks are proportional. Keyboard values are unchanged.

Three rotation bugs found by measuring a real Ship rather than reading the
code:

- apply_torque() is world-space and the torque was never rotated into the
  hull's frame (unlike thrust, which uses -ship_basis.z). Roll input became
  pitch after a 90 degree turn and inverted at 180, so the controls were
  correct flying up-field and backwards flying back.
- ship.tscn's inertia is Vector3(7, 1, 7) but a flat torque was applied to
  every axis, giving yaw 7x the angular acceleration of pitch and roll
  (172 deg/s vs 52). Torque is now scaled per-axis by inertia, so
  rotation_acceleration means rad/s^2 and all three axes match. Yaw is
  unchanged.
- pitch_down pitched the nose UP: get_axis's arguments were reversed, so
  the I/K keys and the stick each did the opposite of their label.

Menus were unusable on a pad for a separate reason: Godot 4.7 gives
ui_up/down/left/right joypad events by default but leaves ui_accept and
ui_cancel with none (verified against a pristine project), so a controller
could move the highlight and never press anything. A confirms and B goes
back. Gameplay exits on a new leave_gameplay action (Escape / Start) rather
than ui_cancel, so carrying B for menus cannot abandon a live match.

Bindings for both devices are rebindable in Settings -> Controls, persisted
to user://input.cfg — a separate file from settings.cfg because
VideoSettings.save() rewrites that file wholesale and would drop any
section it does not know about. project.godot stays the source of truth for
defaults; overrides are only ever a delta on top of a boot-time snapshot.

Verified: 268 unit tests, the ENet integration gate, and a 16-sample
before/after comparison of networked prediction residuals showing the
physics change does not regress them (median 0.083m -> 0.065m).

Note for follow-up: every policy in Game/bots/ was trained against the old
sluggish, world-axis rotation and will over-rotate until retrained.
This commit is contained in:
Josh Creek
2026-09-06 20:41:20 +01:00
parent 00b900d864
commit 076d27a564
17 changed files with 1430 additions and 87 deletions
+18 -9
View File
@@ -15,7 +15,7 @@ const SimConstants = preload("res://scripts/sim_constants.gd")
@export var vertical_thrust = 120.0 # Up/down thruster power
@export var turbo_multiplier = 2.5 # Turbo boost multiplier
@export var max_speed = 35.0 # Maximum velocity
@export var rotation_power = 20.0 # Angular thrust power
@export var rotation_acceleration = 20.0 # Angular acceleration, rad/s^2, equal on all three axes (see apply_rotation_forces)
@export var max_angular_speed = 3.0 # Maximum rotation speed
@export var drag_coefficient = 0.98 # Linear drag (air resistance)
@export var angular_drag = 0.95 # Rotational drag
@@ -557,18 +557,27 @@ func apply_rotation_forces(state: PhysicsDirectBodyState3D, rotation_input: Vect
if rotation_input.length() < 0.01:
return
# Apply torque for rotation - simple and effective
# Physics: τ = I * α (torque = moment of inertia × angular acceleration)
# Also: α = τ / I (angular acceleration = torque / moment of inertia)
# Lower inertia = higher angular acceleration for same torque
# Scaling each axis by its own inertia makes rotation_acceleration mean
# exactly that — α, in rad/s² — so all three axes respond identically.
# ship.tscn's inertia is Vector3(7, 1, 7): a flat torque across all three
# axes therefore used to give yaw 7x the angular acceleration of pitch and
# roll (172 deg/s vs 52 deg/s at steady state). That was an accident of the
# inertia tensor rather than a design decision, and it read as "rotation is
# sluggish except when turning".
var torque = Vector3(
rotation_input.x * rotation_power, # Pitch (rotation around X-axis)
rotation_input.y * rotation_power, # Yaw (rotation around Y-axis)
rotation_input.z * rotation_power # Roll (rotation around Z-axis)
rotation_input.x * rotation_acceleration * inertia.x, # Pitch (local X)
rotation_input.y * rotation_acceleration * inertia.y, # Yaw (local Y)
rotation_input.z * rotation_acceleration * inertia.z # Roll (local Z)
)
# Physics: Δω = τ * Δt / I (change in angular velocity = torque × time / inertia)
state.apply_torque(torque)
# apply_torque() is world-space, and the vector above is in the ship's own
# frame, so it MUST be rotated by the hull's basis — exactly as thrust is
# (see the -ship_basis.z term in apply_thrust_forces). Without this the
# ship rotated about the world axes: roll input became pitch once the ship
# had yawed 90 degrees, and both roll and pitch inverted at 180 degrees, so
# the controls were correct flying up-field and backwards flying back.
state.apply_torque(state.transform.basis * torque)
# Scales a per-tick decay multiplier `k` (defined at a 60 Hz reference rate)