From 076d27a564f871605506bb29ab824133a272bc07 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Sun, 6 Sep 2026 20:41:20 +0100 Subject: [PATCH] feat(input): full controller support, rebindable controls, and rotation fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- FLIGHT_MANUAL.md | 64 ++- Game/project.godot | 40 +- Game/scenes/settings.tscn | 174 +++++-- Game/scripts/controls_settings.gd | 188 ++++++++ Game/scripts/controls_settings.gd.uid | 1 + Game/scripts/game_mode.gd | 6 +- Game/scripts/input_settings.gd | 329 +++++++++++++ Game/scripts/input_settings.gd.uid | 1 + Game/scripts/player_ship_controller.gd | 35 +- Game/scripts/settings_menu.gd | 10 +- Game/scripts/ship.gd | 27 +- Game/scripts/ship_camera.gd | 4 +- Game/tests/cases/test_input_settings.gd | 450 ++++++++++++++++++ Game/tests/cases/test_input_settings.gd.uid | 1 + .../cases/test_player_ship_controller.gd | 177 +++++++ .../cases/test_player_ship_controller.gd.uid | 1 + Game/tests/cases/test_project_settings.gd | 9 +- 17 files changed, 1430 insertions(+), 87 deletions(-) create mode 100644 Game/scripts/controls_settings.gd create mode 100644 Game/scripts/controls_settings.gd.uid create mode 100644 Game/scripts/input_settings.gd create mode 100644 Game/scripts/input_settings.gd.uid create mode 100644 Game/tests/cases/test_input_settings.gd create mode 100644 Game/tests/cases/test_input_settings.gd.uid create mode 100644 Game/tests/cases/test_player_ship_controller.gd create mode 100644 Game/tests/cases/test_player_ship_controller.gd.uid diff --git a/FLIGHT_MANUAL.md b/FLIGHT_MANUAL.md index 172c7e66..7c5c8fb4 100644 --- a/FLIGHT_MANUAL.md +++ b/FLIGHT_MANUAL.md @@ -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 diff --git a/Game/project.godot b/Game/project.godot index f06d3780..e1dcb97e 100644 --- a/Game/project.godot +++ b/Game/project.godot @@ -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) diff --git a/Game/scenes/settings.tscn b/Game/scenes/settings.tscn index 1fccb217..5d5e94d3 100644 --- a/Game/scenes/settings.tscn +++ b/Game/scenes/settings.tscn @@ -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"] diff --git a/Game/scripts/controls_settings.gd b/Game/scripts/controls_settings.gd new file mode 100644 index 00000000..40c8efe0 --- /dev/null +++ b/Game/scripts/controls_settings.gd @@ -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." diff --git a/Game/scripts/controls_settings.gd.uid b/Game/scripts/controls_settings.gd.uid new file mode 100644 index 00000000..aed0593b --- /dev/null +++ b/Game/scripts/controls_settings.gd.uid @@ -0,0 +1 @@ +uid://dk7plirjfqvld diff --git a/Game/scripts/game_mode.gd b/Game/scripts/game_mode.gd index 4629486c..a59a253e 100644 --- a/Game/scripts/game_mode.gd +++ b/Game/scripts/game_mode.gd @@ -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) diff --git a/Game/scripts/input_settings.gd b/Game/scripts/input_settings.gd new file mode 100644 index 00000000..5898a252 --- /dev/null +++ b/Game/scripts/input_settings.gd @@ -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)) diff --git a/Game/scripts/input_settings.gd.uid b/Game/scripts/input_settings.gd.uid new file mode 100644 index 00000000..c18f5414 --- /dev/null +++ b/Game/scripts/input_settings.gd.uid @@ -0,0 +1 @@ +uid://bjtdsbem7kwdv diff --git a/Game/scripts/player_ship_controller.gd b/Game/scripts/player_ship_controller.gd index 4a3c45ca..3f45eaa9 100644 --- a/Game/scripts/player_ship_controller.gd +++ b/Game/scripts/player_ship_controller.gd @@ -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") diff --git a/Game/scripts/settings_menu.gd b/Game/scripts/settings_menu.gd index 2c9c0a01..c2f9b90b 100644 --- a/Game/scripts/settings_menu.gd +++ b/Game/scripts/settings_menu.gd @@ -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) diff --git a/Game/scripts/ship.gd b/Game/scripts/ship.gd index a5326483..1db1751b 100644 --- a/Game/scripts/ship.gd +++ b/Game/scripts/ship.gd @@ -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) diff --git a/Game/scripts/ship_camera.gd b/Game/scripts/ship_camera.gd index e5cd610b..883def7f 100644 --- a/Game/scripts/ship_camera.gd +++ b/Game/scripts/ship_camera.gd @@ -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) diff --git a/Game/tests/cases/test_input_settings.gd b/Game/tests/cases/test_input_settings.gd new file mode 100644 index 00000000..9e8f250f --- /dev/null +++ b/Game/tests/cases/test_input_settings.gd @@ -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" + ) diff --git a/Game/tests/cases/test_input_settings.gd.uid b/Game/tests/cases/test_input_settings.gd.uid new file mode 100644 index 00000000..a321852d --- /dev/null +++ b/Game/tests/cases/test_input_settings.gd.uid @@ -0,0 +1 @@ +uid://bwbkb6wa46yh1 diff --git a/Game/tests/cases/test_player_ship_controller.gd b/Game/tests/cases/test_player_ship_controller.gd new file mode 100644 index 00000000..187b6d0f --- /dev/null +++ b/Game/tests/cases/test_player_ship_controller.gd @@ -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") diff --git a/Game/tests/cases/test_player_ship_controller.gd.uid b/Game/tests/cases/test_player_ship_controller.gd.uid new file mode 100644 index 00000000..bf4a3dce --- /dev/null +++ b/Game/tests/cases/test_player_ship_controller.gd.uid @@ -0,0 +1 @@ +uid://xpr311fjhw2m diff --git a/Game/tests/cases/test_project_settings.gd b/Game/tests/cases/test_project_settings.gd index ac924f11..345dea55 100644 --- a/Game/tests/cases/test_project_settings.gd +++ b/Game/tests/cases/test_project_settings.gd @@ -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