mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
076d27a564
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.
330 lines
13 KiB
GDScript
330 lines
13 KiB
GDScript
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))
|