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
+52 -12
View File
@@ -27,18 +27,58 @@ Welcome to space, pilot! This guide will teach you everything you need to know a
### Controller Controls
#### Translation
Button names are the Xbox layout; a PlayStation pad maps the same physical
positions (A = ✕, B = ○, X = □, Y = △).
- Left Stick - Strafe (Left/Right) + Thrust (Forward/Back)
- Right Trigger - Forward Thrust
- Left Trigger - Reverse Thrust
- Face Buttons - Up/Down Thrust
**The left stick points the nose, the right stick moves the hull.** Your ship has
six degrees of freedom and a pad has exactly six analog axes, so every one gets a
real axis rather than an on/off button.
#### Rotation
#### Rotation — left stick and shoulders
- Right Stick - Pitch/Yaw
- Shoulder Buttons - Roll
- `A Button` - Turbo Boost
- Left Stick (left/right) - Yaw
- Left Stick (up/down) - Pitch. Flight-sim polarity by default: **push the
stick forward and the nose goes down.** Flip it with "Invert pitch" in
Settings → Controls.
- `LB` - Roll Left (Bank Left)
- `RB` - Roll Right (Bank Right)
#### Translation — right stick and triggers
- `RT` - Forward Thrust (Main Engines)
- `LT` - Reverse Thrust (Retro Engines)
- Right Stick (left/right) - Strafe (Port/Starboard Thrusters)
- Right Stick (up/down) - Thrust Up/Down (Dorsal/Ventral Thrusters)
- `L3` (click the left stick) - Turbo Boost
`X` and `Y` are deliberately unused in flight, and `A`/`B` are menu-only, so a
reflexive face-button press never does anything mid-match.
#### Menus
- D-Pad or Left Stick - move the highlight
- `A` - select
- `B` - back
- `Start` - leave a match in progress (deliberately not `B`, which is too easy
to press by accident mid-game)
#### Other
- `R3` (click the right stick) - Toggle ball camera
- `D-Pad Up` - Reset the ball (Free Play only)
The triggers and sticks are **analog**: a half-pulled trigger gives half thrust,
and a gentle stick lean gives a gentle turn. Keyboard keys are all-or-nothing,
which is the main reason a pad is easier to fly precisely.
### Rebinding
Every control above — keyboard and controller alike — can be remapped in
**Settings → Controls**. Pick the device with the Keyboard/Controller toggle,
click the binding you want to change, and press the key or button to assign.
Binding an input that is already in use unbinds it from the action that had it,
and the screen tells you which. "Reset all bindings to defaults" restores this
table.
## 🛸 Basic Flight Principles
@@ -69,7 +109,7 @@ Your ship is a **realistic space vehicle** with the following characteristics:
#### Camera Control
- **Ball Cam**: Press `Enter` to toggle ball tracking camera
- **Ball Cam**: Press `Space` (or `R3` on a controller) to toggle ball tracking camera
- **Ship Cam**: Normal follow camera that looks where your ship points
### Intermediate Maneuvers
@@ -115,7 +155,7 @@ Your ship is a **realistic space vehicle** with the following characteristics:
## 🎮 Ball Cam vs Ship Cam
### Ball Cam Mode (`Enter` to toggle)
### Ball Cam Mode (`Space` / `R3` to toggle)
- **Camera**: Always looks toward the ball
- **Ship Control**: Based on ship orientation (NOT camera view)
@@ -133,7 +173,7 @@ Your ship is a **realistic space vehicle** with the following characteristics:
### Turbo System
- **Activation**: Hold `Shift` (keyboard) or `A` (controller) while thrusting forward
- **Activation**: Hold `Shift` (keyboard) or `L3` (controller) while thrusting forward
- **Effect**: 2.5x thrust multiplier on main engines only
- **Strategy**: Use for quick acceleration or emergency maneuvers
+37 -3
View File
@@ -28,6 +28,7 @@ run/main_scene.dedicated_server="res://scenes/server_boot.tscn"
GameSettings="*res://scripts/game_settings.gd"
ControlPlaneClient="*res://scripts/control_plane_client.gd"
VideoSettings="*res://scripts/video_settings.gd"
InputSettings="*res://scripts/input_settings.gd"
BackgroundFPS="*res://scripts/background_fps.gd"
PerfOverlay="*res://scripts/perf_overlay.gd"
NetSim="*res://scripts/net_sim.gd"
@@ -55,42 +56,49 @@ enabled=PackedStringArray("res://addons/godot_rl_agents/plugin.cfg")
reset_ball={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":11,"pressure":0.0,"pressed":false,"script":null)
]
}
move_forward={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":5,"axis_value":1.0,"script":null)
]
}
move_back={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":4,"axis_value":1.0,"script":null)
]
}
move_left={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":2,"axis_value":-1.0,"script":null)
]
}
move_right={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":2,"axis_value":1.0,"script":null)
]
}
move_up={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":3,"axis_value":-1.0,"script":null)
]
}
move_down={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":81,"key_label":0,"unicode":113,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":3,"axis_value":1.0,"script":null)
]
}
turbo={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194325,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":7,"pressure":0.0,"pressed":false,"script":null)
]
}
turn_left={
@@ -108,13 +116,13 @@ turn_right={
pitch_up={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":73,"key_label":0,"unicode":105,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":-1.0,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":1.0,"script":null)
]
}
pitch_down={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":75,"key_label":0,"unicode":107,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":1.0,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":-1.0,"script":null)
]
}
roll_left={
@@ -129,6 +137,32 @@ roll_right={
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":10,"pressure":0.0,"pressed":false,"script":null)
]
}
toggle_ball_cam={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":8,"pressure":0.0,"pressed":false,"script":null)
]
}
ui_cancel={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":1,"pressure":0.0,"pressed":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":6,"pressure":0.0,"pressed":false,"script":null)
]
}
ui_accept={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194309,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194310,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":false,"script":null)
]
}
leave_gameplay={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":6,"pressure":0.0,"pressed":false,"script":null)
]
}
toggle_perf_overlay={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+129 -45
View File
@@ -1,7 +1,8 @@
[gd_scene load_steps=3 format=3]
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://scripts/settings_menu.gd" id="1_settings"]
[ext_resource type="Theme" path="res://themes/cosmic_clash_theme.tres" id="2_theme"]
[ext_resource type="Script" path="res://scripts/controls_settings.gd" id="3_controls"]
[node name="SettingsMenu" type="Control"]
layout_mode = 3
@@ -13,69 +14,88 @@ grow_vertical = 2
script = ExtResource("1_settings")
theme = ExtResource("2_theme")
[node name="CenterContainer" type="CenterContainer" parent="."]
[node name="MarginContainer" type="MarginContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/margin_left = 24
theme_override_constants/margin_top = 24
theme_override_constants/margin_right = 24
theme_override_constants/margin_bottom = 24
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
custom_minimum_size = Vector2(420, 0)
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
[node name="TitleLabel" type="Label" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
theme_override_font_sizes/font_size = 36
text = "Settings"
horizontal_alignment = 1
[node name="TitleSpacer" type="Control" parent="CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 14)
[node name="TabContainer" type="TabContainer" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
tab_alignment = 1
[node name="PresetRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
[node name="Video" type="ScrollContainer" parent="MarginContainer/VBoxContainer/TabContainer"]
layout_mode = 2
follow_focus = true
horizontal_scroll_mode = 0
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer"]
custom_minimum_size = Vector2(420, 0)
layout_mode = 2
theme_override_constants/separation = 10
[node name="PresetLabel" type="Label" parent="CenterContainer/VBoxContainer/PresetRow"]
[node name="PresetRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="PresetLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/PresetRow"]
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
text = "Graphics preset"
[node name="PresetDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/PresetRow"]
[node name="PresetDropdown" type="OptionButton" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/PresetRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
[node name="AARow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
[node name="AARow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="AALabel" type="Label" parent="CenterContainer/VBoxContainer/AARow"]
[node name="AALabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/AARow"]
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
text = "Anti-aliasing"
[node name="AADropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/AARow"]
[node name="AADropdown" type="OptionButton" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/AARow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
[node name="ResolutionRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
[node name="ResolutionRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="ResolutionLabel" type="Label" parent="CenterContainer/VBoxContainer/ResolutionRow"]
[node name="ResolutionLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/ResolutionRow"]
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
text = "Resolution scale"
[node name="ResolutionSlider" type="HSlider" parent="CenterContainer/VBoxContainer/ResolutionRow"]
[node name="ResolutionSlider" type="HSlider" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/ResolutionRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 24)
layout_mode = 2
@@ -86,23 +106,23 @@ max_value = 1.0
step = 0.05
value = 1.0
[node name="ResolutionValueLabel" type="Label" parent="CenterContainer/VBoxContainer/ResolutionRow"]
[node name="ResolutionValueLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/ResolutionRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(48, 0)
layout_mode = 2
text = "100%"
horizontal_alignment = 2
[node name="GlowRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
[node name="GlowRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="GlowLabel" type="Label" parent="CenterContainer/VBoxContainer/GlowRow"]
[node name="GlowLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/GlowRow"]
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
text = "Glow intensity"
[node name="GlowSlider" type="HSlider" parent="CenterContainer/VBoxContainer/GlowRow"]
[node name="GlowSlider" type="HSlider" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/GlowRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 24)
layout_mode = 2
@@ -113,23 +133,23 @@ max_value = 1.5
step = 0.05
value = 1.0
[node name="GlowValueLabel" type="Label" parent="CenterContainer/VBoxContainer/GlowRow"]
[node name="GlowValueLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/GlowRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(48, 0)
layout_mode = 2
text = "100%"
horizontal_alignment = 2
[node name="BrightnessRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
[node name="BrightnessRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="BrightnessLabel" type="Label" parent="CenterContainer/VBoxContainer/BrightnessRow"]
[node name="BrightnessLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/BrightnessRow"]
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
text = "Brightness"
[node name="BrightnessSlider" type="HSlider" parent="CenterContainer/VBoxContainer/BrightnessRow"]
[node name="BrightnessSlider" type="HSlider" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/BrightnessRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 24)
layout_mode = 2
@@ -140,72 +160,136 @@ max_value = 1.3
step = 0.02
value = 1.0
[node name="BrightnessValueLabel" type="Label" parent="CenterContainer/VBoxContainer/BrightnessRow"]
[node name="BrightnessValueLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/BrightnessRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(48, 0)
layout_mode = 2
text = "100%"
horizontal_alignment = 2
[node name="VsyncRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
[node name="VsyncRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="VsyncLabel" type="Label" parent="CenterContainer/VBoxContainer/VsyncRow"]
[node name="VsyncLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/VsyncRow"]
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
text = "VSync"
[node name="VsyncDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/VsyncRow"]
[node name="VsyncDropdown" type="OptionButton" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/VsyncRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
[node name="FpsCapRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
[node name="FpsCapRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="FpsCapLabel" type="Label" parent="CenterContainer/VBoxContainer/FpsCapRow"]
[node name="FpsCapLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsCapRow"]
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
text = "FPS cap"
[node name="FpsCapDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/FpsCapRow"]
[node name="FpsCapDropdown" type="OptionButton" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsCapRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
[node name="FpsReadoutRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
[node name="FpsReadoutRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="FpsReadoutTitleLabel" type="Label" parent="CenterContainer/VBoxContainer/FpsReadoutRow"]
[node name="FpsReadoutTitleLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsReadoutRow"]
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
text = "Current"
[node name="FpsReadoutLabel" type="Label" parent="CenterContainer/VBoxContainer/FpsReadoutRow"]
[node name="FpsReadoutLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsReadoutRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
text = "0 fps"
[node name="ButtonSpacer" type="Control" parent="CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 14)
[node name="Controls" type="ScrollContainer" parent="MarginContainer/VBoxContainer/TabContainer"]
visible = false
layout_mode = 2
follow_focus = true
horizontal_scroll_mode = 0
script = ExtResource("3_controls")
[node name="BackButton" type="Button" parent="CenterContainer/VBoxContainer"]
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/VBoxContainer/TabContainer/Controls"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer"]
custom_minimum_size = Vector2(520, 0)
layout_mode = 2
theme_override_constants/separation = 10
[node name="DeviceRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="DeviceLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer/DeviceRow"]
custom_minimum_size = Vector2(160, 0)
layout_mode = 2
text = "Device"
[node name="KeyboardButton" type="Button" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer/DeviceRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
toggle_mode = true
button_pressed = true
text = "Keyboard"
[node name="ControllerButton" type="Button" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer/DeviceRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
toggle_mode = true
text = "Controller"
[node name="StatusLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
modulate = Color(1, 0.85, 0.5, 1)
layout_mode = 2
theme_override_font_sizes/font_size = 13
text = ""
autowrap_mode = 2
[node name="BindingList" type="VBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 6
[node name="InvertPitchCheck" type="CheckBox" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
text = "Invert pitch"
[node name="ResetButton" type="Button" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 44)
layout_mode = 2
text = "Reset all bindings to defaults"
[node name="BackButton" type="Button" parent="MarginContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 56)
layout_mode = 2
text = "Back"
[connection signal="item_selected" from="CenterContainer/VBoxContainer/PresetRow/PresetDropdown" to="." method="_on_preset_dropdown_item_selected"]
[connection signal="item_selected" from="CenterContainer/VBoxContainer/AARow/AADropdown" to="." method="_on_aa_dropdown_item_selected"]
[connection signal="value_changed" from="CenterContainer/VBoxContainer/ResolutionRow/ResolutionSlider" to="." method="_on_resolution_slider_value_changed"]
[connection signal="value_changed" from="CenterContainer/VBoxContainer/GlowRow/GlowSlider" to="." method="_on_glow_slider_value_changed"]
[connection signal="value_changed" from="CenterContainer/VBoxContainer/BrightnessRow/BrightnessSlider" to="." method="_on_brightness_slider_value_changed"]
[connection signal="item_selected" from="CenterContainer/VBoxContainer/VsyncRow/VsyncDropdown" to="." method="_on_vsync_dropdown_item_selected"]
[connection signal="item_selected" from="CenterContainer/VBoxContainer/FpsCapRow/FpsCapDropdown" to="." method="_on_fps_cap_dropdown_item_selected"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/BackButton" to="." method="_on_back_pressed"]
[connection signal="item_selected" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/PresetRow/PresetDropdown" to="." method="_on_preset_dropdown_item_selected"]
[connection signal="item_selected" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/AARow/AADropdown" to="." method="_on_aa_dropdown_item_selected"]
[connection signal="value_changed" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/ResolutionRow/ResolutionSlider" to="." method="_on_resolution_slider_value_changed"]
[connection signal="value_changed" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/GlowRow/GlowSlider" to="." method="_on_glow_slider_value_changed"]
[connection signal="value_changed" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/BrightnessRow/BrightnessSlider" to="." method="_on_brightness_slider_value_changed"]
[connection signal="item_selected" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/VsyncRow/VsyncDropdown" to="." method="_on_vsync_dropdown_item_selected"]
[connection signal="item_selected" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsCapRow/FpsCapDropdown" to="." method="_on_fps_cap_dropdown_item_selected"]
[connection signal="pressed" from="MarginContainer/VBoxContainer/BackButton" to="." method="_on_back_pressed"]
+188
View File
@@ -0,0 +1,188 @@
extends ScrollContainer
# Settings screen's Controls tab: rebinds every action in InputSettings.ACTIONS
# for either device, toggles invert-pitch, and resets to the project.godot
# defaults. InputSettings owns the bindings themselves and their persistence;
# this script is only the editor for them, and deliberately keeps
# settings_menu.gd video-only.
#
# Rows are built in code rather than laid out in settings.tscn so the list stays
# derived from InputSettings.ACTIONS — adding a rebindable action means editing
# that one const, not this scene as well.
# A joypad axis has to travel this far before a capture accepts it. Resting
# stick drift is routinely a few percent off centre and would otherwise bind
# itself the instant the player opened a capture.
const AXIS_CAPTURE_THRESHOLD := 0.5
@onready var keyboard_button: Button = %KeyboardButton
@onready var controller_button: Button = %ControllerButton
@onready var status_label: Label = %StatusLabel
@onready var binding_list: VBoxContainer = %BindingList
@onready var invert_pitch_check: CheckBox = %InvertPitchCheck
@onready var reset_button: Button = %ResetButton
var _device: String = InputSettings.DEVICE_KEYBOARD
# The action currently awaiting an input event, or "" when not capturing.
var _capturing: String = ""
# action -> the row's Button, so a rebuild-free label refresh is possible and
# so capture can restore the right button's text on cancel.
var _row_buttons: Dictionary = {}
func _ready() -> void:
keyboard_button.pressed.connect(_on_device_selected.bind(InputSettings.DEVICE_KEYBOARD))
controller_button.pressed.connect(_on_device_selected.bind(InputSettings.DEVICE_JOYPAD))
invert_pitch_check.toggled.connect(_on_invert_pitch_toggled)
reset_button.pressed.connect(_on_reset_pressed)
invert_pitch_check.button_pressed = InputSettings.invert_pitch
_update_device_buttons()
_rebuild_rows()
func _on_device_selected(device: String) -> void:
_cancel_capture()
_device = device
_update_device_buttons()
_rebuild_rows()
func _update_device_buttons() -> void:
keyboard_button.button_pressed = _device == InputSettings.DEVICE_KEYBOARD
controller_button.button_pressed = _device == InputSettings.DEVICE_JOYPAD
func _rebuild_rows() -> void:
for child in binding_list.get_children():
child.queue_free()
_row_buttons.clear()
var last_group := ""
for entry in InputSettings.ACTIONS:
var group: String = entry["group"]
if group != last_group:
last_group = group
binding_list.add_child(_make_group_header(group))
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
var label := Label.new()
label.text = entry["label"]
label.custom_minimum_size = Vector2(200, 0)
row.add_child(label)
var button := Button.new()
var action: String = entry["action"]
button.text = InputSettings.binding_text(action, _device)
button.custom_minimum_size = Vector2(0, 36)
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.clip_text = true
button.pressed.connect(_begin_capture.bind(action))
row.add_child(button)
_row_buttons[action] = button
binding_list.add_child(row)
# Re-bound after every rebuild: the rows above are new nodes each time, so
# the buttons AudioManager was tracking no longer exist.
AudioManager.bind_tree_buttons(self)
func _make_group_header(group: String) -> Label:
var header := Label.new()
header.text = group
header.add_theme_font_size_override("font_size", 18)
header.modulate = Color(1, 1, 1, 0.7)
return header
func _begin_capture(action: String) -> void:
_cancel_capture()
_capturing = action
var button: Button = _row_buttons[action]
button.text = "Press a key…" if _device == InputSettings.DEVICE_KEYBOARD else "Press a button…"
status_label.text = "Listening — press Escape to cancel."
func _cancel_capture() -> void:
if _capturing == "":
return
var action := _capturing
_capturing = ""
if _row_buttons.has(action) and is_instance_valid(_row_buttons[action]):
_row_buttons[action].text = InputSettings.binding_text(action, _device)
status_label.text = ""
# _input rather than _unhandled_input: the row Button has focus while capturing,
# and an unhandled-input handler would never see the key that Button consumes as
# its own activation. Everything consumed here is marked handled so the pending
# event cannot also re-press that button and re-enter capture.
func _input(event: InputEvent) -> void:
if _capturing == "":
return
if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled()
_cancel_capture()
return
var captured := _capturable_event(event)
if captured == null:
return
get_viewport().set_input_as_handled()
var action := _capturing
_capturing = ""
var displaced := InputSettings.set_binding(action, captured)
_rebuild_rows()
if displaced.is_empty():
status_label.text = ""
else:
status_label.text = "Unbound %s — it was using the same input." % ", ".join(_labels_for(displaced))
# Returns the event to bind, or null if this event is not a legal binding for
# the device kind currently being edited. Keeping the check here means a joypad
# press can never land in the keyboard column just because that tab was open.
func _capturable_event(event: InputEvent) -> InputEvent:
if _device == InputSettings.DEVICE_KEYBOARD:
if event is InputEventKey and event.pressed and not event.echo:
var key := InputEventKey.new()
key.physical_keycode = event.physical_keycode
return key
return null
if event is InputEventJoypadButton and event.pressed:
var button := InputEventJoypadButton.new()
button.button_index = event.button_index
return button
if event is InputEventJoypadMotion and absf(event.axis_value) >= AXIS_CAPTURE_THRESHOLD:
var motion := InputEventJoypadMotion.new()
motion.axis = event.axis
motion.axis_value = signf(event.axis_value)
return motion
return null
func _labels_for(actions: PackedStringArray) -> PackedStringArray:
var out := PackedStringArray()
for action in actions:
for entry in InputSettings.ACTIONS:
if entry["action"] == action:
out.append(entry["label"])
break
return out
func _on_invert_pitch_toggled(pressed: bool) -> void:
InputSettings.invert_pitch = pressed
func _on_reset_pressed() -> void:
_cancel_capture()
InputSettings.reset_all()
invert_pitch_check.button_pressed = InputSettings.invert_pitch
_rebuild_rows()
status_label.text = "Bindings reset to defaults."
+1
View File
@@ -0,0 +1 @@
uid://dk7plirjfqvld
+5 -1
View File
@@ -276,7 +276,11 @@ func _reset_body(body: RigidBody3D, to: Transform3D) -> void:
func _unhandled_input(event):
if event.is_action_pressed("ui_cancel"):
# leave_gameplay (Escape / Start), NOT ui_cancel. ui_cancel carries the B
# button so menus behave the way a controller player expects, and B is far
# too easy to hit by accident for "abandon the match you are playing".
# Menus and the lobby still use ui_cancel; only live gameplay is guarded.
if event.is_action_pressed("leave_gameplay"):
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
+329
View File
@@ -0,0 +1,329 @@
extends Node
# Autoload: persisted keyboard/controller bindings on top of project.godot's
# [input] defaults, plus the flight-feel preferences that belong with them
# (invert pitch). The Settings screen's Controls tab (controls_settings.gd) is
# the only writer; PlayerShipController is the only reader of pitch_sign().
#
# project.godot stays the single source of truth for *defaults*: _ready()
# snapshots whatever InputMap holds at boot, before any override is applied, so
# the default table is never duplicated in GDScript and can never drift from the
# file. An override is only ever a delta on top of that snapshot.
#
# Persisted to user://input.cfg rather than user://settings.cfg, deliberately.
# VideoSettings.save() builds a fresh ConfigFile and writes it, which would drop
# every section it does not itself know about — so two autoloads sharing one
# file would silently erase each other. A separate file sidesteps that entirely
# instead of coupling the two save paths.
# Each action is bound at most once per device kind. That is a deliberate
# simplification of Godot's arbitrary-length event list: it makes a rebind row
# a single button rather than an editable list, and makes "what is X bound to?"
# answerable. The consequence is that applying a binding replaces the whole
# event list for that action (see apply()), so anything project.godot binds
# beyond one keyboard + one joypad event per action would be dropped here.
const DEVICE_KEYBOARD := "keyboard"
const DEVICE_JOYPAD := "joypad"
const SETTINGS_PATH := "user://input.cfg"
# The rebindable action list, and the only place the Controls tab and the tests
# read it from. Order is display order. Actions NOT listed here (ui_*, the F3/F4
# debug overlays) are deliberately not rebindable.
const ACTIONS := [
{"action": "move_forward", "label": "Thrust forward", "group": "Flight"},
{"action": "move_back", "label": "Thrust backward", "group": "Flight"},
{"action": "move_left", "label": "Strafe left", "group": "Flight"},
{"action": "move_right", "label": "Strafe right", "group": "Flight"},
{"action": "move_up", "label": "Thrust up", "group": "Flight"},
{"action": "move_down", "label": "Thrust down", "group": "Flight"},
{"action": "turbo", "label": "Turbo", "group": "Flight"},
{"action": "turn_left", "label": "Yaw left", "group": "Attitude"},
{"action": "turn_right", "label": "Yaw right", "group": "Attitude"},
{"action": "pitch_up", "label": "Pitch up", "group": "Attitude"},
{"action": "pitch_down", "label": "Pitch down", "group": "Attitude"},
{"action": "roll_left", "label": "Roll left", "group": "Attitude"},
{"action": "roll_right", "label": "Roll right", "group": "Attitude"},
{"action": "toggle_ball_cam", "label": "Ball camera", "group": "Other"},
{"action": "reset_ball", "label": "Reset ball (Free Play)", "group": "Other"},
]
# button_index -> label, using the Xbox names the default map is expressed in.
# InputEvent.as_text() renders these as "Joypad Button 9 (Left Shoulder)", which
# is both long and wrong-looking in a rebind row.
const JOY_BUTTON_NAMES := {
JOY_BUTTON_A: "A", JOY_BUTTON_B: "B", JOY_BUTTON_X: "X", JOY_BUTTON_Y: "Y",
JOY_BUTTON_BACK: "Back", JOY_BUTTON_GUIDE: "Guide", JOY_BUTTON_START: "Start",
JOY_BUTTON_LEFT_STICK: "L3", JOY_BUTTON_RIGHT_STICK: "R3",
JOY_BUTTON_LEFT_SHOULDER: "LB", JOY_BUTTON_RIGHT_SHOULDER: "RB",
JOY_BUTTON_DPAD_UP: "D-Pad Up", JOY_BUTTON_DPAD_DOWN: "D-Pad Down",
JOY_BUTTON_DPAD_LEFT: "D-Pad Left", JOY_BUTTON_DPAD_RIGHT: "D-Pad Right",
}
# axis -> [label at negative deflection, label at positive deflection]. The
# triggers rest at 0 and only travel positive, so their negative half is never
# a reachable binding and is labelled as such rather than as a direction.
const JOY_AXIS_NAMES := {
JOY_AXIS_LEFT_X: ["Left Stick Left", "Left Stick Right"],
JOY_AXIS_LEFT_Y: ["Left Stick Up", "Left Stick Down"],
JOY_AXIS_RIGHT_X: ["Right Stick Left", "Right Stick Right"],
JOY_AXIS_RIGHT_Y: ["Right Stick Up", "Right Stick Down"],
JOY_AXIS_TRIGGER_LEFT: ["LT", "LT"],
JOY_AXIS_TRIGGER_RIGHT: ["RT", "RT"],
}
signal bindings_changed
# Push the right stick forward and the nose goes down (flight-sim). Ticking this
# flips it. Applied in PlayerShipController rather than by rewriting the
# bindings, so it stays one preference instead of two swapped rows the player
# then has to reason about.
var invert_pitch: bool = false
# action -> {DEVICE_KEYBOARD: InputEvent|null, DEVICE_JOYPAD: InputEvent|null},
# snapshotted from InputMap at boot before any override lands.
var _defaults: Dictionary = {}
# Same shape, but only for actions the player has actually customised. A device
# key that is absent means "still using the default"; a device key present with
# null means "the player deliberately unbound it".
var _overrides: Dictionary = {}
func _ready() -> void:
_capture_defaults()
_load()
apply()
# Reads project.godot's [input] back out of InputMap. Anything that is neither a
# key nor a joypad button/motion event (mouse buttons, say) is ignored rather
# than mis-filed under a device kind it does not belong to.
func _capture_defaults() -> void:
_defaults.clear()
for entry in ACTIONS:
var action: String = entry["action"]
var slots := {DEVICE_KEYBOARD: null, DEVICE_JOYPAD: null}
if InputMap.has_action(action):
for event in InputMap.action_get_events(action):
var kind := device_kind_of(event)
if kind != "" and slots[kind] == null:
slots[kind] = event
_defaults[action] = slots
# "" for an event this system cannot express (mouse, gesture, MIDI), which is
# also the signal to callers that it is not a legal binding.
static func device_kind_of(event: InputEvent) -> String:
if event is InputEventKey:
return DEVICE_KEYBOARD
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
return DEVICE_JOYPAD
return ""
func _load() -> void:
_overrides.clear()
var cfg := ConfigFile.new()
if cfg.load(SETTINGS_PATH) != OK:
return
invert_pitch = cfg.get_value("input", "invert_pitch", invert_pitch)
for entry in ACTIONS:
var action: String = entry["action"]
for device in [DEVICE_KEYBOARD, DEVICE_JOYPAD]:
var key := "%s.%s" % [action, device]
if not cfg.has_section_key("bindings", key):
continue
var stored = cfg.get_value("bindings", key)
if not (stored is Dictionary):
continue
# An empty dict is the "deliberately unbound" sentinel — see save().
if stored.is_empty():
_set_override(action, device, null)
continue
var event := event_from_dict(stored)
if event != null:
_set_override(action, device, event)
func save() -> void:
var cfg := ConfigFile.new()
cfg.set_value("input", "invert_pitch", invert_pitch)
for action in _overrides:
var slots: Dictionary = _overrides[action]
for device in slots:
var event: InputEvent = slots[device]
var key := "%s.%s" % [action, device]
# An unbound override is written as an empty dict, NOT as null:
# ConfigFile.set_value() treats a null value as "erase this key", so
# storing null would drop the entry and the next load would fall
# back to the project default — silently rebinding something the
# player had deliberately cleared.
cfg.set_value("bindings", key, {} if event == null else event_to_dict(event))
cfg.save(SETTINGS_PATH)
# Rebuilds InputMap for every rebindable action from defaults + overrides. Runs
# wholesale rather than incrementally so there is exactly one code path that
# decides what an action is bound to, whatever route got us here.
func apply() -> void:
for entry in ACTIONS:
var action: String = entry["action"]
if not InputMap.has_action(action):
continue
InputMap.action_erase_events(action)
for device in [DEVICE_KEYBOARD, DEVICE_JOYPAD]:
var event := get_binding(action, device)
if event != null:
InputMap.action_add_event(action, event)
bindings_changed.emit()
func get_binding(action: String, device: String) -> InputEvent:
if _overrides.has(action) and _overrides[action].has(device):
return _overrides[action][device]
if _defaults.has(action):
return _defaults[action][device]
return null
func get_default_binding(action: String, device: String) -> InputEvent:
if not _defaults.has(action):
return null
return _defaults[action][device]
# Binds `event` to `action`, replacing whatever that action had for the event's
# own device kind. Returns the actions that were unbound to avoid a duplicate,
# so the caller can say so rather than leaving the player to discover it.
func set_binding(action: String, event: InputEvent) -> PackedStringArray:
var device := device_kind_of(event)
if device == "":
return PackedStringArray()
var displaced := find_conflicts(event, action)
for other in displaced:
_set_override(other, device, null)
_set_override(action, device, event)
apply()
return displaced
func clear_binding(action: String, device: String) -> void:
_set_override(action, device, null)
apply()
# Actions already bound to an equivalent event, excluding `except_action`.
# Compared by value rather than by object identity — the event coming out of a
# rebind capture is a different instance from the one in the map.
func find_conflicts(event: InputEvent, except_action: String = "") -> PackedStringArray:
var device := device_kind_of(event)
var out := PackedStringArray()
if device == "":
return out
for entry in ACTIONS:
var action: String = entry["action"]
if action == except_action:
continue
var bound := get_binding(action, device)
if bound != null and events_match(bound, event):
out.append(action)
return out
# Equality by the fields a binding is identified by. Deliberately not
# InputEvent.is_match(): for an axis that ignores axis_value, which would make
# "Right Stick Up" and "Right Stick Down" collide as the same binding.
static func events_match(a: InputEvent, b: InputEvent) -> bool:
if a is InputEventKey and b is InputEventKey:
return a.physical_keycode == b.physical_keycode
if a is InputEventJoypadButton and b is InputEventJoypadButton:
return a.button_index == b.button_index
if a is InputEventJoypadMotion and b is InputEventJoypadMotion:
return a.axis == b.axis and signf(a.axis_value) == signf(b.axis_value)
return false
func reset_action(action: String) -> void:
_overrides.erase(action)
apply()
func reset_all() -> void:
_overrides.clear()
invert_pitch = false
apply()
func has_override(action: String) -> bool:
return _overrides.has(action)
func pitch_sign() -> float:
return -1.0 if invert_pitch else 1.0
func _set_override(action: String, device: String, event: InputEvent) -> void:
if not _overrides.has(action):
_overrides[action] = {}
_overrides[action][device] = event
# ConfigFile stores Dictionary values natively, so bindings persist as plain
# data. Never the Object(...) literal Godot writes into project.godot — that
# form is only parsed by the engine's own project-file loader, and round-tripping
# it through user:// would be storing engine-internal syntax in a save file.
static func event_to_dict(event: InputEvent) -> Dictionary:
if event is InputEventKey:
return {"type": "key", "physical_keycode": int(event.physical_keycode)}
if event is InputEventJoypadButton:
return {"type": "joy_button", "button_index": int(event.button_index)}
if event is InputEventJoypadMotion:
return {"type": "joy_axis", "axis": int(event.axis), "value": float(signf(event.axis_value))}
return {}
# Returns null for anything unrecognised, so a save file from a newer build (or
# a hand-edited one) degrades to "this action is unbound" rather than crashing
# the game before the player can reach the Controls tab to fix it.
static func event_from_dict(data: Dictionary) -> InputEvent:
match data.get("type", ""):
"key":
var key := InputEventKey.new()
key.physical_keycode = int(data.get("physical_keycode", 0))
return key if key.physical_keycode != 0 else null
"joy_button":
var button := InputEventJoypadButton.new()
button.button_index = int(data.get("button_index", -1))
return button if button.button_index >= 0 else null
"joy_axis":
var motion := InputEventJoypadMotion.new()
motion.axis = int(data.get("axis", -1))
motion.axis_value = signf(float(data.get("value", 0.0)))
return motion if motion.axis >= 0 and motion.axis_value != 0.0 else null
return null
func event_to_text(event: InputEvent) -> String:
if event == null:
return "Unbound"
if event is InputEventKey:
# Physical keycodes throughout, so the label matches the key's position
# on a non-QWERTY layout the same way the binding itself does. The
# headless display server has no keyboard layout to consult and pushes
# an ERROR for the attempt — which the ENet smoke gate treats as a
# failure on sight — so fall back to the unmapped keycode there.
var keycode: int = event.physical_keycode
if DisplayServer.get_name() != "headless":
keycode = DisplayServer.keyboard_get_keycode_from_physical(keycode)
return OS.get_keycode_string(keycode)
if event is InputEventJoypadButton:
return JOY_BUTTON_NAMES.get(event.button_index, "Button %d" % event.button_index)
if event is InputEventJoypadMotion:
if JOY_AXIS_NAMES.has(event.axis):
return JOY_AXIS_NAMES[event.axis][0 if event.axis_value < 0.0 else 1]
return "Axis %d%s" % [event.axis, "-" if event.axis_value < 0.0 else "+"]
return event.as_text()
func binding_text(action: String, device: String) -> String:
return event_to_text(get_binding(action, device))
+1
View File
@@ -0,0 +1 @@
uid://bjtdsbem7kwdv
+22 -13
View File
@@ -10,30 +10,39 @@ var _action := ShipAction.new()
func get_action() -> ShipAction:
# Full overwrite per axis (not +=/-=): _action is reused across ticks, so
# fields must not depend on starting from a fresh Vector3.ZERO each call.
#
# Input.get_axis(negative, positive) is strength(positive) -
# strength(negative), so these keep the exact sign conventions the digital
# version had while becoming proportional on a controller:
# get_action_strength() returns a flat 1.0 for a held key but the
# normalised past-deadzone deflection for an InputEventJoypadMotion. A
# half-pulled trigger is therefore half thrust, and keyboard flight is
# unchanged down to the value.
# Forward/Backward thrust (main engines)
_action.thrust.z = (1.0 if Input.is_action_pressed("move_forward") else 0.0) \
- (1.0 if Input.is_action_pressed("move_back") else 0.0)
_action.thrust.z = Input.get_axis("move_back", "move_forward")
# Strafe thrusters (left/right)
_action.thrust.x = (1.0 if Input.is_action_pressed("move_right") else 0.0) \
- (1.0 if Input.is_action_pressed("move_left") else 0.0)
_action.thrust.x = Input.get_axis("move_left", "move_right")
# Vertical thrusters (up/down)
_action.thrust.y = (1.0 if Input.is_action_pressed("move_up") else 0.0) \
- (1.0 if Input.is_action_pressed("move_down") else 0.0)
_action.thrust.y = Input.get_axis("move_down", "move_up")
# Yaw (turn left/right around Y axis)
_action.rotation.y = (1.0 if Input.is_action_pressed("turn_left") else 0.0) \
- (1.0 if Input.is_action_pressed("turn_right") else 0.0)
_action.rotation.y = Input.get_axis("turn_right", "turn_left")
# Pitch (nose up/down around X axis)
_action.rotation.x = (1.0 if Input.is_action_pressed("pitch_down") else 0.0) \
- (1.0 if Input.is_action_pressed("pitch_up") else 0.0)
# Pitch (nose up/down around X axis). Positive rotation.x is nose-UP:
# torque about local +X rotates the ship's up vector toward its tail by the
# right-hand rule, which lifts the nose (measured, not assumed). The
# argument order here used to be reversed, so "pitch_down" pitched up and
# the I/K keys were each labelled as the opposite of what they did.
# The default binding then gives flight-sim polarity — right stick forward
# is pitch_down is nose down — and InputSettings holds the player's
# preference for flipping that.
_action.rotation.x = Input.get_axis("pitch_down", "pitch_up") * InputSettings.pitch_sign()
# Roll (bank left/right around Z axis)
_action.rotation.z = (1.0 if Input.is_action_pressed("roll_left") else 0.0) \
- (1.0 if Input.is_action_pressed("roll_right") else 0.0)
_action.rotation.z = Input.get_axis("roll_right", "roll_left")
_action.turbo = Input.is_action_pressed("turbo")
+9 -1
View File
@@ -1,6 +1,10 @@
extends Control
# Settings screen: player-facing video knobs on top of VideoSettings (the
# Settings screen root, owning the Video tab and the shared Back button. The
# Controls tab has its own script (controls_settings.gd) so this file stays
# video-only; both tabs' state is committed in _on_back_pressed below.
#
# Video tab: player-facing video knobs on top of VideoSettings (the
# autoload holding + persisting them). Preset/AA/vsync/fps-cap/resolution
# scale apply immediately since they're Viewport- or DisplayServer-wide;
# glow/brightness/shadow/SDFGI/SSIL/SSAO apply the next time an arena loads
@@ -205,6 +209,10 @@ func _mark_custom_if_user_driven() -> void:
func _on_back_pressed() -> void:
VideoSettings.save()
# Bindings are applied live as the player rebinds them (InputSettings.apply
# runs on every change) but are only committed to disk here, matching how
# the video knobs behave.
InputSettings.save()
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
+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)
+3 -1
View File
@@ -110,7 +110,9 @@ func _exit_tree() -> void:
func _input(event):
if event.is_action_pressed("ui_accept"): # Enter key
# A dedicated action rather than ui_accept, so the camera toggle is
# rebindable and A stays purely a menu-confirm button. Space / R3.
if event.is_action_pressed("toggle_ball_cam"):
ball_cam_enabled = !ball_cam_enabled
camera_mode_changed.emit(ball_cam_enabled)
+450
View File
@@ -0,0 +1,450 @@
extends "res://tests/test_case.gd"
# Guards the input map and the InputSettings remap layer.
#
# The defect this file exists for: project.godot bound joypad events to only 5
# of the 13 flight actions, so a controller could yaw/pitch/roll/turbo but could
# not translate at all. Nothing failed, because nothing asserted that a *pair*
# of bindings exists — the actions were all present and the game booted fine.
# test_every_action_has_both_a_keyboard_and_a_joypad_binding is that assertion,
# and it can tell "bound on both devices" from "bound on one", which is the
# distinction that was actually missing.
#
# Several of these tests write to the global InputMap through InputSettings, so
# each one must restore it before returning or it corrupts every later case in
# the run (the runner shares one process). reset_all() is the restore.
const CONTROLLER_ONLY_ACTIONS := ["toggle_ball_cam", "reset_ball"]
func _joypad_events(action: String) -> Array:
var out := []
for event in InputMap.action_get_events(action):
if InputSettings.device_kind_of(event) == InputSettings.DEVICE_JOYPAD:
out.append(event)
return out
func _keyboard_events(action: String) -> Array:
var out := []
for event in InputMap.action_get_events(action):
if InputSettings.device_kind_of(event) == InputSettings.DEVICE_KEYBOARD:
out.append(event)
return out
func test_every_rebindable_action_exists() -> void:
for entry in InputSettings.ACTIONS:
assert_true(InputMap.has_action(entry["action"]), "InputMap has action %s" % entry["action"])
func test_every_action_has_both_a_keyboard_and_a_joypad_binding() -> void:
# The regression itself: a controller player must be able to reach every
# action without touching the keyboard, and vice versa.
for entry in InputSettings.ACTIONS:
var action: String = entry["action"]
assert_true(not _keyboard_events(action).is_empty(), "%s has a keyboard binding" % action)
assert_true(not _joypad_events(action).is_empty(), "%s has a joypad binding" % action)
func test_apply_is_lossless_against_the_project_defaults() -> void:
# InputSettings stores one binding per device per action, so apply()
# rewrites each action's event list to exactly [keyboard, joypad]. If
# project.godot ever gains a second keyboard event for a rebindable action,
# booting the game would silently drop it — the action would still work, on
# fewer keys than the file says. Asserting apply() is a no-op over the
# defaults is what distinguishes "bindings intact" from "bindings quietly
# trimmed", which counting events cannot do.
InputSettings.reset_all()
var before := {}
for entry in InputSettings.ACTIONS:
before[entry["action"]] = InputMap.action_get_events(entry["action"]).size()
InputSettings.apply()
for entry in InputSettings.ACTIONS:
var action: String = entry["action"]
assert_eq(
InputMap.action_get_events(action).size(),
before[action],
"%s keeps every event across apply()" % action
)
assert_eq(before[action], 2, "%s has exactly one keyboard and one joypad event" % action)
func test_no_two_actions_share_a_joypad_binding() -> void:
# Two flight actions sharing one input is silently unplayable rather than an
# error, and it is easy to reintroduce: an early draft of this layout had A
# as both turbo and thrust-up, and B as both thrust-down and ui_cancel.
for entry in InputSettings.ACTIONS:
var action: String = entry["action"]
var bound := InputSettings.get_binding(action, InputSettings.DEVICE_JOYPAD)
assert_true(bound != null, "%s resolves a joypad binding" % action)
if bound == null:
continue
var conflicts := InputSettings.find_conflicts(bound, action)
assert_true(
conflicts.is_empty(),
"%s's joypad binding is unique (also on: %s)" % [action, ", ".join(conflicts)]
)
func test_no_two_actions_share_a_keyboard_binding() -> void:
for entry in InputSettings.ACTIONS:
var action: String = entry["action"]
var bound := InputSettings.get_binding(action, InputSettings.DEVICE_KEYBOARD)
assert_true(bound != null, "%s resolves a keyboard binding" % action)
if bound == null:
continue
var conflicts := InputSettings.find_conflicts(bound, action)
assert_true(
conflicts.is_empty(),
"%s's keyboard binding is unique (also on: %s)" % [action, ", ".join(conflicts)]
)
func _joypad_buttons(action: String) -> Array:
var out := []
for event in InputMap.action_get_events(action):
if event is InputEventJoypadButton:
out.append(event.button_index)
return out
func test_menus_are_usable_with_a_controller() -> void:
# Godot 4.7 ships ui_up/down/left/right with D-pad and stick events but
# gives ui_accept and ui_cancel NO joypad binding at all (verified against a
# pristine project). A controller could therefore move the highlight around
# the main menu and never press anything — the menu looked responsive, which
# is exactly why it went unnoticed. project.godot binds them explicitly.
assert_true(JOY_BUTTON_A in _joypad_buttons("ui_accept"), "A confirms in menus")
assert_true(JOY_BUTTON_B in _joypad_buttons("ui_cancel"), "B goes back in menus")
# Navigation is the engine default, but assert it so a future override of
# these actions cannot silently strand a controller player again.
for action in ["ui_up", "ui_down", "ui_left", "ui_right"]:
var pad := 0
for event in InputMap.action_get_events(action):
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
pad += 1
assert_true(pad > 0, "%s is reachable on a controller" % action)
func test_leaving_gameplay_is_not_on_a_face_button() -> void:
# game_mode.gd exits to the main menu on leave_gameplay, deliberately NOT on
# ui_cancel: ui_cancel carries B so menus behave conventionally, and B is far
# too easy to hit by accident to also mean "abandon this match". The two
# actions being distinct is the whole point, so assert they really differ.
assert_true(InputMap.has_action("leave_gameplay"), "leave_gameplay exists")
var buttons := _joypad_buttons("leave_gameplay")
assert_true(JOY_BUTTON_START in buttons, "Start leaves gameplay")
for face in [JOY_BUTTON_A, JOY_BUTTON_B, JOY_BUTTON_X, JOY_BUTTON_Y]:
assert_true(face not in buttons, "leave_gameplay must not use a face button")
func test_ball_cam_has_its_own_action_off_ui_accept() -> void:
# Ball-cam used to ride ui_accept, which project.godot now binds to A for
# menu confirmation. A dedicated action keeps A purely a menu button and
# lets the camera toggle be rebound like anything else.
assert_true(InputMap.has_action("toggle_ball_cam"), "toggle_ball_cam exists")
var joypad := _joypad_events("toggle_ball_cam")
assert_true(joypad.size() == 1, "toggle_ball_cam has one joypad binding")
if joypad.size() == 1:
assert_true(
joypad[0] is InputEventJoypadButton and joypad[0].button_index != JOY_BUTTON_A,
"toggle_ball_cam is not on A (menu confirm)"
)
func test_thrust_is_on_the_triggers() -> void:
# The requested layout, asserted where it is load-bearing: triggers are the
# only analog inputs on the thrust axis, so binding them to buttons instead
# would silently cost proportional throttle without failing anything.
var forward := _joypad_events("move_forward")
var back := _joypad_events("move_back")
assert_true(forward.size() == 1 and forward[0] is InputEventJoypadMotion, "move_forward is an axis")
assert_true(back.size() == 1 and back[0] is InputEventJoypadMotion, "move_back is an axis")
if forward.size() == 1 and forward[0] is InputEventJoypadMotion:
assert_eq(forward[0].axis, JOY_AXIS_TRIGGER_RIGHT, "move_forward axis")
if back.size() == 1 and back[0] is InputEventJoypadMotion:
assert_eq(back[0].axis, JOY_AXIS_TRIGGER_LEFT, "move_back axis")
func test_pitch_is_on_the_left_stick_nose_down_when_pushed_forward() -> void:
var down := _joypad_events("pitch_down")
var up := _joypad_events("pitch_up")
assert_true(down.size() == 1 and down[0] is InputEventJoypadMotion, "pitch_down is an axis")
assert_true(up.size() == 1 and up[0] is InputEventJoypadMotion, "pitch_up is an axis")
if down.size() == 1 and down[0] is InputEventJoypadMotion:
assert_eq(down[0].axis, JOY_AXIS_LEFT_Y, "pitch_down axis")
# Godot reports a stick pushed away from the player as negative Y.
assert_true(down[0].axis_value < 0.0, "stick forward pitches the nose down")
if up.size() == 1 and up[0] is InputEventJoypadMotion:
assert_eq(up[0].axis, JOY_AXIS_LEFT_Y, "pitch_up axis")
assert_true(up[0].axis_value > 0.0, "stick back pitches the nose up")
func test_all_rotation_lives_on_the_left_stick() -> void:
# Yaw and pitch belong on the same stick. Splitting them across two sticks
# (yaw left, pitch right) is playable in the sense that every input works,
# so nothing here failed when it was wrong — it just felt broken, because
# each stick had a dead axis. Asserting both are on the right stick is what
# pins the 6DOF convention down.
for action in ["turn_left", "turn_right"]:
var events := _joypad_events(action)
assert_true(events.size() == 1 and events[0] is InputEventJoypadMotion, "%s is an axis" % action)
if events.size() == 1 and events[0] is InputEventJoypadMotion:
assert_eq(events[0].axis, JOY_AXIS_LEFT_X, "%s axis" % action)
for action in ["pitch_up", "pitch_down"]:
var events := _joypad_events(action)
if events.size() == 1 and events[0] is InputEventJoypadMotion:
assert_eq(events[0].axis, JOY_AXIS_LEFT_Y, "%s axis" % action)
func test_translation_is_analog_on_the_right_stick_and_triggers() -> void:
# Six degrees of freedom onto the pad's six analog axes. Strafe and
# vertical were digital buttons at first, which cost proportional control
# without failing anything — a button binding here still "works", it just
# gives full power or nothing, so only checking the event type catches it.
var expected := {
"move_left": JOY_AXIS_RIGHT_X, "move_right": JOY_AXIS_RIGHT_X,
"move_up": JOY_AXIS_RIGHT_Y, "move_down": JOY_AXIS_RIGHT_Y,
"move_forward": JOY_AXIS_TRIGGER_RIGHT, "move_back": JOY_AXIS_TRIGGER_LEFT,
}
for action in expected:
var events := _joypad_events(action)
assert_true(
events.size() == 1 and events[0] is InputEventJoypadMotion,
"%s is analog, not a button" % action
)
if events.size() == 1 and events[0] is InputEventJoypadMotion:
assert_eq(events[0].axis, expected[action], "%s axis" % action)
func test_pushing_the_right_stick_up_thrusts_up() -> void:
# Godot reports a stick pushed away from the player as negative Y, so the
# intuitive direction needs the negative half — easy to get backwards, and
# inverted vertical thrust is not something any other assertion notices.
var up := _joypad_events("move_up")
var down := _joypad_events("move_down")
if up.size() == 1 and up[0] is InputEventJoypadMotion:
assert_true(up[0].axis_value < 0.0, "stick up thrusts up")
if down.size() == 1 and down[0] is InputEventJoypadMotion:
assert_true(down[0].axis_value > 0.0, "stick down thrusts down")
func test_roll_is_on_the_shoulder_buttons() -> void:
var expected := {"roll_left": JOY_BUTTON_LEFT_SHOULDER, "roll_right": JOY_BUTTON_RIGHT_SHOULDER}
for action in expected:
var events := _joypad_events(action)
assert_true(events.size() == 1 and events[0] is InputEventJoypadButton, "%s is a button" % action)
if events.size() == 1 and events[0] is InputEventJoypadButton:
assert_eq(events[0].button_index, expected[action], "%s button" % action)
func test_the_face_buttons_are_free_for_menus() -> void:
# A/B/X/Y carry no flight action, which is what lets ui_accept keep A and
# keeps a stray face-button press from doing something during a match.
for entry in InputSettings.ACTIONS:
var bound := InputSettings.get_binding(entry["action"], InputSettings.DEVICE_JOYPAD)
if bound is InputEventJoypadButton:
assert_true(
bound.button_index not in [JOY_BUTTON_A, JOY_BUTTON_B, JOY_BUTTON_X, JOY_BUTTON_Y],
"%s must not use a face button" % entry["action"]
)
func test_event_dict_round_trip_preserves_every_default() -> void:
for entry in InputSettings.ACTIONS:
for device in [InputSettings.DEVICE_KEYBOARD, InputSettings.DEVICE_JOYPAD]:
var original := InputSettings.get_default_binding(entry["action"], device)
assert_true(original != null, "%s/%s has a default" % [entry["action"], device])
if original == null:
continue
var restored := InputSettings.event_from_dict(InputSettings.event_to_dict(original))
assert_true(restored != null, "%s/%s round-trips to an event" % [entry["action"], device])
if restored != null:
assert_true(
InputSettings.events_match(original, restored),
"%s/%s round-trips to an equal event" % [entry["action"], device]
)
func test_event_from_dict_rejects_junk() -> void:
# A save file from a newer build, or a hand-edited one, must degrade to
# "unbound" rather than taking the game down before the player can reach
# the Controls tab to fix it.
assert_true(InputSettings.event_from_dict({}) == null, "empty dict is not an event")
assert_true(InputSettings.event_from_dict({"type": "mouse"}) == null, "unknown type is not an event")
assert_true(InputSettings.event_from_dict({"type": "key"}) == null, "keycode-less key is not an event")
assert_true(
InputSettings.event_from_dict({"type": "joy_axis", "axis": 3, "value": 0.0}) == null,
"a centred axis is not an event"
)
func test_axis_bindings_are_distinguished_by_direction() -> void:
# events_match must NOT collapse the two halves of one axis, or binding
# pitch-up would silently unbind pitch-down as a "conflict".
var up := InputEventJoypadMotion.new()
up.axis = JOY_AXIS_RIGHT_Y
up.axis_value = 1.0
var down := InputEventJoypadMotion.new()
down.axis = JOY_AXIS_RIGHT_Y
down.axis_value = -1.0
assert_true(not InputSettings.events_match(up, down), "opposite axis halves are different bindings")
assert_true(InputSettings.events_match(up, up), "an axis binding matches itself")
func test_set_binding_changes_the_live_input_map() -> void:
var rebound := InputEventKey.new()
rebound.physical_keycode = KEY_F # not used by any default binding
InputSettings.set_binding("move_forward", rebound)
var found := false
for event in InputMap.action_get_events("move_forward"):
if event is InputEventKey and event.physical_keycode == KEY_F:
found = true
assert_true(found, "the rebound key reaches InputMap")
assert_true(InputSettings.has_override("move_forward"), "the rebind is recorded as an override")
# The joypad half must survive a keyboard-only rebind.
assert_true(not _joypad_events("move_forward").is_empty(), "rebinding the key keeps the trigger")
InputSettings.reset_all()
func test_set_binding_displaces_the_conflicting_action() -> void:
# Binding X to an input already in use must report and clear the previous
# owner, not leave both bound and let the player wonder why two things fire.
var shared := InputSettings.get_binding("move_left", InputSettings.DEVICE_KEYBOARD)
assert_true(shared != null, "move_left has a keyboard binding to steal")
if shared == null:
return
var displaced := InputSettings.set_binding("move_right", shared)
assert_true(displaced.has("move_left"), "the displaced action is reported")
assert_true(
InputSettings.get_binding("move_left", InputSettings.DEVICE_KEYBOARD) == null,
"the displaced action is actually unbound"
)
InputSettings.reset_all()
func test_reset_all_restores_the_project_defaults() -> void:
var before := InputSettings.binding_text("move_up", InputSettings.DEVICE_JOYPAD)
var rebound := InputEventJoypadButton.new()
rebound.button_index = JOY_BUTTON_BACK
InputSettings.set_binding("move_up", rebound)
assert_true(
InputSettings.binding_text("move_up", InputSettings.DEVICE_JOYPAD) != before,
"the rebind took effect"
)
InputSettings.reset_all()
assert_eq(
InputSettings.binding_text("move_up", InputSettings.DEVICE_JOYPAD),
before,
"reset_all restores the default binding"
)
assert_true(not InputSettings.has_override("move_up"), "reset_all clears the override")
func test_bindings_survive_a_save_and_reload() -> void:
# The end-to-end persistence path, which nothing else covers: a rebind that
# does not survive a restart is the single most visible way this feature can
# fail, and it fails silently — the game runs fine, just on the defaults.
#
# This writes the real user://input.cfg, so the player's own file is saved
# and put back. Restoring it is not optional: the test suite shares a
# user:// directory with the game.
var had_file := FileAccess.file_exists(InputSettings.SETTINGS_PATH)
var original := ""
if had_file:
original = FileAccess.get_file_as_string(InputSettings.SETTINGS_PATH)
var rebound := InputEventJoypadButton.new()
rebound.button_index = JOY_BUTTON_BACK
InputSettings.set_binding("turbo", rebound)
InputSettings.invert_pitch = true
InputSettings.save()
# Drop the in-memory state the way a fresh launch would, then reload.
InputSettings.reset_all()
assert_true(not InputSettings.has_override("turbo"), "state cleared before reload")
InputSettings._load()
InputSettings.apply()
assert_true(InputSettings.has_override("turbo"), "the override came back from disk")
var loaded := InputSettings.get_binding("turbo", InputSettings.DEVICE_JOYPAD)
assert_true(loaded != null, "the reloaded binding is an event")
if loaded != null:
assert_true(InputSettings.events_match(loaded, rebound), "the reloaded binding matches what was saved")
assert_true(InputSettings.invert_pitch, "invert_pitch survives a reload")
# A deliberately-cleared binding must stay cleared across a restart. This is
# the case that distinguishes a real "unbound" record from an absent one:
# ConfigFile.set_value() erases a key whose value is null, so a naive
# implementation silently restores the default here instead.
InputSettings.clear_binding("roll_left", InputSettings.DEVICE_JOYPAD)
InputSettings.save()
InputSettings.reset_all()
InputSettings._load()
InputSettings.apply()
assert_true(
InputSettings.get_binding("roll_left", InputSettings.DEVICE_JOYPAD) == null,
"an unbound action stays unbound across a reload"
)
assert_true(
_joypad_events("roll_left").is_empty(),
"the unbound action has no joypad event in InputMap after a reload"
)
# And it must actually be live in InputMap, not merely remembered.
var live := false
for event in InputMap.action_get_events("turbo"):
if event is InputEventJoypadButton and event.button_index == JOY_BUTTON_BACK:
live = true
assert_true(live, "the reloaded binding is applied to InputMap")
InputSettings.reset_all()
if had_file:
var restore := FileAccess.open(InputSettings.SETTINGS_PATH, FileAccess.WRITE)
if restore != null:
restore.store_string(original)
restore.close()
InputSettings._load()
InputSettings.apply()
else:
DirAccess.remove_absolute(ProjectSettings.globalize_path(InputSettings.SETTINGS_PATH))
func test_invert_pitch_drives_pitch_sign() -> void:
var restore := InputSettings.invert_pitch
InputSettings.invert_pitch = false
assert_eq(InputSettings.pitch_sign(), 1.0, "default pitch sign")
InputSettings.invert_pitch = true
assert_eq(InputSettings.pitch_sign(), -1.0, "inverted pitch sign")
InputSettings.invert_pitch = restore
func test_every_default_binding_has_readable_text() -> void:
# A rebind row showing "" or "Joypad Button 9 (Left Shoulder)" is a UI bug
# that no other assertion here would catch.
for entry in InputSettings.ACTIONS:
for device in [InputSettings.DEVICE_KEYBOARD, InputSettings.DEVICE_JOYPAD]:
var text := InputSettings.binding_text(entry["action"], device)
assert_true(
text != "" and text != "Unbound",
"%s/%s has a readable label (got %s)" % [entry["action"], device, text]
)
func test_device_kind_of_rejects_events_it_cannot_bind() -> void:
assert_eq(InputSettings.device_kind_of(InputEventMouseButton.new()), "", "mouse is not a bindable device")
assert_eq(InputSettings.device_kind_of(InputEventKey.new()), InputSettings.DEVICE_KEYBOARD, "key device")
assert_eq(
InputSettings.device_kind_of(InputEventJoypadMotion.new()),
InputSettings.DEVICE_JOYPAD,
"joypad motion device"
)
@@ -0,0 +1 @@
uid://bwbkb6wa46yh1
@@ -0,0 +1,177 @@
extends "res://tests/test_case.gd"
# Covers PlayerShipController's translation of input actions into a ShipAction.
#
# The point of most of these is the *analog* path. The controller used to read
# is_action_pressed(), which is a bool, so a half-pulled trigger and a fully
# pulled one produced identical full thrust. A test that only ever pressed
# actions at full strength could not tell the two implementations apart — so
# these press at fractional strength, which only the get_action_strength()
# version can reproduce.
#
# Input.action_press writes to the global input state, so every test must
# release what it pressed before returning or it leaks into later cases.
const ACTIONS_USED := [
"move_forward", "move_back", "move_left", "move_right", "move_up", "move_down",
"turn_left", "turn_right", "pitch_up", "pitch_down", "roll_left", "roll_right",
"turbo",
]
func _controller() -> PlayerShipController:
return PlayerShipController.new()
func _release_all() -> void:
for action in ACTIONS_USED:
Input.action_release(action)
func test_full_strength_matches_the_historical_digital_values() -> void:
# The keyboard path must be unchanged by the move to analog: a held key
# reports strength 1.0, so every axis lands on exactly ±1.
var controller := _controller()
Input.action_press("move_forward", 1.0)
Input.action_press("move_right", 1.0)
Input.action_press("move_up", 1.0)
var action := controller.get_action()
assert_almost_eq(action.thrust.z, 1.0, 0.001, "forward thrust")
assert_almost_eq(action.thrust.x, 1.0, 0.001, "right thrust")
assert_almost_eq(action.thrust.y, 1.0, 0.001, "up thrust")
_release_all()
Input.action_press("move_back", 1.0)
Input.action_press("move_left", 1.0)
Input.action_press("move_down", 1.0)
action = controller.get_action()
assert_almost_eq(action.thrust.z, -1.0, 0.001, "backward thrust")
assert_almost_eq(action.thrust.x, -1.0, 0.001, "left thrust")
assert_almost_eq(action.thrust.y, -1.0, 0.001, "down thrust")
_release_all()
func test_rotation_sign_conventions_are_unchanged() -> void:
# Each action must move the ship the way its NAME says. The physics
# directions were measured by driving a real Ship through ship.tscn rather
# than reasoned about, because the right-hand rule is exactly the kind of
# thing that reads as obvious and comes out backwards:
#
# rotation.x > 0 -> nose UP (torque about local +X)
# rotation.y > 0 -> nose LEFT (torque about local +Y)
# rotation.z > 0 -> banks LEFT (torque about local +Z)
#
# pitch was inverted against this for a long time — get_axis's arguments
# were the wrong way round, so "pitch_down" raised the nose and the I/K keys
# each did the opposite of their label. Nothing caught it because the sign
# was self-consistent everywhere it was used; only comparing against the
# physics reveals it.
var controller := _controller()
var restore := InputSettings.invert_pitch
InputSettings.invert_pitch = false
Input.action_press("turn_left", 1.0)
Input.action_press("pitch_up", 1.0)
Input.action_press("roll_left", 1.0)
var action := controller.get_action()
assert_almost_eq(action.rotation.y, 1.0, 0.001, "yaw left is positive")
assert_almost_eq(action.rotation.x, 1.0, 0.001, "pitch UP is positive (nose up)")
assert_almost_eq(action.rotation.z, 1.0, 0.001, "roll left is positive")
_release_all()
Input.action_press("turn_right", 1.0)
Input.action_press("pitch_down", 1.0)
Input.action_press("roll_right", 1.0)
action = controller.get_action()
assert_almost_eq(action.rotation.y, -1.0, 0.001, "yaw right is negative")
assert_almost_eq(action.rotation.x, -1.0, 0.001, "pitch DOWN is negative (nose down)")
assert_almost_eq(action.rotation.z, -1.0, 0.001, "roll right is negative")
_release_all()
InputSettings.invert_pitch = restore
func test_partial_strength_produces_partial_thrust() -> void:
# The analog assertion. A digital is_action_pressed() implementation would
# return 1.0 here and fail.
var controller := _controller()
Input.action_press("move_forward", 0.5)
assert_almost_eq(controller.get_action().thrust.z, 0.5, 0.001, "half trigger is half thrust")
_release_all()
Input.action_press("move_up", 0.25)
assert_almost_eq(controller.get_action().thrust.y, 0.25, 0.001, "quarter deflection is quarter thrust")
_release_all()
Input.action_press("turn_left", 0.3)
assert_almost_eq(controller.get_action().rotation.y, 0.3, 0.001, "partial stick is partial yaw")
_release_all()
func test_opposing_inputs_subtract_rather_than_saturate() -> void:
# Both halves of one stick axis can report a strength at once; the result
# must be their difference, not whichever was read last.
var controller := _controller()
Input.action_press("move_forward", 0.75)
Input.action_press("move_back", 0.25)
assert_almost_eq(controller.get_action().thrust.z, 0.5, 0.001, "opposed thrust subtracts")
_release_all()
Input.action_press("move_forward", 0.4)
Input.action_press("move_back", 0.4)
assert_almost_eq(controller.get_action().thrust.z, 0.0, 0.001, "equal opposed thrust cancels")
_release_all()
func test_no_input_is_a_zero_action() -> void:
var controller := _controller()
_release_all()
var action := controller.get_action()
assert_eq(action.thrust, Vector3.ZERO, "idle thrust")
assert_eq(action.rotation, Vector3.ZERO, "idle rotation")
assert_true(not action.turbo, "idle turbo")
func test_invert_pitch_flips_only_the_pitch_axis() -> void:
var controller := _controller()
var restore := InputSettings.invert_pitch
Input.action_press("pitch_down", 1.0)
Input.action_press("turn_left", 1.0)
InputSettings.invert_pitch = false
var normal := controller.get_action().copy()
InputSettings.invert_pitch = true
var inverted := controller.get_action().copy()
assert_almost_eq(inverted.rotation.x, -normal.rotation.x, 0.001, "invert flips pitch")
assert_almost_eq(inverted.rotation.y, normal.rotation.y, 0.001, "invert leaves yaw alone")
InputSettings.invert_pitch = restore
_release_all()
func test_turbo_is_a_boolean() -> void:
var controller := _controller()
Input.action_press("turbo", 1.0)
assert_true(controller.get_action().turbo, "turbo held")
Input.action_release("turbo")
assert_true(not controller.get_action().turbo, "turbo released")
_release_all()
func test_the_returned_action_is_reused_between_ticks() -> void:
# get_action() documents that it returns a reused instance and overwrites
# every axis. Callers that keep an action past its tick must copy() it —
# local_input_timeline.gd and the prediction ring rely on that contract, so
# assert both halves of it.
var controller := _controller()
Input.action_press("move_forward", 1.0)
var first := controller.get_action()
_release_all()
var second := controller.get_action()
assert_true(first == second, "the same ShipAction instance is returned each tick")
assert_almost_eq(second.thrust.z, 0.0, 0.001, "releasing clears the axis rather than leaving it stale")
@@ -0,0 +1 @@
uid://xpr311fjhw2m
+7 -2
View File
@@ -71,7 +71,7 @@ func test_physics_engine_is_jolt() -> void:
func test_required_autoloads_are_registered() -> void:
# NetworkManager in particular is reached by name from many scripts; losing
# it from [autoload] fails only at the point of use, deep in a smoke test.
for autoload_name in ["GameSettings", "ControlPlaneClient", "VideoSettings", "NetworkManager", "MatchNet", "MatchSim"]:
for autoload_name in ["GameSettings", "ControlPlaneClient", "VideoSettings", "InputSettings", "NetworkManager", "MatchNet", "MatchSim"]:
assert_true(
ProjectSettings.has_setting("autoload/" + autoload_name),
"autoload/%s registered" % autoload_name
@@ -89,7 +89,12 @@ func test_test_hook_autoloads_are_not_shipped() -> void:
# when running those scene-level smoke tests, and must be removed again —
# see CLAUDE.md. Shipping one registered would run test code in the real
# game, so fail here rather than discovering it in a build.
for hook_name in ["MainMenuTestHooks", "LobbyTestHooks", "NetworkedMatchTestHooks"]:
# McpInteractionServer is registered automatically by the vendored godot-mcp
# tooling whenever it launches the project, and is left behind in
# project.godot afterwards. It is a debug channel into a running game, so
# shipping it registered is worse than a stray test hook, and it arrives
# without anyone having typed it.
for hook_name in ["MainMenuTestHooks", "LobbyTestHooks", "NetworkedMatchTestHooks", "McpInteractionServer"]:
assert_true(
not ProjectSettings.has_setting("autoload/" + hook_name),
"test hook autoload/%s must not be registered" % hook_name