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

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

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

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

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

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

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

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

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

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

Note for follow-up: every policy in Game/bots/ was trained against the old
sluggish, world-axis rotation and will over-rotate until retrained.
This commit is contained in:
Josh Creek
2026-09-06 20:41:20 +01:00
parent 00b900d864
commit 076d27a564
17 changed files with 1430 additions and 87 deletions
+188
View File
@@ -0,0 +1,188 @@
extends ScrollContainer
# Settings screen's Controls tab: rebinds every action in InputSettings.ACTIONS
# for either device, toggles invert-pitch, and resets to the project.godot
# defaults. InputSettings owns the bindings themselves and their persistence;
# this script is only the editor for them, and deliberately keeps
# settings_menu.gd video-only.
#
# Rows are built in code rather than laid out in settings.tscn so the list stays
# derived from InputSettings.ACTIONS — adding a rebindable action means editing
# that one const, not this scene as well.
# A joypad axis has to travel this far before a capture accepts it. Resting
# stick drift is routinely a few percent off centre and would otherwise bind
# itself the instant the player opened a capture.
const AXIS_CAPTURE_THRESHOLD := 0.5
@onready var keyboard_button: Button = %KeyboardButton
@onready var controller_button: Button = %ControllerButton
@onready var status_label: Label = %StatusLabel
@onready var binding_list: VBoxContainer = %BindingList
@onready var invert_pitch_check: CheckBox = %InvertPitchCheck
@onready var reset_button: Button = %ResetButton
var _device: String = InputSettings.DEVICE_KEYBOARD
# The action currently awaiting an input event, or "" when not capturing.
var _capturing: String = ""
# action -> the row's Button, so a rebuild-free label refresh is possible and
# so capture can restore the right button's text on cancel.
var _row_buttons: Dictionary = {}
func _ready() -> void:
keyboard_button.pressed.connect(_on_device_selected.bind(InputSettings.DEVICE_KEYBOARD))
controller_button.pressed.connect(_on_device_selected.bind(InputSettings.DEVICE_JOYPAD))
invert_pitch_check.toggled.connect(_on_invert_pitch_toggled)
reset_button.pressed.connect(_on_reset_pressed)
invert_pitch_check.button_pressed = InputSettings.invert_pitch
_update_device_buttons()
_rebuild_rows()
func _on_device_selected(device: String) -> void:
_cancel_capture()
_device = device
_update_device_buttons()
_rebuild_rows()
func _update_device_buttons() -> void:
keyboard_button.button_pressed = _device == InputSettings.DEVICE_KEYBOARD
controller_button.button_pressed = _device == InputSettings.DEVICE_JOYPAD
func _rebuild_rows() -> void:
for child in binding_list.get_children():
child.queue_free()
_row_buttons.clear()
var last_group := ""
for entry in InputSettings.ACTIONS:
var group: String = entry["group"]
if group != last_group:
last_group = group
binding_list.add_child(_make_group_header(group))
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
var label := Label.new()
label.text = entry["label"]
label.custom_minimum_size = Vector2(200, 0)
row.add_child(label)
var button := Button.new()
var action: String = entry["action"]
button.text = InputSettings.binding_text(action, _device)
button.custom_minimum_size = Vector2(0, 36)
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.clip_text = true
button.pressed.connect(_begin_capture.bind(action))
row.add_child(button)
_row_buttons[action] = button
binding_list.add_child(row)
# Re-bound after every rebuild: the rows above are new nodes each time, so
# the buttons AudioManager was tracking no longer exist.
AudioManager.bind_tree_buttons(self)
func _make_group_header(group: String) -> Label:
var header := Label.new()
header.text = group
header.add_theme_font_size_override("font_size", 18)
header.modulate = Color(1, 1, 1, 0.7)
return header
func _begin_capture(action: String) -> void:
_cancel_capture()
_capturing = action
var button: Button = _row_buttons[action]
button.text = "Press a key…" if _device == InputSettings.DEVICE_KEYBOARD else "Press a button…"
status_label.text = "Listening — press Escape to cancel."
func _cancel_capture() -> void:
if _capturing == "":
return
var action := _capturing
_capturing = ""
if _row_buttons.has(action) and is_instance_valid(_row_buttons[action]):
_row_buttons[action].text = InputSettings.binding_text(action, _device)
status_label.text = ""
# _input rather than _unhandled_input: the row Button has focus while capturing,
# and an unhandled-input handler would never see the key that Button consumes as
# its own activation. Everything consumed here is marked handled so the pending
# event cannot also re-press that button and re-enter capture.
func _input(event: InputEvent) -> void:
if _capturing == "":
return
if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled()
_cancel_capture()
return
var captured := _capturable_event(event)
if captured == null:
return
get_viewport().set_input_as_handled()
var action := _capturing
_capturing = ""
var displaced := InputSettings.set_binding(action, captured)
_rebuild_rows()
if displaced.is_empty():
status_label.text = ""
else:
status_label.text = "Unbound %s — it was using the same input." % ", ".join(_labels_for(displaced))
# Returns the event to bind, or null if this event is not a legal binding for
# the device kind currently being edited. Keeping the check here means a joypad
# press can never land in the keyboard column just because that tab was open.
func _capturable_event(event: InputEvent) -> InputEvent:
if _device == InputSettings.DEVICE_KEYBOARD:
if event is InputEventKey and event.pressed and not event.echo:
var key := InputEventKey.new()
key.physical_keycode = event.physical_keycode
return key
return null
if event is InputEventJoypadButton and event.pressed:
var button := InputEventJoypadButton.new()
button.button_index = event.button_index
return button
if event is InputEventJoypadMotion and absf(event.axis_value) >= AXIS_CAPTURE_THRESHOLD:
var motion := InputEventJoypadMotion.new()
motion.axis = event.axis
motion.axis_value = signf(event.axis_value)
return motion
return null
func _labels_for(actions: PackedStringArray) -> PackedStringArray:
var out := PackedStringArray()
for action in actions:
for entry in InputSettings.ACTIONS:
if entry["action"] == action:
out.append(entry["label"])
break
return out
func _on_invert_pitch_toggled(pressed: bool) -> void:
InputSettings.invert_pitch = pressed
func _on_reset_pressed() -> void:
_cancel_capture()
InputSettings.reset_all()
invert_pitch_check.button_pressed = InputSettings.invert_pitch
_rebuild_rows()
status_label.text = "Bindings reset to defaults."
+1
View File
@@ -0,0 +1 @@
uid://dk7plirjfqvld
+5 -1
View File
@@ -276,7 +276,11 @@ func _reset_body(body: RigidBody3D, to: Transform3D) -> void:
func _unhandled_input(event):
if event.is_action_pressed("ui_cancel"):
# leave_gameplay (Escape / Start), NOT ui_cancel. ui_cancel carries the B
# button so menus behave the way a controller player expects, and B is far
# too easy to hit by accident for "abandon the match you are playing".
# Menus and the lobby still use ui_cancel; only live gameplay is guarded.
if event.is_action_pressed("leave_gameplay"):
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
+329
View File
@@ -0,0 +1,329 @@
extends Node
# Autoload: persisted keyboard/controller bindings on top of project.godot's
# [input] defaults, plus the flight-feel preferences that belong with them
# (invert pitch). The Settings screen's Controls tab (controls_settings.gd) is
# the only writer; PlayerShipController is the only reader of pitch_sign().
#
# project.godot stays the single source of truth for *defaults*: _ready()
# snapshots whatever InputMap holds at boot, before any override is applied, so
# the default table is never duplicated in GDScript and can never drift from the
# file. An override is only ever a delta on top of that snapshot.
#
# Persisted to user://input.cfg rather than user://settings.cfg, deliberately.
# VideoSettings.save() builds a fresh ConfigFile and writes it, which would drop
# every section it does not itself know about — so two autoloads sharing one
# file would silently erase each other. A separate file sidesteps that entirely
# instead of coupling the two save paths.
# Each action is bound at most once per device kind. That is a deliberate
# simplification of Godot's arbitrary-length event list: it makes a rebind row
# a single button rather than an editable list, and makes "what is X bound to?"
# answerable. The consequence is that applying a binding replaces the whole
# event list for that action (see apply()), so anything project.godot binds
# beyond one keyboard + one joypad event per action would be dropped here.
const DEVICE_KEYBOARD := "keyboard"
const DEVICE_JOYPAD := "joypad"
const SETTINGS_PATH := "user://input.cfg"
# The rebindable action list, and the only place the Controls tab and the tests
# read it from. Order is display order. Actions NOT listed here (ui_*, the F3/F4
# debug overlays) are deliberately not rebindable.
const ACTIONS := [
{"action": "move_forward", "label": "Thrust forward", "group": "Flight"},
{"action": "move_back", "label": "Thrust backward", "group": "Flight"},
{"action": "move_left", "label": "Strafe left", "group": "Flight"},
{"action": "move_right", "label": "Strafe right", "group": "Flight"},
{"action": "move_up", "label": "Thrust up", "group": "Flight"},
{"action": "move_down", "label": "Thrust down", "group": "Flight"},
{"action": "turbo", "label": "Turbo", "group": "Flight"},
{"action": "turn_left", "label": "Yaw left", "group": "Attitude"},
{"action": "turn_right", "label": "Yaw right", "group": "Attitude"},
{"action": "pitch_up", "label": "Pitch up", "group": "Attitude"},
{"action": "pitch_down", "label": "Pitch down", "group": "Attitude"},
{"action": "roll_left", "label": "Roll left", "group": "Attitude"},
{"action": "roll_right", "label": "Roll right", "group": "Attitude"},
{"action": "toggle_ball_cam", "label": "Ball camera", "group": "Other"},
{"action": "reset_ball", "label": "Reset ball (Free Play)", "group": "Other"},
]
# button_index -> label, using the Xbox names the default map is expressed in.
# InputEvent.as_text() renders these as "Joypad Button 9 (Left Shoulder)", which
# is both long and wrong-looking in a rebind row.
const JOY_BUTTON_NAMES := {
JOY_BUTTON_A: "A", JOY_BUTTON_B: "B", JOY_BUTTON_X: "X", JOY_BUTTON_Y: "Y",
JOY_BUTTON_BACK: "Back", JOY_BUTTON_GUIDE: "Guide", JOY_BUTTON_START: "Start",
JOY_BUTTON_LEFT_STICK: "L3", JOY_BUTTON_RIGHT_STICK: "R3",
JOY_BUTTON_LEFT_SHOULDER: "LB", JOY_BUTTON_RIGHT_SHOULDER: "RB",
JOY_BUTTON_DPAD_UP: "D-Pad Up", JOY_BUTTON_DPAD_DOWN: "D-Pad Down",
JOY_BUTTON_DPAD_LEFT: "D-Pad Left", JOY_BUTTON_DPAD_RIGHT: "D-Pad Right",
}
# axis -> [label at negative deflection, label at positive deflection]. The
# triggers rest at 0 and only travel positive, so their negative half is never
# a reachable binding and is labelled as such rather than as a direction.
const JOY_AXIS_NAMES := {
JOY_AXIS_LEFT_X: ["Left Stick Left", "Left Stick Right"],
JOY_AXIS_LEFT_Y: ["Left Stick Up", "Left Stick Down"],
JOY_AXIS_RIGHT_X: ["Right Stick Left", "Right Stick Right"],
JOY_AXIS_RIGHT_Y: ["Right Stick Up", "Right Stick Down"],
JOY_AXIS_TRIGGER_LEFT: ["LT", "LT"],
JOY_AXIS_TRIGGER_RIGHT: ["RT", "RT"],
}
signal bindings_changed
# Push the right stick forward and the nose goes down (flight-sim). Ticking this
# flips it. Applied in PlayerShipController rather than by rewriting the
# bindings, so it stays one preference instead of two swapped rows the player
# then has to reason about.
var invert_pitch: bool = false
# action -> {DEVICE_KEYBOARD: InputEvent|null, DEVICE_JOYPAD: InputEvent|null},
# snapshotted from InputMap at boot before any override lands.
var _defaults: Dictionary = {}
# Same shape, but only for actions the player has actually customised. A device
# key that is absent means "still using the default"; a device key present with
# null means "the player deliberately unbound it".
var _overrides: Dictionary = {}
func _ready() -> void:
_capture_defaults()
_load()
apply()
# Reads project.godot's [input] back out of InputMap. Anything that is neither a
# key nor a joypad button/motion event (mouse buttons, say) is ignored rather
# than mis-filed under a device kind it does not belong to.
func _capture_defaults() -> void:
_defaults.clear()
for entry in ACTIONS:
var action: String = entry["action"]
var slots := {DEVICE_KEYBOARD: null, DEVICE_JOYPAD: null}
if InputMap.has_action(action):
for event in InputMap.action_get_events(action):
var kind := device_kind_of(event)
if kind != "" and slots[kind] == null:
slots[kind] = event
_defaults[action] = slots
# "" for an event this system cannot express (mouse, gesture, MIDI), which is
# also the signal to callers that it is not a legal binding.
static func device_kind_of(event: InputEvent) -> String:
if event is InputEventKey:
return DEVICE_KEYBOARD
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
return DEVICE_JOYPAD
return ""
func _load() -> void:
_overrides.clear()
var cfg := ConfigFile.new()
if cfg.load(SETTINGS_PATH) != OK:
return
invert_pitch = cfg.get_value("input", "invert_pitch", invert_pitch)
for entry in ACTIONS:
var action: String = entry["action"]
for device in [DEVICE_KEYBOARD, DEVICE_JOYPAD]:
var key := "%s.%s" % [action, device]
if not cfg.has_section_key("bindings", key):
continue
var stored = cfg.get_value("bindings", key)
if not (stored is Dictionary):
continue
# An empty dict is the "deliberately unbound" sentinel — see save().
if stored.is_empty():
_set_override(action, device, null)
continue
var event := event_from_dict(stored)
if event != null:
_set_override(action, device, event)
func save() -> void:
var cfg := ConfigFile.new()
cfg.set_value("input", "invert_pitch", invert_pitch)
for action in _overrides:
var slots: Dictionary = _overrides[action]
for device in slots:
var event: InputEvent = slots[device]
var key := "%s.%s" % [action, device]
# An unbound override is written as an empty dict, NOT as null:
# ConfigFile.set_value() treats a null value as "erase this key", so
# storing null would drop the entry and the next load would fall
# back to the project default — silently rebinding something the
# player had deliberately cleared.
cfg.set_value("bindings", key, {} if event == null else event_to_dict(event))
cfg.save(SETTINGS_PATH)
# Rebuilds InputMap for every rebindable action from defaults + overrides. Runs
# wholesale rather than incrementally so there is exactly one code path that
# decides what an action is bound to, whatever route got us here.
func apply() -> void:
for entry in ACTIONS:
var action: String = entry["action"]
if not InputMap.has_action(action):
continue
InputMap.action_erase_events(action)
for device in [DEVICE_KEYBOARD, DEVICE_JOYPAD]:
var event := get_binding(action, device)
if event != null:
InputMap.action_add_event(action, event)
bindings_changed.emit()
func get_binding(action: String, device: String) -> InputEvent:
if _overrides.has(action) and _overrides[action].has(device):
return _overrides[action][device]
if _defaults.has(action):
return _defaults[action][device]
return null
func get_default_binding(action: String, device: String) -> InputEvent:
if not _defaults.has(action):
return null
return _defaults[action][device]
# Binds `event` to `action`, replacing whatever that action had for the event's
# own device kind. Returns the actions that were unbound to avoid a duplicate,
# so the caller can say so rather than leaving the player to discover it.
func set_binding(action: String, event: InputEvent) -> PackedStringArray:
var device := device_kind_of(event)
if device == "":
return PackedStringArray()
var displaced := find_conflicts(event, action)
for other in displaced:
_set_override(other, device, null)
_set_override(action, device, event)
apply()
return displaced
func clear_binding(action: String, device: String) -> void:
_set_override(action, device, null)
apply()
# Actions already bound to an equivalent event, excluding `except_action`.
# Compared by value rather than by object identity — the event coming out of a
# rebind capture is a different instance from the one in the map.
func find_conflicts(event: InputEvent, except_action: String = "") -> PackedStringArray:
var device := device_kind_of(event)
var out := PackedStringArray()
if device == "":
return out
for entry in ACTIONS:
var action: String = entry["action"]
if action == except_action:
continue
var bound := get_binding(action, device)
if bound != null and events_match(bound, event):
out.append(action)
return out
# Equality by the fields a binding is identified by. Deliberately not
# InputEvent.is_match(): for an axis that ignores axis_value, which would make
# "Right Stick Up" and "Right Stick Down" collide as the same binding.
static func events_match(a: InputEvent, b: InputEvent) -> bool:
if a is InputEventKey and b is InputEventKey:
return a.physical_keycode == b.physical_keycode
if a is InputEventJoypadButton and b is InputEventJoypadButton:
return a.button_index == b.button_index
if a is InputEventJoypadMotion and b is InputEventJoypadMotion:
return a.axis == b.axis and signf(a.axis_value) == signf(b.axis_value)
return false
func reset_action(action: String) -> void:
_overrides.erase(action)
apply()
func reset_all() -> void:
_overrides.clear()
invert_pitch = false
apply()
func has_override(action: String) -> bool:
return _overrides.has(action)
func pitch_sign() -> float:
return -1.0 if invert_pitch else 1.0
func _set_override(action: String, device: String, event: InputEvent) -> void:
if not _overrides.has(action):
_overrides[action] = {}
_overrides[action][device] = event
# ConfigFile stores Dictionary values natively, so bindings persist as plain
# data. Never the Object(...) literal Godot writes into project.godot — that
# form is only parsed by the engine's own project-file loader, and round-tripping
# it through user:// would be storing engine-internal syntax in a save file.
static func event_to_dict(event: InputEvent) -> Dictionary:
if event is InputEventKey:
return {"type": "key", "physical_keycode": int(event.physical_keycode)}
if event is InputEventJoypadButton:
return {"type": "joy_button", "button_index": int(event.button_index)}
if event is InputEventJoypadMotion:
return {"type": "joy_axis", "axis": int(event.axis), "value": float(signf(event.axis_value))}
return {}
# Returns null for anything unrecognised, so a save file from a newer build (or
# a hand-edited one) degrades to "this action is unbound" rather than crashing
# the game before the player can reach the Controls tab to fix it.
static func event_from_dict(data: Dictionary) -> InputEvent:
match data.get("type", ""):
"key":
var key := InputEventKey.new()
key.physical_keycode = int(data.get("physical_keycode", 0))
return key if key.physical_keycode != 0 else null
"joy_button":
var button := InputEventJoypadButton.new()
button.button_index = int(data.get("button_index", -1))
return button if button.button_index >= 0 else null
"joy_axis":
var motion := InputEventJoypadMotion.new()
motion.axis = int(data.get("axis", -1))
motion.axis_value = signf(float(data.get("value", 0.0)))
return motion if motion.axis >= 0 and motion.axis_value != 0.0 else null
return null
func event_to_text(event: InputEvent) -> String:
if event == null:
return "Unbound"
if event is InputEventKey:
# Physical keycodes throughout, so the label matches the key's position
# on a non-QWERTY layout the same way the binding itself does. The
# headless display server has no keyboard layout to consult and pushes
# an ERROR for the attempt — which the ENet smoke gate treats as a
# failure on sight — so fall back to the unmapped keycode there.
var keycode: int = event.physical_keycode
if DisplayServer.get_name() != "headless":
keycode = DisplayServer.keyboard_get_keycode_from_physical(keycode)
return OS.get_keycode_string(keycode)
if event is InputEventJoypadButton:
return JOY_BUTTON_NAMES.get(event.button_index, "Button %d" % event.button_index)
if event is InputEventJoypadMotion:
if JOY_AXIS_NAMES.has(event.axis):
return JOY_AXIS_NAMES[event.axis][0 if event.axis_value < 0.0 else 1]
return "Axis %d%s" % [event.axis, "-" if event.axis_value < 0.0 else "+"]
return event.as_text()
func binding_text(action: String, device: String) -> String:
return event_to_text(get_binding(action, device))
+1
View File
@@ -0,0 +1 @@
uid://bjtdsbem7kwdv
+22 -13
View File
@@ -10,30 +10,39 @@ var _action := ShipAction.new()
func get_action() -> ShipAction:
# Full overwrite per axis (not +=/-=): _action is reused across ticks, so
# fields must not depend on starting from a fresh Vector3.ZERO each call.
#
# Input.get_axis(negative, positive) is strength(positive) -
# strength(negative), so these keep the exact sign conventions the digital
# version had while becoming proportional on a controller:
# get_action_strength() returns a flat 1.0 for a held key but the
# normalised past-deadzone deflection for an InputEventJoypadMotion. A
# half-pulled trigger is therefore half thrust, and keyboard flight is
# unchanged down to the value.
# Forward/Backward thrust (main engines)
_action.thrust.z = (1.0 if Input.is_action_pressed("move_forward") else 0.0) \
- (1.0 if Input.is_action_pressed("move_back") else 0.0)
_action.thrust.z = Input.get_axis("move_back", "move_forward")
# Strafe thrusters (left/right)
_action.thrust.x = (1.0 if Input.is_action_pressed("move_right") else 0.0) \
- (1.0 if Input.is_action_pressed("move_left") else 0.0)
_action.thrust.x = Input.get_axis("move_left", "move_right")
# Vertical thrusters (up/down)
_action.thrust.y = (1.0 if Input.is_action_pressed("move_up") else 0.0) \
- (1.0 if Input.is_action_pressed("move_down") else 0.0)
_action.thrust.y = Input.get_axis("move_down", "move_up")
# Yaw (turn left/right around Y axis)
_action.rotation.y = (1.0 if Input.is_action_pressed("turn_left") else 0.0) \
- (1.0 if Input.is_action_pressed("turn_right") else 0.0)
_action.rotation.y = Input.get_axis("turn_right", "turn_left")
# Pitch (nose up/down around X axis)
_action.rotation.x = (1.0 if Input.is_action_pressed("pitch_down") else 0.0) \
- (1.0 if Input.is_action_pressed("pitch_up") else 0.0)
# Pitch (nose up/down around X axis). Positive rotation.x is nose-UP:
# torque about local +X rotates the ship's up vector toward its tail by the
# right-hand rule, which lifts the nose (measured, not assumed). The
# argument order here used to be reversed, so "pitch_down" pitched up and
# the I/K keys were each labelled as the opposite of what they did.
# The default binding then gives flight-sim polarity — right stick forward
# is pitch_down is nose down — and InputSettings holds the player's
# preference for flipping that.
_action.rotation.x = Input.get_axis("pitch_down", "pitch_up") * InputSettings.pitch_sign()
# Roll (bank left/right around Z axis)
_action.rotation.z = (1.0 if Input.is_action_pressed("roll_left") else 0.0) \
- (1.0 if Input.is_action_pressed("roll_right") else 0.0)
_action.rotation.z = Input.get_axis("roll_right", "roll_left")
_action.turbo = Input.is_action_pressed("turbo")
+9 -1
View File
@@ -1,6 +1,10 @@
extends Control
# Settings screen: player-facing video knobs on top of VideoSettings (the
# Settings screen root, owning the Video tab and the shared Back button. The
# Controls tab has its own script (controls_settings.gd) so this file stays
# video-only; both tabs' state is committed in _on_back_pressed below.
#
# Video tab: player-facing video knobs on top of VideoSettings (the
# autoload holding + persisting them). Preset/AA/vsync/fps-cap/resolution
# scale apply immediately since they're Viewport- or DisplayServer-wide;
# glow/brightness/shadow/SDFGI/SSIL/SSAO apply the next time an arena loads
@@ -205,6 +209,10 @@ func _mark_custom_if_user_driven() -> void:
func _on_back_pressed() -> void:
VideoSettings.save()
# Bindings are applied live as the player rebinds them (InputSettings.apply
# runs on every change) but are only committed to disk here, matching how
# the video knobs behave.
InputSettings.save()
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
+18 -9
View File
@@ -15,7 +15,7 @@ const SimConstants = preload("res://scripts/sim_constants.gd")
@export var vertical_thrust = 120.0 # Up/down thruster power
@export var turbo_multiplier = 2.5 # Turbo boost multiplier
@export var max_speed = 35.0 # Maximum velocity
@export var rotation_power = 20.0 # Angular thrust power
@export var rotation_acceleration = 20.0 # Angular acceleration, rad/s^2, equal on all three axes (see apply_rotation_forces)
@export var max_angular_speed = 3.0 # Maximum rotation speed
@export var drag_coefficient = 0.98 # Linear drag (air resistance)
@export var angular_drag = 0.95 # Rotational drag
@@ -557,18 +557,27 @@ func apply_rotation_forces(state: PhysicsDirectBodyState3D, rotation_input: Vect
if rotation_input.length() < 0.01:
return
# Apply torque for rotation - simple and effective
# Physics: τ = I * α (torque = moment of inertia × angular acceleration)
# Also: α = τ / I (angular acceleration = torque / moment of inertia)
# Lower inertia = higher angular acceleration for same torque
# Scaling each axis by its own inertia makes rotation_acceleration mean
# exactly that — α, in rad/s² — so all three axes respond identically.
# ship.tscn's inertia is Vector3(7, 1, 7): a flat torque across all three
# axes therefore used to give yaw 7x the angular acceleration of pitch and
# roll (172 deg/s vs 52 deg/s at steady state). That was an accident of the
# inertia tensor rather than a design decision, and it read as "rotation is
# sluggish except when turning".
var torque = Vector3(
rotation_input.x * rotation_power, # Pitch (rotation around X-axis)
rotation_input.y * rotation_power, # Yaw (rotation around Y-axis)
rotation_input.z * rotation_power # Roll (rotation around Z-axis)
rotation_input.x * rotation_acceleration * inertia.x, # Pitch (local X)
rotation_input.y * rotation_acceleration * inertia.y, # Yaw (local Y)
rotation_input.z * rotation_acceleration * inertia.z # Roll (local Z)
)
# Physics: Δω = τ * Δt / I (change in angular velocity = torque × time / inertia)
state.apply_torque(torque)
# apply_torque() is world-space, and the vector above is in the ship's own
# frame, so it MUST be rotated by the hull's basis — exactly as thrust is
# (see the -ship_basis.z term in apply_thrust_forces). Without this the
# ship rotated about the world axes: roll input became pitch once the ship
# had yawed 90 degrees, and both roll and pitch inverted at 180 degrees, so
# the controls were correct flying up-field and backwards flying back.
state.apply_torque(state.transform.basis * torque)
# Scales a per-tick decay multiplier `k` (defined at a 60 Hz reference rate)
+3 -1
View File
@@ -110,7 +110,9 @@ func _exit_tree() -> void:
func _input(event):
if event.is_action_pressed("ui_accept"): # Enter key
# A dedicated action rather than ui_accept, so the camera toggle is
# rebindable and A stays purely a menu-confirm button. Space / R3.
if event.is_action_pressed("toggle_ball_cam"):
ball_cam_enabled = !ball_cam_enabled
camera_mode_changed.emit(ball_cam_enabled)