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))