mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
Compare commits
2 Commits
00b900d864
...
c02aad66a0
| Author | SHA1 | Date | |
|---|---|---|---|
| c02aad66a0 | |||
| 076d27a564 |
+52
-12
@@ -27,18 +27,58 @@ Welcome to space, pilot! This guide will teach you everything you need to know a
|
||||
|
||||
### Controller Controls
|
||||
|
||||
#### Translation
|
||||
Button names are the Xbox layout; a PlayStation pad maps the same physical
|
||||
positions (A = ✕, B = ○, X = □, Y = △).
|
||||
|
||||
- Left Stick - Strafe (Left/Right) + Thrust (Forward/Back)
|
||||
- Right Trigger - Forward Thrust
|
||||
- Left Trigger - Reverse Thrust
|
||||
- Face Buttons - Up/Down Thrust
|
||||
**The left stick points the nose, the right stick moves the hull.** Your ship has
|
||||
six degrees of freedom and a pad has exactly six analog axes, so every one gets a
|
||||
real axis rather than an on/off button.
|
||||
|
||||
#### Rotation
|
||||
#### Rotation — left stick and shoulders
|
||||
|
||||
- Right Stick - Pitch/Yaw
|
||||
- Shoulder Buttons - Roll
|
||||
- `A Button` - Turbo Boost
|
||||
- Left Stick (left/right) - Yaw
|
||||
- Left Stick (up/down) - Pitch. Flight-sim polarity by default: **push the
|
||||
stick forward and the nose goes down.** Flip it with "Invert pitch" in
|
||||
Settings → Controls.
|
||||
- `LB` - Roll Left (Bank Left)
|
||||
- `RB` - Roll Right (Bank Right)
|
||||
|
||||
#### Translation — right stick and triggers
|
||||
|
||||
- `RT` - Forward Thrust (Main Engines)
|
||||
- `LT` - Reverse Thrust (Retro Engines)
|
||||
- Right Stick (left/right) - Strafe (Port/Starboard Thrusters)
|
||||
- Right Stick (up/down) - Thrust Up/Down (Dorsal/Ventral Thrusters)
|
||||
- `L3` (click the left stick) - Turbo Boost
|
||||
|
||||
`X` and `Y` are deliberately unused in flight, and `A`/`B` are menu-only, so a
|
||||
reflexive face-button press never does anything mid-match.
|
||||
|
||||
#### Menus
|
||||
|
||||
- D-Pad or Left Stick - move the highlight
|
||||
- `A` - select
|
||||
- `B` - back
|
||||
- `Start` - leave a match in progress (deliberately not `B`, which is too easy
|
||||
to press by accident mid-game)
|
||||
|
||||
#### Other
|
||||
|
||||
- `R3` (click the right stick) - Toggle ball camera
|
||||
- `D-Pad Up` - Reset the ball (Free Play only)
|
||||
|
||||
The triggers and sticks are **analog**: a half-pulled trigger gives half thrust,
|
||||
and a gentle stick lean gives a gentle turn. Keyboard keys are all-or-nothing,
|
||||
which is the main reason a pad is easier to fly precisely.
|
||||
|
||||
### Rebinding
|
||||
|
||||
Every control above — keyboard and controller alike — can be remapped in
|
||||
**Settings → Controls**. Pick the device with the Keyboard/Controller toggle,
|
||||
click the binding you want to change, and press the key or button to assign.
|
||||
Binding an input that is already in use unbinds it from the action that had it,
|
||||
and the screen tells you which. "Reset all bindings to defaults" restores this
|
||||
table.
|
||||
|
||||
## 🛸 Basic Flight Principles
|
||||
|
||||
@@ -69,7 +109,7 @@ Your ship is a **realistic space vehicle** with the following characteristics:
|
||||
|
||||
#### Camera Control
|
||||
|
||||
- **Ball Cam**: Press `Enter` to toggle ball tracking camera
|
||||
- **Ball Cam**: Press `Space` (or `R3` on a controller) to toggle ball tracking camera
|
||||
- **Ship Cam**: Normal follow camera that looks where your ship points
|
||||
|
||||
### Intermediate Maneuvers
|
||||
@@ -115,7 +155,7 @@ Your ship is a **realistic space vehicle** with the following characteristics:
|
||||
|
||||
## 🎮 Ball Cam vs Ship Cam
|
||||
|
||||
### Ball Cam Mode (`Enter` to toggle)
|
||||
### Ball Cam Mode (`Space` / `R3` to toggle)
|
||||
|
||||
- **Camera**: Always looks toward the ball
|
||||
- **Ship Control**: Based on ship orientation (NOT camera view)
|
||||
@@ -133,7 +173,7 @@ Your ship is a **realistic space vehicle** with the following characteristics:
|
||||
|
||||
### Turbo System
|
||||
|
||||
- **Activation**: Hold `Shift` (keyboard) or `A` (controller) while thrusting forward
|
||||
- **Activation**: Hold `Shift` (keyboard) or `L3` (controller) while thrusting forward
|
||||
- **Effect**: 2.5x thrust multiplier on main engines only
|
||||
- **Strategy**: Use for quick acceleration or emergency maneuvers
|
||||
|
||||
|
||||
+37
-3
@@ -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)
|
||||
|
||||
+35
-21
@@ -13,26 +13,40 @@ grow_vertical = 2
|
||||
script = ExtResource("1_lobby")
|
||||
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"]
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
follow_focus = true
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/ScrollContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer"]
|
||||
custom_minimum_size = Vector2(520, 0)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="TitleLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 40
|
||||
text = "Lobby"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="StatusLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
modulate = Color(1, 1, 1, 0.65)
|
||||
layout_mode = 2
|
||||
@@ -41,74 +55,74 @@ text = "Connecting..."
|
||||
horizontal_alignment = 1
|
||||
autowrap_mode = 2
|
||||
|
||||
[node name="TeamsSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="TeamsSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="TeamsRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="TeamsRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 20
|
||||
|
||||
[node name="Team0Panel" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow"]
|
||||
[node name="Team0Panel" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="Team0Header" type="Label" parent="CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
|
||||
[node name="Team0Header" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "Team 1"
|
||||
|
||||
[node name="Team0List" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
|
||||
[node name="Team0List" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 2
|
||||
|
||||
[node name="TeamsVSeparator" type="VSeparator" parent="CenterContainer/VBoxContainer/TeamsRow"]
|
||||
[node name="TeamsVSeparator" type="VSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Team1Panel" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow"]
|
||||
[node name="Team1Panel" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="Team1Header" type="Label" parent="CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
|
||||
[node name="Team1Header" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "Team 2"
|
||||
|
||||
[node name="Team1List" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
|
||||
[node name="Team1List" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 2
|
||||
|
||||
[node name="ControlsSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="ControlsSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="ControlsRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="ControlsRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="SwitchTeamButton" type="Button" parent="CenterContainer/VBoxContainer/ControlsRow"]
|
||||
[node name="SwitchTeamButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ControlsRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Switch Team"
|
||||
|
||||
[node name="ReadyButton" type="CheckButton" parent="CenterContainer/VBoxContainer/ControlsRow"]
|
||||
[node name="ReadyButton" type="CheckButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ControlsRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Ready"
|
||||
|
||||
[node name="LeaveButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="LeaveButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
text = "Leave"
|
||||
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/ControlsRow/SwitchTeamButton" to="." method="_on_switch_team_pressed"]
|
||||
[connection signal="toggled" from="CenterContainer/VBoxContainer/ControlsRow/ReadyButton" to="." method="_on_ready_toggled"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/LeaveButton" to="." method="_on_leave_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ControlsRow/SwitchTeamButton" to="." method="_on_switch_team_pressed"]
|
||||
[connection signal="toggled" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ControlsRow/ReadyButton" to="." method="_on_ready_toggled"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/LeaveButton" to="." method="_on_leave_pressed"]
|
||||
|
||||
+66
-51
@@ -13,124 +13,139 @@ grow_vertical = 2
|
||||
script = ExtResource("1_menu")
|
||||
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"]
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
follow_focus = true
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/ScrollContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer"]
|
||||
custom_minimum_size = Vector2(420, 0)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="TitleLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 48
|
||||
text = "Cosmic Clash"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="SubtitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="SubtitleLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
modulate = Color(1, 1, 1, 0.55)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 16
|
||||
text = "Physics-based soccer in space"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="TitleSpacer" type="Control" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="TitleSpacer" type="Control" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 14)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="FreePlayButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="FreePlayButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 56)
|
||||
layout_mode = 2
|
||||
text = "Free Play"
|
||||
|
||||
[node name="FreePlayHint" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="FreePlayHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
modulate = Color(1, 1, 1, 0.55)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "Solo practice — no timer, R resets the ball"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="ArenaRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="ArenaRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="ArenaLabel" type="Label" parent="CenterContainer/VBoxContainer/ArenaRow"]
|
||||
[node name="ArenaLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ArenaRow"]
|
||||
layout_mode = 2
|
||||
text = "Arena"
|
||||
|
||||
[node name="ArenaDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/ArenaRow"]
|
||||
[node name="ArenaDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ArenaRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="MatchSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MatchSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="MatchHeader" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MatchHeader" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "Match"
|
||||
|
||||
[node name="MatchHint" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MatchHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
modulate = Color(1, 1, 1, 0.55)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "A 2:30 match — you vs a trained bot"
|
||||
|
||||
[node name="MatchRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MatchRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="DifficultyLabel" type="Label" parent="CenterContainer/VBoxContainer/MatchRow"]
|
||||
[node name="DifficultyLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/MatchRow"]
|
||||
layout_mode = 2
|
||||
text = "Difficulty"
|
||||
|
||||
[node name="DifficultyDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/MatchRow"]
|
||||
[node name="DifficultyDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/MatchRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="MatchButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MatchButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 56)
|
||||
layout_mode = 2
|
||||
text = "Play Match"
|
||||
|
||||
[node name="MultiplayerSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MultiplayerSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="MultiplayerHeader" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MultiplayerHeader" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "Multiplayer"
|
||||
|
||||
[node name="MultiplayerHint" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MultiplayerHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
modulate = Color(1, 1, 1, 0.55)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "LAN / direct IP — host a match or join one"
|
||||
|
||||
[node name="FindMatchButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="FindMatchButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
text = "Find Match"
|
||||
|
||||
[node name="HostButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="HostButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
text = "Host"
|
||||
|
||||
[node name="JoinRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="JoinRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="JoinAddressEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/JoinRow"]
|
||||
[node name="JoinAddressEdit" type="LineEdit" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
@@ -138,12 +153,12 @@ size_flags_horizontal = 3
|
||||
text = "127.0.0.1"
|
||||
placeholder_text = "IP address"
|
||||
|
||||
[node name="JoinButton" type="Button" parent="CenterContainer/VBoxContainer/JoinRow"]
|
||||
[node name="JoinButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow"]
|
||||
custom_minimum_size = Vector2(96, 40)
|
||||
layout_mode = 2
|
||||
text = "Join"
|
||||
|
||||
[node name="MultiplayerErrorLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="MultiplayerErrorLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
modulate = Color(1, 0.5, 0.5, 1)
|
||||
layout_mode = 2
|
||||
@@ -152,82 +167,82 @@ text = ""
|
||||
autowrap_mode = 2
|
||||
visible = false
|
||||
|
||||
[node name="SettingsSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="SettingsSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="SettingsButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="SettingsButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 56)
|
||||
layout_mode = 2
|
||||
text = "Settings"
|
||||
|
||||
[node name="DevSection" type="VBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="DevSection" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="DevSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="DevSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="DevHeader" type="Label" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="DevHeader" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "Developer"
|
||||
|
||||
[node name="DevHint" type="Label" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="DevHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
modulate = Color(1, 1, 1, 0.55)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "Dev-only — hidden in release builds"
|
||||
|
||||
[node name="DevOpponentRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="DevOpponentRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="DevOpponentLabel" type="Label" parent="CenterContainer/VBoxContainer/DevSection/DevOpponentRow"]
|
||||
[node name="DevOpponentLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/DevOpponentRow"]
|
||||
layout_mode = 2
|
||||
text = "Opponent override"
|
||||
|
||||
[node name="DevBotDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/DevSection/DevOpponentRow"]
|
||||
[node name="DevBotDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/DevOpponentRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="SpectateSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="SpectateSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="SpectateHeader" type="Label" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="SpectateHeader" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "Spectate"
|
||||
|
||||
[node name="SpectateHint" type="Label" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="SpectateHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
modulate = Color(1, 1, 1, 0.55)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "Watch two bots play each other"
|
||||
|
||||
[node name="SpectateRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="SpectateRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="BotADropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
||||
[node name="BotADropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="VsLabel" type="Label" parent="CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
||||
[node name="VsLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
||||
layout_mode = 2
|
||||
text = "vs"
|
||||
|
||||
[node name="BotBDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
||||
[node name="BotBDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="SpectateButton" type="Button" parent="CenterContainer/VBoxContainer/DevSection"]
|
||||
[node name="SpectateButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||
custom_minimum_size = Vector2(0, 56)
|
||||
layout_mode = 2
|
||||
text = "Watch Match"
|
||||
@@ -279,12 +294,12 @@ custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
text = "Cancel"
|
||||
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/FreePlayButton" to="." method="_on_free_play_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/MatchButton" to="." method="_on_match_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/FindMatchButton" to="." method="_on_find_match_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/HostButton" to="." method="_on_host_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/JoinRow/JoinButton" to="." method="_on_join_pressed"]
|
||||
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/JoinRow/JoinAddressEdit" to="." method="_on_join_address_submitted"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/SettingsButton" to="." method="_on_settings_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/DevSection/SpectateButton" to="." method="_on_spectate_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/FreePlayButton" to="." method="_on_free_play_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/MatchButton" to="." method="_on_match_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/FindMatchButton" to="." method="_on_find_match_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/HostButton" to="." method="_on_host_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinButton" to="." method="_on_join_pressed"]
|
||||
[connection signal="text_submitted" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinAddressEdit" to="." method="_on_join_address_submitted"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/SettingsButton" to="." method="_on_settings_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/SpectateButton" to="." method="_on_spectate_pressed"]
|
||||
[connection signal="pressed" from="ConnectingOverlay/CenterContainer/VBoxContainer/ConnectingCancelButton" to="." method="_on_connecting_cancel_pressed"]
|
||||
|
||||
@@ -13,45 +13,59 @@ grow_vertical = 2
|
||||
script = ExtResource("1_matchmaking")
|
||||
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"]
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
follow_focus = true
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/ScrollContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer"]
|
||||
custom_minimum_size = Vector2(480, 0)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="TitleLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 40
|
||||
text = "Find a Match"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="PlaylistDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="PlaylistDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="StatusLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "Ready to search"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="DetailLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="DetailLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
modulate = Color(1, 1, 1, 0.65)
|
||||
layout_mode = 2
|
||||
autowrap_mode = 2
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="RankedProfileLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="RankedProfileLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
modulate = Color(1, 1, 1, 0.65)
|
||||
layout_mode = 2
|
||||
@@ -59,24 +73,24 @@ text = "Ranked profile unavailable"
|
||||
horizontal_alignment = 1
|
||||
visible = false
|
||||
|
||||
[node name="QueueButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="QueueButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 52)
|
||||
layout_mode = 2
|
||||
text = "Search"
|
||||
|
||||
[node name="CancelButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="CancelButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
text = "Cancel Search"
|
||||
visible = false
|
||||
|
||||
[node name="ProposalRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="ProposalRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="AcceptButton" type="Button" parent="CenterContainer/VBoxContainer/ProposalRow"]
|
||||
[node name="AcceptButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ProposalRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
@@ -84,7 +98,7 @@ size_flags_horizontal = 3
|
||||
text = "Accept"
|
||||
visible = false
|
||||
|
||||
[node name="DeclineButton" type="Button" parent="CenterContainer/VBoxContainer/ProposalRow"]
|
||||
[node name="DeclineButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ProposalRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
@@ -92,14 +106,14 @@ size_flags_horizontal = 3
|
||||
text = "Decline"
|
||||
visible = false
|
||||
|
||||
[node name="BackButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
[node name="BackButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 48)
|
||||
layout_mode = 2
|
||||
text = "Back"
|
||||
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/QueueButton" to="." method="_on_queue_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/CancelButton" to="." method="_on_cancel_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/ProposalRow/AcceptButton" to="." method="_on_accept_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/ProposalRow/DeclineButton" to="." method="_on_decline_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/BackButton" to="." method="_on_back_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/QueueButton" to="." method="_on_queue_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/CancelButton" to="." method="_on_cancel_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ProposalRow/AcceptButton" to="." method="_on_accept_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ProposalRow/DeclineButton" to="." method="_on_decline_pressed"]
|
||||
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/BackButton" to="." method="_on_back_pressed"]
|
||||
|
||||
+129
-45
@@ -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"]
|
||||
|
||||
@@ -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."
|
||||
@@ -0,0 +1 @@
|
||||
uid://dk7plirjfqvld
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://bjtdsbem7kwdv
|
||||
@@ -52,7 +52,7 @@ func _ready() -> void:
|
||||
_populate_dropdown(bot_b_dropdown, bots, GameSettings.spectate_bot_b_path)
|
||||
NetworkManager.connected_to_server.connect(_on_connected_to_server)
|
||||
NetworkManager.connection_failed.connect(_on_connection_failed)
|
||||
$CenterContainer/VBoxContainer/FreePlayButton.grab_focus()
|
||||
%FreePlayButton.grab_focus()
|
||||
|
||||
|
||||
# main_menu.gd's first async flow (task 1.7): Host is synchronous
|
||||
@@ -120,6 +120,11 @@ func _list_bots() -> Array[String]:
|
||||
# disk, else the newest (last) bot.
|
||||
func _populate_dropdown(dropdown: OptionButton, bots: Array[String], preferred_path: String, include_none: bool = false) -> void:
|
||||
dropdown.clear()
|
||||
# These are filled from whatever checkpoints happen to be in res://bots, so
|
||||
# a long filename would otherwise widen the OptionButton (size_flags_h =
|
||||
# EXPAND_FILL) and drag the whole menu past its 420px minimum width.
|
||||
dropdown.clip_text = true
|
||||
dropdown.fit_to_longest_item = false
|
||||
if include_none:
|
||||
dropdown.add_item("(Use difficulty)")
|
||||
dropdown.set_item_metadata(0, "")
|
||||
@@ -173,7 +178,7 @@ func _on_match_pressed() -> void:
|
||||
|
||||
|
||||
func _on_settings_pressed() -> void:
|
||||
get_tree().change_scene_to_file("res://scenes/settings.tscn")
|
||||
get_tree().change_scene_to_file(ScenePaths.SETTINGS)
|
||||
|
||||
|
||||
func _on_spectate_pressed() -> void:
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class_name ScenePaths
|
||||
|
||||
const MAIN_MENU := "res://scenes/main_menu.tscn"
|
||||
const SETTINGS := "res://scenes/settings.tscn"
|
||||
# §6.2 step 10: after RESULTS both peers return HERE, not to the main menu —
|
||||
# a community server whose players are all dumped back to their own menus
|
||||
# every 2.5 minutes has no way to keep a lobby together.
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
extends Control
|
||||
|
||||
# Settings screen: player-facing video knobs on top of VideoSettings (the
|
||||
# Settings screen root, owning the Video tab and the shared Back button. The
|
||||
# Controls tab has its own script (controls_settings.gd) so this file stays
|
||||
# video-only; both tabs' state is committed in _on_back_pressed below.
|
||||
#
|
||||
# Video tab: player-facing video knobs on top of VideoSettings (the
|
||||
# autoload holding + persisting them). Preset/AA/vsync/fps-cap/resolution
|
||||
# scale apply immediately since they're Viewport- or DisplayServer-wide;
|
||||
# glow/brightness/shadow/SDFGI/SSIL/SSAO apply the next time an arena loads
|
||||
@@ -205,6 +209,10 @@ func _mark_custom_if_user_driven() -> void:
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
VideoSettings.save()
|
||||
# Bindings are applied live as the player rebinds them (InputSettings.apply
|
||||
# runs on every change) but are only committed to disk here, matching how
|
||||
# the video knobs behave.
|
||||
InputSettings.save()
|
||||
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||
|
||||
|
||||
|
||||
+18
-9
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
# Guards the input map and the InputSettings remap layer.
|
||||
#
|
||||
# The defect this file exists for: project.godot bound joypad events to only 5
|
||||
# of the 13 flight actions, so a controller could yaw/pitch/roll/turbo but could
|
||||
# not translate at all. Nothing failed, because nothing asserted that a *pair*
|
||||
# of bindings exists — the actions were all present and the game booted fine.
|
||||
# test_every_action_has_both_a_keyboard_and_a_joypad_binding is that assertion,
|
||||
# and it can tell "bound on both devices" from "bound on one", which is the
|
||||
# distinction that was actually missing.
|
||||
#
|
||||
# Several of these tests write to the global InputMap through InputSettings, so
|
||||
# each one must restore it before returning or it corrupts every later case in
|
||||
# the run (the runner shares one process). reset_all() is the restore.
|
||||
|
||||
const CONTROLLER_ONLY_ACTIONS := ["toggle_ball_cam", "reset_ball"]
|
||||
|
||||
|
||||
func _joypad_events(action: String) -> Array:
|
||||
var out := []
|
||||
for event in InputMap.action_get_events(action):
|
||||
if InputSettings.device_kind_of(event) == InputSettings.DEVICE_JOYPAD:
|
||||
out.append(event)
|
||||
return out
|
||||
|
||||
|
||||
func _keyboard_events(action: String) -> Array:
|
||||
var out := []
|
||||
for event in InputMap.action_get_events(action):
|
||||
if InputSettings.device_kind_of(event) == InputSettings.DEVICE_KEYBOARD:
|
||||
out.append(event)
|
||||
return out
|
||||
|
||||
|
||||
func test_every_rebindable_action_exists() -> void:
|
||||
for entry in InputSettings.ACTIONS:
|
||||
assert_true(InputMap.has_action(entry["action"]), "InputMap has action %s" % entry["action"])
|
||||
|
||||
|
||||
func test_every_action_has_both_a_keyboard_and_a_joypad_binding() -> void:
|
||||
# The regression itself: a controller player must be able to reach every
|
||||
# action without touching the keyboard, and vice versa.
|
||||
for entry in InputSettings.ACTIONS:
|
||||
var action: String = entry["action"]
|
||||
assert_true(not _keyboard_events(action).is_empty(), "%s has a keyboard binding" % action)
|
||||
assert_true(not _joypad_events(action).is_empty(), "%s has a joypad binding" % action)
|
||||
|
||||
|
||||
func test_apply_is_lossless_against_the_project_defaults() -> void:
|
||||
# InputSettings stores one binding per device per action, so apply()
|
||||
# rewrites each action's event list to exactly [keyboard, joypad]. If
|
||||
# project.godot ever gains a second keyboard event for a rebindable action,
|
||||
# booting the game would silently drop it — the action would still work, on
|
||||
# fewer keys than the file says. Asserting apply() is a no-op over the
|
||||
# defaults is what distinguishes "bindings intact" from "bindings quietly
|
||||
# trimmed", which counting events cannot do.
|
||||
InputSettings.reset_all()
|
||||
var before := {}
|
||||
for entry in InputSettings.ACTIONS:
|
||||
before[entry["action"]] = InputMap.action_get_events(entry["action"]).size()
|
||||
|
||||
InputSettings.apply()
|
||||
|
||||
for entry in InputSettings.ACTIONS:
|
||||
var action: String = entry["action"]
|
||||
assert_eq(
|
||||
InputMap.action_get_events(action).size(),
|
||||
before[action],
|
||||
"%s keeps every event across apply()" % action
|
||||
)
|
||||
assert_eq(before[action], 2, "%s has exactly one keyboard and one joypad event" % action)
|
||||
|
||||
|
||||
func test_no_two_actions_share_a_joypad_binding() -> void:
|
||||
# Two flight actions sharing one input is silently unplayable rather than an
|
||||
# error, and it is easy to reintroduce: an early draft of this layout had A
|
||||
# as both turbo and thrust-up, and B as both thrust-down and ui_cancel.
|
||||
for entry in InputSettings.ACTIONS:
|
||||
var action: String = entry["action"]
|
||||
var bound := InputSettings.get_binding(action, InputSettings.DEVICE_JOYPAD)
|
||||
assert_true(bound != null, "%s resolves a joypad binding" % action)
|
||||
if bound == null:
|
||||
continue
|
||||
var conflicts := InputSettings.find_conflicts(bound, action)
|
||||
assert_true(
|
||||
conflicts.is_empty(),
|
||||
"%s's joypad binding is unique (also on: %s)" % [action, ", ".join(conflicts)]
|
||||
)
|
||||
|
||||
|
||||
func test_no_two_actions_share_a_keyboard_binding() -> void:
|
||||
for entry in InputSettings.ACTIONS:
|
||||
var action: String = entry["action"]
|
||||
var bound := InputSettings.get_binding(action, InputSettings.DEVICE_KEYBOARD)
|
||||
assert_true(bound != null, "%s resolves a keyboard binding" % action)
|
||||
if bound == null:
|
||||
continue
|
||||
var conflicts := InputSettings.find_conflicts(bound, action)
|
||||
assert_true(
|
||||
conflicts.is_empty(),
|
||||
"%s's keyboard binding is unique (also on: %s)" % [action, ", ".join(conflicts)]
|
||||
)
|
||||
|
||||
|
||||
func _joypad_buttons(action: String) -> Array:
|
||||
var out := []
|
||||
for event in InputMap.action_get_events(action):
|
||||
if event is InputEventJoypadButton:
|
||||
out.append(event.button_index)
|
||||
return out
|
||||
|
||||
|
||||
func test_menus_are_usable_with_a_controller() -> void:
|
||||
# Godot 4.7 ships ui_up/down/left/right with D-pad and stick events but
|
||||
# gives ui_accept and ui_cancel NO joypad binding at all (verified against a
|
||||
# pristine project). A controller could therefore move the highlight around
|
||||
# the main menu and never press anything — the menu looked responsive, which
|
||||
# is exactly why it went unnoticed. project.godot binds them explicitly.
|
||||
assert_true(JOY_BUTTON_A in _joypad_buttons("ui_accept"), "A confirms in menus")
|
||||
assert_true(JOY_BUTTON_B in _joypad_buttons("ui_cancel"), "B goes back in menus")
|
||||
# Navigation is the engine default, but assert it so a future override of
|
||||
# these actions cannot silently strand a controller player again.
|
||||
for action in ["ui_up", "ui_down", "ui_left", "ui_right"]:
|
||||
var pad := 0
|
||||
for event in InputMap.action_get_events(action):
|
||||
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
|
||||
pad += 1
|
||||
assert_true(pad > 0, "%s is reachable on a controller" % action)
|
||||
|
||||
|
||||
func test_leaving_gameplay_is_not_on_a_face_button() -> void:
|
||||
# game_mode.gd exits to the main menu on leave_gameplay, deliberately NOT on
|
||||
# ui_cancel: ui_cancel carries B so menus behave conventionally, and B is far
|
||||
# too easy to hit by accident to also mean "abandon this match". The two
|
||||
# actions being distinct is the whole point, so assert they really differ.
|
||||
assert_true(InputMap.has_action("leave_gameplay"), "leave_gameplay exists")
|
||||
var buttons := _joypad_buttons("leave_gameplay")
|
||||
assert_true(JOY_BUTTON_START in buttons, "Start leaves gameplay")
|
||||
for face in [JOY_BUTTON_A, JOY_BUTTON_B, JOY_BUTTON_X, JOY_BUTTON_Y]:
|
||||
assert_true(face not in buttons, "leave_gameplay must not use a face button")
|
||||
|
||||
|
||||
func test_ball_cam_has_its_own_action_off_ui_accept() -> void:
|
||||
# Ball-cam used to ride ui_accept, which project.godot now binds to A for
|
||||
# menu confirmation. A dedicated action keeps A purely a menu button and
|
||||
# lets the camera toggle be rebound like anything else.
|
||||
assert_true(InputMap.has_action("toggle_ball_cam"), "toggle_ball_cam exists")
|
||||
var joypad := _joypad_events("toggle_ball_cam")
|
||||
assert_true(joypad.size() == 1, "toggle_ball_cam has one joypad binding")
|
||||
if joypad.size() == 1:
|
||||
assert_true(
|
||||
joypad[0] is InputEventJoypadButton and joypad[0].button_index != JOY_BUTTON_A,
|
||||
"toggle_ball_cam is not on A (menu confirm)"
|
||||
)
|
||||
|
||||
|
||||
func test_thrust_is_on_the_triggers() -> void:
|
||||
# The requested layout, asserted where it is load-bearing: triggers are the
|
||||
# only analog inputs on the thrust axis, so binding them to buttons instead
|
||||
# would silently cost proportional throttle without failing anything.
|
||||
var forward := _joypad_events("move_forward")
|
||||
var back := _joypad_events("move_back")
|
||||
assert_true(forward.size() == 1 and forward[0] is InputEventJoypadMotion, "move_forward is an axis")
|
||||
assert_true(back.size() == 1 and back[0] is InputEventJoypadMotion, "move_back is an axis")
|
||||
if forward.size() == 1 and forward[0] is InputEventJoypadMotion:
|
||||
assert_eq(forward[0].axis, JOY_AXIS_TRIGGER_RIGHT, "move_forward axis")
|
||||
if back.size() == 1 and back[0] is InputEventJoypadMotion:
|
||||
assert_eq(back[0].axis, JOY_AXIS_TRIGGER_LEFT, "move_back axis")
|
||||
|
||||
|
||||
func test_pitch_is_on_the_left_stick_nose_down_when_pushed_forward() -> void:
|
||||
var down := _joypad_events("pitch_down")
|
||||
var up := _joypad_events("pitch_up")
|
||||
assert_true(down.size() == 1 and down[0] is InputEventJoypadMotion, "pitch_down is an axis")
|
||||
assert_true(up.size() == 1 and up[0] is InputEventJoypadMotion, "pitch_up is an axis")
|
||||
if down.size() == 1 and down[0] is InputEventJoypadMotion:
|
||||
assert_eq(down[0].axis, JOY_AXIS_LEFT_Y, "pitch_down axis")
|
||||
# Godot reports a stick pushed away from the player as negative Y.
|
||||
assert_true(down[0].axis_value < 0.0, "stick forward pitches the nose down")
|
||||
if up.size() == 1 and up[0] is InputEventJoypadMotion:
|
||||
assert_eq(up[0].axis, JOY_AXIS_LEFT_Y, "pitch_up axis")
|
||||
assert_true(up[0].axis_value > 0.0, "stick back pitches the nose up")
|
||||
|
||||
|
||||
func test_all_rotation_lives_on_the_left_stick() -> void:
|
||||
# Yaw and pitch belong on the same stick. Splitting them across two sticks
|
||||
# (yaw left, pitch right) is playable in the sense that every input works,
|
||||
# so nothing here failed when it was wrong — it just felt broken, because
|
||||
# each stick had a dead axis. Asserting both are on the right stick is what
|
||||
# pins the 6DOF convention down.
|
||||
for action in ["turn_left", "turn_right"]:
|
||||
var events := _joypad_events(action)
|
||||
assert_true(events.size() == 1 and events[0] is InputEventJoypadMotion, "%s is an axis" % action)
|
||||
if events.size() == 1 and events[0] is InputEventJoypadMotion:
|
||||
assert_eq(events[0].axis, JOY_AXIS_LEFT_X, "%s axis" % action)
|
||||
for action in ["pitch_up", "pitch_down"]:
|
||||
var events := _joypad_events(action)
|
||||
if events.size() == 1 and events[0] is InputEventJoypadMotion:
|
||||
assert_eq(events[0].axis, JOY_AXIS_LEFT_Y, "%s axis" % action)
|
||||
|
||||
|
||||
func test_translation_is_analog_on_the_right_stick_and_triggers() -> void:
|
||||
# Six degrees of freedom onto the pad's six analog axes. Strafe and
|
||||
# vertical were digital buttons at first, which cost proportional control
|
||||
# without failing anything — a button binding here still "works", it just
|
||||
# gives full power or nothing, so only checking the event type catches it.
|
||||
var expected := {
|
||||
"move_left": JOY_AXIS_RIGHT_X, "move_right": JOY_AXIS_RIGHT_X,
|
||||
"move_up": JOY_AXIS_RIGHT_Y, "move_down": JOY_AXIS_RIGHT_Y,
|
||||
"move_forward": JOY_AXIS_TRIGGER_RIGHT, "move_back": JOY_AXIS_TRIGGER_LEFT,
|
||||
}
|
||||
for action in expected:
|
||||
var events := _joypad_events(action)
|
||||
assert_true(
|
||||
events.size() == 1 and events[0] is InputEventJoypadMotion,
|
||||
"%s is analog, not a button" % action
|
||||
)
|
||||
if events.size() == 1 and events[0] is InputEventJoypadMotion:
|
||||
assert_eq(events[0].axis, expected[action], "%s axis" % action)
|
||||
|
||||
|
||||
func test_pushing_the_right_stick_up_thrusts_up() -> void:
|
||||
# Godot reports a stick pushed away from the player as negative Y, so the
|
||||
# intuitive direction needs the negative half — easy to get backwards, and
|
||||
# inverted vertical thrust is not something any other assertion notices.
|
||||
var up := _joypad_events("move_up")
|
||||
var down := _joypad_events("move_down")
|
||||
if up.size() == 1 and up[0] is InputEventJoypadMotion:
|
||||
assert_true(up[0].axis_value < 0.0, "stick up thrusts up")
|
||||
if down.size() == 1 and down[0] is InputEventJoypadMotion:
|
||||
assert_true(down[0].axis_value > 0.0, "stick down thrusts down")
|
||||
|
||||
|
||||
func test_roll_is_on_the_shoulder_buttons() -> void:
|
||||
var expected := {"roll_left": JOY_BUTTON_LEFT_SHOULDER, "roll_right": JOY_BUTTON_RIGHT_SHOULDER}
|
||||
for action in expected:
|
||||
var events := _joypad_events(action)
|
||||
assert_true(events.size() == 1 and events[0] is InputEventJoypadButton, "%s is a button" % action)
|
||||
if events.size() == 1 and events[0] is InputEventJoypadButton:
|
||||
assert_eq(events[0].button_index, expected[action], "%s button" % action)
|
||||
|
||||
|
||||
func test_the_face_buttons_are_free_for_menus() -> void:
|
||||
# A/B/X/Y carry no flight action, which is what lets ui_accept keep A and
|
||||
# keeps a stray face-button press from doing something during a match.
|
||||
for entry in InputSettings.ACTIONS:
|
||||
var bound := InputSettings.get_binding(entry["action"], InputSettings.DEVICE_JOYPAD)
|
||||
if bound is InputEventJoypadButton:
|
||||
assert_true(
|
||||
bound.button_index not in [JOY_BUTTON_A, JOY_BUTTON_B, JOY_BUTTON_X, JOY_BUTTON_Y],
|
||||
"%s must not use a face button" % entry["action"]
|
||||
)
|
||||
|
||||
|
||||
func test_event_dict_round_trip_preserves_every_default() -> void:
|
||||
for entry in InputSettings.ACTIONS:
|
||||
for device in [InputSettings.DEVICE_KEYBOARD, InputSettings.DEVICE_JOYPAD]:
|
||||
var original := InputSettings.get_default_binding(entry["action"], device)
|
||||
assert_true(original != null, "%s/%s has a default" % [entry["action"], device])
|
||||
if original == null:
|
||||
continue
|
||||
var restored := InputSettings.event_from_dict(InputSettings.event_to_dict(original))
|
||||
assert_true(restored != null, "%s/%s round-trips to an event" % [entry["action"], device])
|
||||
if restored != null:
|
||||
assert_true(
|
||||
InputSettings.events_match(original, restored),
|
||||
"%s/%s round-trips to an equal event" % [entry["action"], device]
|
||||
)
|
||||
|
||||
|
||||
func test_event_from_dict_rejects_junk() -> void:
|
||||
# A save file from a newer build, or a hand-edited one, must degrade to
|
||||
# "unbound" rather than taking the game down before the player can reach
|
||||
# the Controls tab to fix it.
|
||||
assert_true(InputSettings.event_from_dict({}) == null, "empty dict is not an event")
|
||||
assert_true(InputSettings.event_from_dict({"type": "mouse"}) == null, "unknown type is not an event")
|
||||
assert_true(InputSettings.event_from_dict({"type": "key"}) == null, "keycode-less key is not an event")
|
||||
assert_true(
|
||||
InputSettings.event_from_dict({"type": "joy_axis", "axis": 3, "value": 0.0}) == null,
|
||||
"a centred axis is not an event"
|
||||
)
|
||||
|
||||
|
||||
func test_axis_bindings_are_distinguished_by_direction() -> void:
|
||||
# events_match must NOT collapse the two halves of one axis, or binding
|
||||
# pitch-up would silently unbind pitch-down as a "conflict".
|
||||
var up := InputEventJoypadMotion.new()
|
||||
up.axis = JOY_AXIS_RIGHT_Y
|
||||
up.axis_value = 1.0
|
||||
var down := InputEventJoypadMotion.new()
|
||||
down.axis = JOY_AXIS_RIGHT_Y
|
||||
down.axis_value = -1.0
|
||||
assert_true(not InputSettings.events_match(up, down), "opposite axis halves are different bindings")
|
||||
assert_true(InputSettings.events_match(up, up), "an axis binding matches itself")
|
||||
|
||||
|
||||
func test_set_binding_changes_the_live_input_map() -> void:
|
||||
var rebound := InputEventKey.new()
|
||||
rebound.physical_keycode = KEY_F # not used by any default binding
|
||||
InputSettings.set_binding("move_forward", rebound)
|
||||
|
||||
var found := false
|
||||
for event in InputMap.action_get_events("move_forward"):
|
||||
if event is InputEventKey and event.physical_keycode == KEY_F:
|
||||
found = true
|
||||
assert_true(found, "the rebound key reaches InputMap")
|
||||
assert_true(InputSettings.has_override("move_forward"), "the rebind is recorded as an override")
|
||||
|
||||
# The joypad half must survive a keyboard-only rebind.
|
||||
assert_true(not _joypad_events("move_forward").is_empty(), "rebinding the key keeps the trigger")
|
||||
|
||||
InputSettings.reset_all()
|
||||
|
||||
|
||||
func test_set_binding_displaces_the_conflicting_action() -> void:
|
||||
# Binding X to an input already in use must report and clear the previous
|
||||
# owner, not leave both bound and let the player wonder why two things fire.
|
||||
var shared := InputSettings.get_binding("move_left", InputSettings.DEVICE_KEYBOARD)
|
||||
assert_true(shared != null, "move_left has a keyboard binding to steal")
|
||||
if shared == null:
|
||||
return
|
||||
|
||||
var displaced := InputSettings.set_binding("move_right", shared)
|
||||
assert_true(displaced.has("move_left"), "the displaced action is reported")
|
||||
assert_true(
|
||||
InputSettings.get_binding("move_left", InputSettings.DEVICE_KEYBOARD) == null,
|
||||
"the displaced action is actually unbound"
|
||||
)
|
||||
|
||||
InputSettings.reset_all()
|
||||
|
||||
|
||||
func test_reset_all_restores_the_project_defaults() -> void:
|
||||
var before := InputSettings.binding_text("move_up", InputSettings.DEVICE_JOYPAD)
|
||||
var rebound := InputEventJoypadButton.new()
|
||||
rebound.button_index = JOY_BUTTON_BACK
|
||||
InputSettings.set_binding("move_up", rebound)
|
||||
assert_true(
|
||||
InputSettings.binding_text("move_up", InputSettings.DEVICE_JOYPAD) != before,
|
||||
"the rebind took effect"
|
||||
)
|
||||
|
||||
InputSettings.reset_all()
|
||||
assert_eq(
|
||||
InputSettings.binding_text("move_up", InputSettings.DEVICE_JOYPAD),
|
||||
before,
|
||||
"reset_all restores the default binding"
|
||||
)
|
||||
assert_true(not InputSettings.has_override("move_up"), "reset_all clears the override")
|
||||
|
||||
|
||||
func test_bindings_survive_a_save_and_reload() -> void:
|
||||
# The end-to-end persistence path, which nothing else covers: a rebind that
|
||||
# does not survive a restart is the single most visible way this feature can
|
||||
# fail, and it fails silently — the game runs fine, just on the defaults.
|
||||
#
|
||||
# This writes the real user://input.cfg, so the player's own file is saved
|
||||
# and put back. Restoring it is not optional: the test suite shares a
|
||||
# user:// directory with the game.
|
||||
var had_file := FileAccess.file_exists(InputSettings.SETTINGS_PATH)
|
||||
var original := ""
|
||||
if had_file:
|
||||
original = FileAccess.get_file_as_string(InputSettings.SETTINGS_PATH)
|
||||
|
||||
var rebound := InputEventJoypadButton.new()
|
||||
rebound.button_index = JOY_BUTTON_BACK
|
||||
InputSettings.set_binding("turbo", rebound)
|
||||
InputSettings.invert_pitch = true
|
||||
InputSettings.save()
|
||||
|
||||
# Drop the in-memory state the way a fresh launch would, then reload.
|
||||
InputSettings.reset_all()
|
||||
assert_true(not InputSettings.has_override("turbo"), "state cleared before reload")
|
||||
InputSettings._load()
|
||||
InputSettings.apply()
|
||||
|
||||
assert_true(InputSettings.has_override("turbo"), "the override came back from disk")
|
||||
var loaded := InputSettings.get_binding("turbo", InputSettings.DEVICE_JOYPAD)
|
||||
assert_true(loaded != null, "the reloaded binding is an event")
|
||||
if loaded != null:
|
||||
assert_true(InputSettings.events_match(loaded, rebound), "the reloaded binding matches what was saved")
|
||||
assert_true(InputSettings.invert_pitch, "invert_pitch survives a reload")
|
||||
|
||||
# A deliberately-cleared binding must stay cleared across a restart. This is
|
||||
# the case that distinguishes a real "unbound" record from an absent one:
|
||||
# ConfigFile.set_value() erases a key whose value is null, so a naive
|
||||
# implementation silently restores the default here instead.
|
||||
InputSettings.clear_binding("roll_left", InputSettings.DEVICE_JOYPAD)
|
||||
InputSettings.save()
|
||||
InputSettings.reset_all()
|
||||
InputSettings._load()
|
||||
InputSettings.apply()
|
||||
assert_true(
|
||||
InputSettings.get_binding("roll_left", InputSettings.DEVICE_JOYPAD) == null,
|
||||
"an unbound action stays unbound across a reload"
|
||||
)
|
||||
assert_true(
|
||||
_joypad_events("roll_left").is_empty(),
|
||||
"the unbound action has no joypad event in InputMap after a reload"
|
||||
)
|
||||
|
||||
# And it must actually be live in InputMap, not merely remembered.
|
||||
var live := false
|
||||
for event in InputMap.action_get_events("turbo"):
|
||||
if event is InputEventJoypadButton and event.button_index == JOY_BUTTON_BACK:
|
||||
live = true
|
||||
assert_true(live, "the reloaded binding is applied to InputMap")
|
||||
|
||||
InputSettings.reset_all()
|
||||
if had_file:
|
||||
var restore := FileAccess.open(InputSettings.SETTINGS_PATH, FileAccess.WRITE)
|
||||
if restore != null:
|
||||
restore.store_string(original)
|
||||
restore.close()
|
||||
InputSettings._load()
|
||||
InputSettings.apply()
|
||||
else:
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(InputSettings.SETTINGS_PATH))
|
||||
|
||||
|
||||
func test_invert_pitch_drives_pitch_sign() -> void:
|
||||
var restore := InputSettings.invert_pitch
|
||||
InputSettings.invert_pitch = false
|
||||
assert_eq(InputSettings.pitch_sign(), 1.0, "default pitch sign")
|
||||
InputSettings.invert_pitch = true
|
||||
assert_eq(InputSettings.pitch_sign(), -1.0, "inverted pitch sign")
|
||||
InputSettings.invert_pitch = restore
|
||||
|
||||
|
||||
func test_every_default_binding_has_readable_text() -> void:
|
||||
# A rebind row showing "" or "Joypad Button 9 (Left Shoulder)" is a UI bug
|
||||
# that no other assertion here would catch.
|
||||
for entry in InputSettings.ACTIONS:
|
||||
for device in [InputSettings.DEVICE_KEYBOARD, InputSettings.DEVICE_JOYPAD]:
|
||||
var text := InputSettings.binding_text(entry["action"], device)
|
||||
assert_true(
|
||||
text != "" and text != "Unbound",
|
||||
"%s/%s has a readable label (got %s)" % [entry["action"], device, text]
|
||||
)
|
||||
|
||||
|
||||
func test_device_kind_of_rejects_events_it_cannot_bind() -> void:
|
||||
assert_eq(InputSettings.device_kind_of(InputEventMouseButton.new()), "", "mouse is not a bindable device")
|
||||
assert_eq(InputSettings.device_kind_of(InputEventKey.new()), InputSettings.DEVICE_KEYBOARD, "key device")
|
||||
assert_eq(
|
||||
InputSettings.device_kind_of(InputEventJoypadMotion.new()),
|
||||
InputSettings.DEVICE_JOYPAD,
|
||||
"joypad motion device"
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bwbkb6wa46yh1
|
||||
@@ -0,0 +1,136 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
# Guards the menu screens against the overflow bug that made the main menu
|
||||
# unusable: the layout runs in a hard-fixed 1920x1080 logical viewport
|
||||
# (window/stretch/mode="viewport"), and a CenterContainer centres its child
|
||||
# rather than clipping it, so once the content's minimum height passed 1080 the
|
||||
# top and bottom spilled off-screen with no way to reach them. In a debug build
|
||||
# the main menu's DevSection pushed it to roughly 1120px, cutting off both the
|
||||
# title and the last button.
|
||||
#
|
||||
# What this file can and cannot do, stated plainly: it asserts the *structure*
|
||||
# that makes overflow reachable — the bottom-most control of each screen sits
|
||||
# inside a ScrollContainer, and that container follows focus so keyboard and
|
||||
# controller navigation cannot strand the player on an off-screen row. It does
|
||||
# not measure anything, so it cannot prove nothing visually clips; that check is
|
||||
# manual, at several window sizes. It exists to stop the wrapper being removed
|
||||
# or a new section being added outside it.
|
||||
#
|
||||
# Scenes are inspected through PackedScene.get_state() rather than instantiated.
|
||||
# main_menu.gd and lobby.gd connect NetworkManager signals and scan res://bots
|
||||
# in _ready(), so instantiating them in a unit test would be doing real work to
|
||||
# answer a question about the scene file.
|
||||
|
||||
# scene path -> the control furthest down that screen, i.e. the first thing to
|
||||
# be lost to overflow. Naming a specific leaf rather than "some ScrollContainer
|
||||
# exists" is what makes this assertion say something: a wrapper that does not
|
||||
# actually contain the content would still pass the weaker version.
|
||||
const DEEPEST_CONTROLS := {
|
||||
"res://scenes/main_menu.tscn": "SpectateButton",
|
||||
"res://scenes/settings.tscn": "ResetButton",
|
||||
"res://scenes/lobby.tscn": "LeaveButton",
|
||||
"res://scenes/matchmaking.tscn": "BackButton",
|
||||
}
|
||||
|
||||
|
||||
# Returns node index -> "Parent/Path/Name" for every node in the scene state,
|
||||
# reconstructing full paths from SceneState's parent-relative storage.
|
||||
func _node_paths(state: SceneState) -> Dictionary:
|
||||
var paths := {}
|
||||
for i in state.get_node_count():
|
||||
var parent := state.get_node_path(i, true)
|
||||
var name := String(state.get_node_name(i))
|
||||
var parent_str := String(parent)
|
||||
if parent_str == "." or parent_str == "":
|
||||
paths[i] = name
|
||||
else:
|
||||
paths[i] = "%s/%s" % [parent_str, name]
|
||||
return paths
|
||||
|
||||
|
||||
func _property(state: SceneState, index: int, wanted: String, fallback):
|
||||
for p in state.get_node_property_count(index):
|
||||
if String(state.get_node_property_name(index, p)) == wanted:
|
||||
return state.get_node_property_value(index, p)
|
||||
return fallback
|
||||
|
||||
|
||||
func test_every_menu_scene_loads() -> void:
|
||||
for scene_path in DEEPEST_CONTROLS:
|
||||
var scene := load(scene_path)
|
||||
assert_true(scene is PackedScene, "%s loads as a PackedScene" % scene_path)
|
||||
|
||||
|
||||
func test_the_bottom_of_each_menu_sits_inside_a_scroll_container() -> void:
|
||||
for scene_path in DEEPEST_CONTROLS:
|
||||
var scene: PackedScene = load(scene_path)
|
||||
if scene == null:
|
||||
assert_true(false, "%s failed to load" % scene_path)
|
||||
continue
|
||||
var state := scene.get_state()
|
||||
var paths := _node_paths(state)
|
||||
|
||||
# Collect the paths of every ScrollContainer in the scene...
|
||||
var scroll_paths := []
|
||||
for i in state.get_node_count():
|
||||
if String(state.get_node_type(i)) == "ScrollContainer":
|
||||
scroll_paths.append(paths[i])
|
||||
assert_true(not scroll_paths.is_empty(), "%s has a ScrollContainer" % scene_path)
|
||||
|
||||
# ...then require the deepest control to live under one of them.
|
||||
var wanted: String = DEEPEST_CONTROLS[scene_path]
|
||||
var found_path := ""
|
||||
for i in state.get_node_count():
|
||||
if String(state.get_node_name(i)) == wanted:
|
||||
found_path = paths[i]
|
||||
break
|
||||
assert_true(found_path != "", "%s contains %s" % [scene_path, wanted])
|
||||
if found_path == "":
|
||||
continue
|
||||
|
||||
var scrolled := false
|
||||
for scroll_path in scroll_paths:
|
||||
if found_path.begins_with(String(scroll_path) + "/"):
|
||||
scrolled = true
|
||||
break
|
||||
assert_true(scrolled, "%s's %s is inside a ScrollContainer (at %s)" % [scene_path, wanted, found_path])
|
||||
|
||||
|
||||
func test_menu_scroll_containers_follow_focus() -> void:
|
||||
# Without follow_focus, grab_focus() on a control below the fold (main_menu
|
||||
# focuses FreePlayButton on ready) leaves the view showing something else,
|
||||
# and controller navigation walks focus off-screen silently.
|
||||
for scene_path in DEEPEST_CONTROLS:
|
||||
var scene: PackedScene = load(scene_path)
|
||||
if scene == null:
|
||||
continue
|
||||
var state := scene.get_state()
|
||||
var checked := 0
|
||||
for i in state.get_node_count():
|
||||
if String(state.get_node_type(i)) != "ScrollContainer":
|
||||
continue
|
||||
checked += 1
|
||||
assert_true(
|
||||
_property(state, i, "follow_focus", false) == true,
|
||||
"%s/%s has follow_focus" % [scene_path, state.get_node_name(i)]
|
||||
)
|
||||
assert_true(checked > 0, "%s has at least one ScrollContainer to check" % scene_path)
|
||||
|
||||
|
||||
func test_menu_scroll_containers_do_not_scroll_horizontally() -> void:
|
||||
# Horizontal scrolling is disabled so content is clamped to the window
|
||||
# width instead of growing a second scrollbar — the dev bot dropdowns are
|
||||
# filled from filenames and would otherwise widen the whole menu.
|
||||
for scene_path in DEEPEST_CONTROLS:
|
||||
var scene: PackedScene = load(scene_path)
|
||||
if scene == null:
|
||||
continue
|
||||
var state := scene.get_state()
|
||||
for i in state.get_node_count():
|
||||
if String(state.get_node_type(i)) != "ScrollContainer":
|
||||
continue
|
||||
assert_eq(
|
||||
_property(state, i, "horizontal_scroll_mode", ScrollContainer.SCROLL_MODE_AUTO),
|
||||
ScrollContainer.SCROLL_MODE_DISABLED,
|
||||
"%s/%s disables horizontal scrolling" % [scene_path, state.get_node_name(i)]
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bs8c31fs0tfhe
|
||||
@@ -0,0 +1,177 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
# Covers PlayerShipController's translation of input actions into a ShipAction.
|
||||
#
|
||||
# The point of most of these is the *analog* path. The controller used to read
|
||||
# is_action_pressed(), which is a bool, so a half-pulled trigger and a fully
|
||||
# pulled one produced identical full thrust. A test that only ever pressed
|
||||
# actions at full strength could not tell the two implementations apart — so
|
||||
# these press at fractional strength, which only the get_action_strength()
|
||||
# version can reproduce.
|
||||
#
|
||||
# Input.action_press writes to the global input state, so every test must
|
||||
# release what it pressed before returning or it leaks into later cases.
|
||||
|
||||
const ACTIONS_USED := [
|
||||
"move_forward", "move_back", "move_left", "move_right", "move_up", "move_down",
|
||||
"turn_left", "turn_right", "pitch_up", "pitch_down", "roll_left", "roll_right",
|
||||
"turbo",
|
||||
]
|
||||
|
||||
|
||||
func _controller() -> PlayerShipController:
|
||||
return PlayerShipController.new()
|
||||
|
||||
|
||||
func _release_all() -> void:
|
||||
for action in ACTIONS_USED:
|
||||
Input.action_release(action)
|
||||
|
||||
|
||||
func test_full_strength_matches_the_historical_digital_values() -> void:
|
||||
# The keyboard path must be unchanged by the move to analog: a held key
|
||||
# reports strength 1.0, so every axis lands on exactly ±1.
|
||||
var controller := _controller()
|
||||
|
||||
Input.action_press("move_forward", 1.0)
|
||||
Input.action_press("move_right", 1.0)
|
||||
Input.action_press("move_up", 1.0)
|
||||
var action := controller.get_action()
|
||||
assert_almost_eq(action.thrust.z, 1.0, 0.001, "forward thrust")
|
||||
assert_almost_eq(action.thrust.x, 1.0, 0.001, "right thrust")
|
||||
assert_almost_eq(action.thrust.y, 1.0, 0.001, "up thrust")
|
||||
_release_all()
|
||||
|
||||
Input.action_press("move_back", 1.0)
|
||||
Input.action_press("move_left", 1.0)
|
||||
Input.action_press("move_down", 1.0)
|
||||
action = controller.get_action()
|
||||
assert_almost_eq(action.thrust.z, -1.0, 0.001, "backward thrust")
|
||||
assert_almost_eq(action.thrust.x, -1.0, 0.001, "left thrust")
|
||||
assert_almost_eq(action.thrust.y, -1.0, 0.001, "down thrust")
|
||||
_release_all()
|
||||
|
||||
|
||||
func test_rotation_sign_conventions_are_unchanged() -> void:
|
||||
# Each action must move the ship the way its NAME says. The physics
|
||||
# directions were measured by driving a real Ship through ship.tscn rather
|
||||
# than reasoned about, because the right-hand rule is exactly the kind of
|
||||
# thing that reads as obvious and comes out backwards:
|
||||
#
|
||||
# rotation.x > 0 -> nose UP (torque about local +X)
|
||||
# rotation.y > 0 -> nose LEFT (torque about local +Y)
|
||||
# rotation.z > 0 -> banks LEFT (torque about local +Z)
|
||||
#
|
||||
# pitch was inverted against this for a long time — get_axis's arguments
|
||||
# were the wrong way round, so "pitch_down" raised the nose and the I/K keys
|
||||
# each did the opposite of their label. Nothing caught it because the sign
|
||||
# was self-consistent everywhere it was used; only comparing against the
|
||||
# physics reveals it.
|
||||
var controller := _controller()
|
||||
var restore := InputSettings.invert_pitch
|
||||
InputSettings.invert_pitch = false
|
||||
|
||||
Input.action_press("turn_left", 1.0)
|
||||
Input.action_press("pitch_up", 1.0)
|
||||
Input.action_press("roll_left", 1.0)
|
||||
var action := controller.get_action()
|
||||
assert_almost_eq(action.rotation.y, 1.0, 0.001, "yaw left is positive")
|
||||
assert_almost_eq(action.rotation.x, 1.0, 0.001, "pitch UP is positive (nose up)")
|
||||
assert_almost_eq(action.rotation.z, 1.0, 0.001, "roll left is positive")
|
||||
_release_all()
|
||||
|
||||
Input.action_press("turn_right", 1.0)
|
||||
Input.action_press("pitch_down", 1.0)
|
||||
Input.action_press("roll_right", 1.0)
|
||||
action = controller.get_action()
|
||||
assert_almost_eq(action.rotation.y, -1.0, 0.001, "yaw right is negative")
|
||||
assert_almost_eq(action.rotation.x, -1.0, 0.001, "pitch DOWN is negative (nose down)")
|
||||
assert_almost_eq(action.rotation.z, -1.0, 0.001, "roll right is negative")
|
||||
_release_all()
|
||||
|
||||
InputSettings.invert_pitch = restore
|
||||
|
||||
|
||||
func test_partial_strength_produces_partial_thrust() -> void:
|
||||
# The analog assertion. A digital is_action_pressed() implementation would
|
||||
# return 1.0 here and fail.
|
||||
var controller := _controller()
|
||||
|
||||
Input.action_press("move_forward", 0.5)
|
||||
assert_almost_eq(controller.get_action().thrust.z, 0.5, 0.001, "half trigger is half thrust")
|
||||
_release_all()
|
||||
|
||||
Input.action_press("move_up", 0.25)
|
||||
assert_almost_eq(controller.get_action().thrust.y, 0.25, 0.001, "quarter deflection is quarter thrust")
|
||||
_release_all()
|
||||
|
||||
Input.action_press("turn_left", 0.3)
|
||||
assert_almost_eq(controller.get_action().rotation.y, 0.3, 0.001, "partial stick is partial yaw")
|
||||
_release_all()
|
||||
|
||||
|
||||
func test_opposing_inputs_subtract_rather_than_saturate() -> void:
|
||||
# Both halves of one stick axis can report a strength at once; the result
|
||||
# must be their difference, not whichever was read last.
|
||||
var controller := _controller()
|
||||
|
||||
Input.action_press("move_forward", 0.75)
|
||||
Input.action_press("move_back", 0.25)
|
||||
assert_almost_eq(controller.get_action().thrust.z, 0.5, 0.001, "opposed thrust subtracts")
|
||||
_release_all()
|
||||
|
||||
Input.action_press("move_forward", 0.4)
|
||||
Input.action_press("move_back", 0.4)
|
||||
assert_almost_eq(controller.get_action().thrust.z, 0.0, 0.001, "equal opposed thrust cancels")
|
||||
_release_all()
|
||||
|
||||
|
||||
func test_no_input_is_a_zero_action() -> void:
|
||||
var controller := _controller()
|
||||
_release_all()
|
||||
var action := controller.get_action()
|
||||
assert_eq(action.thrust, Vector3.ZERO, "idle thrust")
|
||||
assert_eq(action.rotation, Vector3.ZERO, "idle rotation")
|
||||
assert_true(not action.turbo, "idle turbo")
|
||||
|
||||
|
||||
func test_invert_pitch_flips_only_the_pitch_axis() -> void:
|
||||
var controller := _controller()
|
||||
var restore := InputSettings.invert_pitch
|
||||
|
||||
Input.action_press("pitch_down", 1.0)
|
||||
Input.action_press("turn_left", 1.0)
|
||||
|
||||
InputSettings.invert_pitch = false
|
||||
var normal := controller.get_action().copy()
|
||||
InputSettings.invert_pitch = true
|
||||
var inverted := controller.get_action().copy()
|
||||
|
||||
assert_almost_eq(inverted.rotation.x, -normal.rotation.x, 0.001, "invert flips pitch")
|
||||
assert_almost_eq(inverted.rotation.y, normal.rotation.y, 0.001, "invert leaves yaw alone")
|
||||
|
||||
InputSettings.invert_pitch = restore
|
||||
_release_all()
|
||||
|
||||
|
||||
func test_turbo_is_a_boolean() -> void:
|
||||
var controller := _controller()
|
||||
Input.action_press("turbo", 1.0)
|
||||
assert_true(controller.get_action().turbo, "turbo held")
|
||||
Input.action_release("turbo")
|
||||
assert_true(not controller.get_action().turbo, "turbo released")
|
||||
_release_all()
|
||||
|
||||
|
||||
func test_the_returned_action_is_reused_between_ticks() -> void:
|
||||
# get_action() documents that it returns a reused instance and overwrites
|
||||
# every axis. Callers that keep an action past its tick must copy() it —
|
||||
# local_input_timeline.gd and the prediction ring rely on that contract, so
|
||||
# assert both halves of it.
|
||||
var controller := _controller()
|
||||
Input.action_press("move_forward", 1.0)
|
||||
var first := controller.get_action()
|
||||
_release_all()
|
||||
var second := controller.get_action()
|
||||
assert_true(first == second, "the same ShipAction instance is returned each tick")
|
||||
assert_almost_eq(second.thrust.z, 0.0, 0.001, "releasing clears the axis rather than leaving it stale")
|
||||
@@ -0,0 +1 @@
|
||||
uid://xpr311fjhw2m
|
||||
@@ -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
|
||||
|
||||
@@ -35,7 +35,7 @@ func _ready() -> void:
|
||||
|
||||
func _run_host() -> void:
|
||||
var menu := get_tree().current_scene
|
||||
var host_btn: Button = menu.get_node("CenterContainer/VBoxContainer/HostButton")
|
||||
var host_btn: Button = menu.get_node("MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/HostButton")
|
||||
host_btn.emit_signal("pressed")
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
var scene := get_tree().current_scene
|
||||
@@ -58,7 +58,7 @@ func _run_join_ok() -> void:
|
||||
var menu := get_tree().current_scene
|
||||
var address_edit: LineEdit = menu.get_node("%JoinAddressEdit")
|
||||
address_edit.text = "127.0.0.1"
|
||||
var join_btn: Button = menu.get_node("CenterContainer/VBoxContainer/JoinRow/JoinButton")
|
||||
var join_btn: Button = menu.get_node("MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinButton")
|
||||
join_btn.emit_signal("pressed")
|
||||
var overlay: Control = menu.get_node("%ConnectingOverlay")
|
||||
print("SMOKE INFO: overlay visible right after Join press = %s" % str(overlay.visible))
|
||||
@@ -72,7 +72,7 @@ func _run_join_refused() -> void:
|
||||
var menu := get_tree().current_scene
|
||||
var address_edit: LineEdit = menu.get_node("%JoinAddressEdit")
|
||||
address_edit.text = "127.0.0.1"
|
||||
var join_btn: Button = menu.get_node("CenterContainer/VBoxContainer/JoinRow/JoinButton")
|
||||
var join_btn: Button = menu.get_node("MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinButton")
|
||||
join_btn.emit_signal("pressed")
|
||||
var overlay: Control = menu.get_node("%ConnectingOverlay")
|
||||
print("SMOKE INFO: overlay visible right after Join press (no server) = %s" % str(overlay.visible))
|
||||
@@ -92,7 +92,7 @@ func _run_join_cancel() -> void:
|
||||
var menu := get_tree().current_scene
|
||||
var address_edit: LineEdit = menu.get_node("%JoinAddressEdit")
|
||||
address_edit.text = "10.255.255.1" # non-routable; connect attempt just hangs until timeout/cancel
|
||||
var join_btn: Button = menu.get_node("CenterContainer/VBoxContainer/JoinRow/JoinButton")
|
||||
var join_btn: Button = menu.get_node("MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinButton")
|
||||
join_btn.emit_signal("pressed")
|
||||
var overlay: Control = menu.get_node("%ConnectingOverlay")
|
||||
await get_tree().create_timer(0.5).timeout
|
||||
|
||||
Reference in New Issue
Block a user