netfishing/settings/controller_mapping_manager.gd

987 lines
30 KiB
GDScript3
Raw Permalink Normal View History

class_name ControllerMappingManager
extends Node
signal active_profile_changed
signal active_controller_changed(controller_name: String)
2026-08-04 08:28:23 -04:00
signal controller_input_observed(event: InputEvent)
const FORMAT_VERSION: int = 1
const PROFILE_PATH: String = "user://controller_mappings.json"
const PROFILE_TEMP_PATH: String = "user://controller_mappings.json.tmp"
const PROFILE_BACKUP_PATH: String = "user://controller_mappings.json.backup"
const MAX_PROFILE_BYTES: int = 1024 * 1024
const CAPTURE_AXIS_THRESHOLD: float = 0.55
2026-08-04 08:28:23 -04:00
const CAPTURE_AXIS_RELEASE_THRESHOLD: float = 0.30
2026-08-04 09:48:11 -04:00
const ACTIVE_DEVICE_AXIS_THRESHOLD: float = 0.35
const MUOS_MAPPING_REVISION: String = "muos-v4"
2026-08-04 08:28:23 -04:00
const MUOS_CONTROLLER_NAMES: Array[String] = [
"muOS-Keys",
"Deeplay-keys",
]
const MUOS_MAPPING_BINDINGS: String = (
"a:b0,b:b1,x:b3,y:b2,"
+ "leftshoulder:b4,rightshoulder:b5,"
+ "lefttrigger:b10,righttrigger:b11,"
+ "guide:b8,start:b7,back:b6,"
2026-08-04 08:28:23 -04:00
+ "dpup:h0.1,dpleft:h0.8,dpright:h0.2,dpdown:h0.4,"
+ "leftx:a0,lefty:a1,leftstick:b9,"
+ "rightx:a2,righty:a3,rightstick:b12,platform:Linux,"
2026-08-04 08:28:23 -04:00
)
const ROLE_A: StringName = &"a"
const ROLE_B: StringName = &"b"
const ROLE_X: StringName = &"x"
const ROLE_Y: StringName = &"y"
const ROLE_LB: StringName = &"lb"
const ROLE_RB: StringName = &"rb"
2026-08-04 09:48:11 -04:00
# Keep the serialized keys for profile compatibility, but name the roles after
# their game actions. A handheld may expose either action as a button, a
# dedicated trigger axis, or one half of a shared axis.
const ROLE_POINTER_MODIFIER: StringName = &"lt"
const ROLE_CAMERA_ZOOM: StringName = &"rt"
const ROLE_LT: StringName = ROLE_POINTER_MODIFIER
const ROLE_RT: StringName = ROLE_CAMERA_ZOOM
const ROLE_SELECT: StringName = &"select"
const ROLE_START: StringName = &"start"
const ROLE_LEFT_STICK_CLICK: StringName = &"left_stick_click"
const ROLE_RIGHT_STICK_CLICK: StringName = &"right_stick_click"
const ROLE_DPAD_UP: StringName = &"dpad_up"
const ROLE_DPAD_DOWN: StringName = &"dpad_down"
const ROLE_DPAD_LEFT: StringName = &"dpad_left"
const ROLE_DPAD_RIGHT: StringName = &"dpad_right"
const ROLE_LEFT_STICK_X: StringName = &"left_stick_x"
const ROLE_LEFT_STICK_Y: StringName = &"left_stick_y"
const ROLE_RIGHT_STICK_X: StringName = &"right_stick_x"
const ROLE_RIGHT_STICK_Y: StringName = &"right_stick_y"
const ROLE_ORDER: Array[StringName] = [
ROLE_A,
ROLE_B,
ROLE_X,
ROLE_Y,
ROLE_LB,
ROLE_RB,
2026-08-04 09:48:11 -04:00
ROLE_POINTER_MODIFIER,
ROLE_CAMERA_ZOOM,
ROLE_SELECT,
ROLE_START,
ROLE_LEFT_STICK_CLICK,
ROLE_RIGHT_STICK_CLICK,
ROLE_DPAD_UP,
ROLE_DPAD_DOWN,
ROLE_DPAD_LEFT,
ROLE_DPAD_RIGHT,
ROLE_LEFT_STICK_X,
ROLE_LEFT_STICK_Y,
ROLE_RIGHT_STICK_X,
ROLE_RIGHT_STICK_Y,
]
const ROLE_LABELS: Dictionary = {
ROLE_A: "jump / menu accept",
ROLE_B: "menu back",
ROLE_X: "player menu",
ROLE_Y: "interact",
ROLE_LB: "focus chat or world",
ROLE_RB: "primary action",
2026-08-04 09:48:11 -04:00
ROLE_POINTER_MODIFIER: "virtual mouse modifier",
ROLE_CAMERA_ZOOM: "camera zoom",
ROLE_SELECT: "chat",
ROLE_START: "pause",
ROLE_LEFT_STICK_CLICK: "sprint",
ROLE_RIGHT_STICK_CLICK: "unassigned stick click",
ROLE_DPAD_UP: "emote wheel",
ROLE_DPAD_DOWN: "quick menu",
ROLE_DPAD_LEFT: "previous hotbar slot",
ROLE_DPAD_RIGHT: "next hotbar slot",
ROLE_LEFT_STICK_X: "move left / right",
ROLE_LEFT_STICK_Y: "move up / down",
ROLE_RIGHT_STICK_X: "camera left / right",
ROLE_RIGHT_STICK_Y: "camera up / down",
}
const ROLE_PROMPTS: Dictionary = {
ROLE_A: "press the a button",
ROLE_B: "press the b button",
ROLE_X: "press the x button",
ROLE_Y: "press the y button",
ROLE_LB: "press the left bumper",
ROLE_RB: "press the right bumper",
2026-08-04 09:48:11 -04:00
ROLE_POINTER_MODIFIER: "press or squeeze the virtual mouse modifier",
ROLE_CAMERA_ZOOM: "press or squeeze the camera zoom control",
ROLE_SELECT: "press select / back",
ROLE_START: "press start",
ROLE_LEFT_STICK_CLICK: "click the left stick",
ROLE_RIGHT_STICK_CLICK: "click the right stick",
ROLE_DPAD_UP: "press d-pad up",
ROLE_DPAD_DOWN: "press d-pad down",
ROLE_DPAD_LEFT: "press d-pad left",
ROLE_DPAD_RIGHT: "press d-pad right",
ROLE_LEFT_STICK_X: "move the left stick left",
ROLE_LEFT_STICK_Y: "move the left stick up",
ROLE_RIGHT_STICK_X: "move the right stick left",
ROLE_RIGHT_STICK_Y: "move the right stick up",
}
const STICK_AXIS_ROLES: Array[StringName] = [
ROLE_LEFT_STICK_X,
ROLE_LEFT_STICK_Y,
ROLE_RIGHT_STICK_X,
ROLE_RIGHT_STICK_Y,
]
2026-08-04 09:48:11 -04:00
const TRIGGER_ROLES: Array[StringName] = [
ROLE_POINTER_MODIFIER,
ROLE_CAMERA_ZOOM,
]
const BUTTON_ACTION_ROLES: Dictionary = {
&"jump": ROLE_A,
&"ui_accept": ROLE_A,
&"ui_cancel": ROLE_B,
&"open_backpack": ROLE_X,
&"interact": ROLE_Y,
&"focus_gameplay": ROLE_LB,
&"fish_primary": ROLE_RB,
&"open_chat": ROLE_SELECT,
&"open_system_menu": ROLE_START,
&"sprint": ROLE_LEFT_STICK_CLICK,
&"open_emotes": ROLE_DPAD_UP,
&"open_quick_actions": ROLE_DPAD_DOWN,
&"hotbar_previous": ROLE_DPAD_LEFT,
&"hotbar_next": ROLE_DPAD_RIGHT,
}
const AXIS_ACTION_ROLES: Dictionary = {
&"move_left": [ROLE_LEFT_STICK_X, -1.0],
&"move_right": [ROLE_LEFT_STICK_X, 1.0],
&"move_forward": [ROLE_LEFT_STICK_Y, -1.0],
&"move_backward": [ROLE_LEFT_STICK_Y, 1.0],
&"ui_left": [ROLE_LEFT_STICK_X, -1.0],
&"ui_right": [ROLE_LEFT_STICK_X, 1.0],
&"ui_up": [ROLE_LEFT_STICK_Y, -1.0],
&"ui_down": [ROLE_LEFT_STICK_Y, 1.0],
}
const UI_DPAD_ACTION_ROLES: Dictionary = {
&"ui_up": ROLE_DPAD_UP,
&"ui_down": ROLE_DPAD_DOWN,
&"ui_left": ROLE_DPAD_LEFT,
&"ui_right": ROLE_DPAD_RIGHT,
}
var _profiles: Dictionary = {}
var _active_device_id: int = 0
var _active_profile_key: String = "default"
var _active_controller_name: String = "controller"
var _default_joy_events: Dictionary = {}
var _axis_rest_by_device: Dictionary = {}
2026-08-04 08:28:23 -04:00
var _installed_compatibility_guids: Dictionary = {}
func _ready() -> void:
2026-08-04 08:28:23 -04:00
if not Input.joy_connection_changed.is_connected(
_on_joy_connection_changed
):
Input.joy_connection_changed.connect(_on_joy_connection_changed)
_install_known_controller_mappings()
2026-08-04 09:48:11 -04:00
for device_id: int in Input.get_connected_joypads():
_sample_axis_rest_values(device_id)
_capture_project_defaults()
load_profiles()
_refresh_active_controller()
func _input(event: InputEvent) -> void:
var device_id: int = -1
var button_event := event as InputEventJoypadButton
2026-08-04 08:28:23 -04:00
if button_event != null:
device_id = button_event.device
var motion_event := event as InputEventJoypadMotion
2026-08-04 08:28:23 -04:00
if motion_event != null:
device_id = motion_event.device
2026-08-04 09:48:11 -04:00
var claims_active_device: bool = (
button_event != null and button_event.pressed
)
if motion_event != null:
claims_active_device = _axis_motion_claims_device(motion_event)
if (
claims_active_device
and device_id >= 0
and device_id != _active_device_id
):
_set_active_controller(device_id)
2026-08-04 08:28:23 -04:00
if button_event != null or motion_event != null:
controller_input_observed.emit(event)
func _process(_delta: float) -> void:
var connected: Array[int] = Input.get_connected_joypads()
if connected.is_empty():
connected.append(0)
if not connected.has(_active_device_id):
_set_active_controller(connected[0])
elif (
_active_controller_name == "controller"
and Input.get_connected_joypads().has(_active_device_id)
):
_set_active_controller(_active_device_id)
_sample_axis_rest_values(_active_device_id)
func load_profiles() -> bool:
_recover_interrupted_write()
if not FileAccess.file_exists(PROFILE_PATH):
_profiles = {}
return true
var file := FileAccess.open(PROFILE_PATH, FileAccess.READ)
if file == null:
return false
if file.get_length() > MAX_PROFILE_BYTES:
file.close()
push_warning("Controller mappings are too large; using defaults.")
_profiles = {}
return false
var json := JSON.new()
var error: Error = json.parse(file.get_as_text())
file.close()
if error != OK or typeof(json.data) != TYPE_DICTIONARY:
push_warning("Controller mappings are malformed; using defaults.")
_profiles = {}
return false
var data: Dictionary = json.data
if int(data.get("format_version", -1)) != FORMAT_VERSION:
push_warning("Controller mapping version is unsupported; using defaults.")
_profiles = {}
return false
var raw_profiles: Variant = data.get("profiles", {})
if typeof(raw_profiles) != TYPE_DICTIONARY:
_profiles = {}
return false
_profiles = _validated_profiles(raw_profiles as Dictionary)
return true
func _validated_profiles(raw_profiles: Dictionary) -> Dictionary:
var validated: Dictionary = {}
for key_value: Variant in raw_profiles:
var raw_profile: Variant = raw_profiles[key_value]
if typeof(raw_profile) != TYPE_DICTIONARY:
continue
var profile := raw_profile as Dictionary
var raw_bindings: Variant = profile.get("bindings", {})
if typeof(raw_bindings) != TYPE_DICTIONARY:
continue
var bindings := raw_bindings as Dictionary
var complete: bool = true
for role: StringName in ROLE_ORDER:
var raw_binding: Variant = bindings.get(str(role), {})
if (
typeof(raw_binding) != TYPE_DICTIONARY
or not validate_binding(role, raw_binding as Dictionary)
):
complete = false
break
if not complete:
continue
validated[str(key_value)] = {
"controller_name": str(
profile.get("controller_name", "controller")
),
"bindings": bindings.duplicate(true),
}
return validated
func has_custom_mapping() -> bool:
return _profiles.has(_active_profile_key)
func get_active_device_id() -> int:
return _active_device_id
func get_active_controller_name() -> String:
return _active_controller_name
2026-08-04 09:48:11 -04:00
func get_active_controller_guid() -> String:
if not Input.get_connected_joypads().has(_active_device_id):
return ""
return Input.get_joy_guid(_active_device_id).strip_edges()
func get_active_profile_key() -> String:
return _active_profile_key
func get_active_controller_platform() -> String:
return OS.get_name()
func recalibrate_active_device() -> void:
_recalibrate_device(_active_device_id)
func get_role_label(role: StringName) -> String:
return str(ROLE_LABELS.get(role, str(role)))
func get_role_prompt(role: StringName) -> String:
return str(ROLE_PROMPTS.get(role, "press or move the requested input"))
func role_expects_axis(role: StringName) -> bool:
return role in STICK_AXIS_ROLES
func role_accepts_axis(role: StringName) -> bool:
return role in STICK_AXIS_ROLES or role in TRIGGER_ROLES
2026-08-04 08:28:23 -04:00
func are_capture_inputs_neutral(device_id: int = -1) -> bool:
var target_device: int = (
_active_device_id if device_id < 0 else device_id
)
for button_index: int in JOY_BUTTON_MAX:
if Input.is_joy_button_pressed(target_device, button_index):
return false
for axis_index: int in JOY_AXIS_MAX:
var rest: float = _axis_rest_value(target_device, axis_index)
var current: float = Input.get_joy_axis(target_device, axis_index)
if absf(current - rest) > CAPTURE_AXIS_RELEASE_THRESHOLD:
return false
return true
func get_pressed_capture_button(device_id: int = -1) -> int:
var target_device: int = (
_active_device_id if device_id < 0 else device_id
)
for button_index: int in JOY_BUTTON_MAX:
if Input.is_joy_button_pressed(target_device, button_index):
return button_index
return -1
func get_active_bindings() -> Dictionary:
var raw_profile: Variant = _profiles.get(_active_profile_key, {})
var profile: Dictionary = (
raw_profile as Dictionary
if typeof(raw_profile) == TYPE_DICTIONARY else {}
)
var bindings: Variant = profile.get("bindings", {})
if (
typeof(bindings) == TYPE_DICTIONARY
and not (bindings as Dictionary).is_empty()
):
return (bindings as Dictionary).duplicate(true)
return default_bindings()
func get_binding(role: StringName) -> Dictionary:
var bindings: Dictionary = get_active_bindings()
var binding: Variant = bindings.get(str(role), {})
if typeof(binding) == TYPE_DICTIONARY:
return (binding as Dictionary).duplicate(true)
return {}
func set_binding(role: StringName, binding: Dictionary) -> bool:
if role not in ROLE_ORDER or not validate_binding(role, binding):
return false
var bindings: Dictionary = get_active_bindings()
bindings[str(role)] = binding.duplicate(true)
return replace_active_bindings(bindings)
func replace_active_bindings(bindings: Dictionary) -> bool:
for role: StringName in ROLE_ORDER:
var binding: Variant = bindings.get(str(role), {})
if typeof(binding) != TYPE_DICTIONARY:
return false
if not validate_binding(role, binding as Dictionary):
return false
var previous: Dictionary = _profiles.duplicate(true)
_profiles[_active_profile_key] = {
"controller_name": _active_controller_name,
"bindings": bindings.duplicate(true),
}
if not _save_profiles():
_profiles = previous
return false
_apply_active_profile()
active_profile_changed.emit()
return true
func reset_active_profile() -> bool:
if not _profiles.has(_active_profile_key):
_restore_project_defaults()
active_profile_changed.emit()
return true
var previous: Dictionary = _profiles.duplicate(true)
_profiles.erase(_active_profile_key)
if not _save_profiles():
_profiles = previous
return false
_restore_project_defaults()
active_profile_changed.emit()
return true
func binding_from_event(role: StringName, event: InputEvent) -> Dictionary:
var button := event as InputEventJoypadButton
if role in TRIGGER_ROLES and button != null and button.pressed:
return {
"kind": "button",
"button": int(button.button_index),
}
if role_accepts_axis(role):
var motion := event as InputEventJoypadMotion
if motion == null:
return {}
_sample_axis_rest_values(motion.device)
var rest: float = _axis_rest_value(motion.device, motion.axis)
var travel: float = motion.axis_value - rest
if absf(travel) < CAPTURE_AXIS_THRESHOLD:
return {}
return {
"kind": "axis",
"axis": int(motion.axis),
"direction": signf(travel),
"rest": rest,
}
if button == null or not button.pressed:
return {}
return {
"kind": "button",
"button": int(button.button_index),
}
func validate_binding(role: StringName, binding: Dictionary) -> bool:
var binding_kind: String = str(binding.get("kind", ""))
if role_expects_axis(role) and binding_kind != "axis":
return false
if role not in STICK_AXIS_ROLES and role not in TRIGGER_ROLES:
if binding_kind != "button":
return false
if role in TRIGGER_ROLES and binding_kind not in ["axis", "button"]:
return false
if binding_kind == "button":
var button_index: int = int(binding.get("button", -1))
return button_index >= 0 and button_index < JOY_BUTTON_MAX
var axis_index: int = int(binding.get("axis", -1))
var direction: float = float(binding.get("direction", 0.0))
var rest: float = float(binding.get("rest", 0.0))
return (
axis_index >= 0
and axis_index < JOY_AXIS_MAX
and not is_zero_approx(direction)
and rest >= -1.0
and rest <= 1.0
)
func binding_label(binding: Dictionary) -> String:
if str(binding.get("kind", "")) == "button":
2026-08-04 09:48:11 -04:00
return _button_label(int(binding.get("button", -1)))
if str(binding.get("kind", "")) == "axis":
2026-08-04 09:48:11 -04:00
return "%s %s" % [
_axis_label(int(binding.get("axis", -1))),
"+" if float(binding.get("direction", 0.0)) > 0.0 else "",
]
return "unmapped"
2026-08-04 09:48:11 -04:00
static func _button_label(button_index: int) -> String:
match button_index:
JOY_BUTTON_A:
return "A button"
JOY_BUTTON_B:
return "B button"
JOY_BUTTON_X:
return "X button"
JOY_BUTTON_Y:
return "Y button"
JOY_BUTTON_BACK:
return "back / select"
JOY_BUTTON_GUIDE:
return "guide button"
JOY_BUTTON_START:
return "start button"
JOY_BUTTON_LEFT_STICK:
return "left stick click"
JOY_BUTTON_RIGHT_STICK:
return "right stick click"
JOY_BUTTON_LEFT_SHOULDER:
return "left shoulder"
JOY_BUTTON_RIGHT_SHOULDER:
return "right shoulder"
JOY_BUTTON_DPAD_UP:
return "d-pad up"
JOY_BUTTON_DPAD_DOWN:
return "d-pad down"
JOY_BUTTON_DPAD_LEFT:
return "d-pad left"
JOY_BUTTON_DPAD_RIGHT:
return "d-pad right"
_:
return "button %d" % button_index
static func _axis_label(axis_index: int) -> String:
match axis_index:
JOY_AXIS_LEFT_X:
return "left stick X"
JOY_AXIS_LEFT_Y:
return "left stick Y"
JOY_AXIS_RIGHT_X:
return "right stick X"
JOY_AXIS_RIGHT_Y:
return "right stick Y"
JOY_AXIS_TRIGGER_LEFT:
return "left trigger axis"
JOY_AXIS_TRIGGER_RIGHT:
return "right trigger axis"
_:
return "axis %d" % axis_index
func get_role_strength(role: StringName) -> float:
var binding: Dictionary = get_binding(role)
if str(binding.get("kind", "")) == "button":
return (
1.0
if Input.is_joy_button_pressed(
_active_device_id,
int(binding.get("button", -1)),
)
else 0.0
)
return _axis_binding_strength(binding)
func get_role_axis(role: StringName) -> float:
var binding: Dictionary = get_binding(role)
if str(binding.get("kind", "")) != "axis":
return 0.0
var raw: float = Input.get_joy_axis(
_active_device_id,
int(binding.get("axis", -1)),
)
var direction: float = signf(float(binding.get("direction", -1.0)))
var rest: float = float(binding.get("rest", 0.0))
var delta: float = raw - rest
if is_zero_approx(delta):
return 0.0
var toward_captured_direction: bool = signf(delta) == direction
var endpoint: float = direction if toward_captured_direction else -direction
var available: float = absf(endpoint - rest)
if available <= 0.001:
return 0.0
var magnitude: float = clampf(absf(delta) / available, 0.0, 1.0)
return -magnitude if toward_captured_direction else magnitude
func event_matches_role(event: InputEvent, role: StringName) -> bool:
2026-08-04 09:48:11 -04:00
if not _event_belongs_to_active_device(event):
return false
var binding: Dictionary = get_binding(role)
var button := event as InputEventJoypadButton
if button != null and str(binding.get("kind", "")) == "button":
return button.button_index == int(binding.get("button", -1))
var motion := event as InputEventJoypadMotion
if motion == null or str(binding.get("kind", "")) != "axis":
return false
if motion.axis != int(binding.get("axis", -1)):
return false
return _axis_binding_strength(binding, motion.axis_value) >= 0.5
func event_uses_role(event: InputEvent, role: StringName) -> bool:
2026-08-04 09:48:11 -04:00
if not _event_belongs_to_active_device(event):
return false
var binding: Dictionary = get_binding(role)
var button := event as InputEventJoypadButton
if button != null and str(binding.get("kind", "")) == "button":
return button.button_index == int(binding.get("button", -1))
var motion := event as InputEventJoypadMotion
return (
motion != null
and str(binding.get("kind", "")) == "axis"
and motion.axis == int(binding.get("axis", -1))
)
static func default_bindings() -> Dictionary:
return {
str(ROLE_A): _button_binding(JOY_BUTTON_A),
str(ROLE_B): _button_binding(JOY_BUTTON_B),
str(ROLE_X): _button_binding(JOY_BUTTON_X),
str(ROLE_Y): _button_binding(JOY_BUTTON_Y),
str(ROLE_LB): _button_binding(JOY_BUTTON_LEFT_SHOULDER),
str(ROLE_RB): _button_binding(JOY_BUTTON_RIGHT_SHOULDER),
2026-08-04 09:48:11 -04:00
str(ROLE_POINTER_MODIFIER): _axis_binding(
JOY_AXIS_TRIGGER_RIGHT, 1.0, 0.0
),
str(ROLE_CAMERA_ZOOM): _axis_binding(
JOY_AXIS_TRIGGER_LEFT, 1.0, 0.0
),
str(ROLE_SELECT): _button_binding(JOY_BUTTON_BACK),
str(ROLE_START): _button_binding(JOY_BUTTON_START),
str(ROLE_LEFT_STICK_CLICK): _button_binding(JOY_BUTTON_LEFT_STICK),
str(ROLE_RIGHT_STICK_CLICK): _button_binding(JOY_BUTTON_RIGHT_STICK),
str(ROLE_DPAD_UP): _button_binding(JOY_BUTTON_DPAD_UP),
str(ROLE_DPAD_DOWN): _button_binding(JOY_BUTTON_DPAD_DOWN),
str(ROLE_DPAD_LEFT): _button_binding(JOY_BUTTON_DPAD_LEFT),
str(ROLE_DPAD_RIGHT): _button_binding(JOY_BUTTON_DPAD_RIGHT),
str(ROLE_LEFT_STICK_X): _axis_binding(JOY_AXIS_LEFT_X, -1.0, 0.0),
str(ROLE_LEFT_STICK_Y): _axis_binding(JOY_AXIS_LEFT_Y, -1.0, 0.0),
str(ROLE_RIGHT_STICK_X): _axis_binding(JOY_AXIS_RIGHT_X, -1.0, 0.0),
str(ROLE_RIGHT_STICK_Y): _axis_binding(JOY_AXIS_RIGHT_Y, -1.0, 0.0),
}
static func _button_binding(button: JoyButton) -> Dictionary:
return {"kind": "button", "button": int(button)}
static func _axis_binding(
axis: JoyAxis,
direction: float,
rest: float,
) -> Dictionary:
return {
"kind": "axis",
"axis": int(axis),
"direction": direction,
"rest": rest,
}
func _axis_binding_strength(
binding: Dictionary,
raw_override: float = INF,
) -> float:
var axis: int = int(binding.get("axis", -1))
if axis < 0:
return 0.0
var raw: float = (
Input.get_joy_axis(_active_device_id, axis)
if is_inf(raw_override) else raw_override
)
var rest: float = float(binding.get("rest", 0.0))
var direction: float = signf(float(binding.get("direction", 0.0)))
var endpoint: float = direction
var available: float = absf(endpoint - rest)
if available <= 0.001:
return 0.0
return clampf((raw - rest) * direction / available, 0.0, 1.0)
func _refresh_active_controller() -> void:
var connected: Array[int] = Input.get_connected_joypads()
2026-08-04 09:48:11 -04:00
if connected.has(_active_device_id):
_set_active_controller(_active_device_id)
return
_set_active_controller(connected[0] if not connected.is_empty() else 0)
2026-08-04 08:28:23 -04:00
func _on_joy_connection_changed(device_id: int, connected: bool) -> void:
2026-08-04 09:48:11 -04:00
_axis_rest_by_device.erase(str(device_id))
2026-08-04 08:28:23 -04:00
if connected:
_install_known_controller_mapping(device_id)
2026-08-04 09:48:11 -04:00
call_deferred("_recalibrate_device", device_id)
2026-08-04 08:28:23 -04:00
_refresh_active_controller()
func _install_known_controller_mappings() -> void:
for device_id: int in Input.get_connected_joypads():
_install_known_controller_mapping(device_id)
func _install_known_controller_mapping(device_id: int) -> bool:
var controller_name: String = Input.get_joy_name(device_id).strip_edges()
2026-08-04 08:54:21 -04:00
if not should_install_muos_compatibility_mapping(
controller_name,
Input.is_joy_known(device_id),
):
2026-08-04 08:28:23 -04:00
return false
var guid: String = Input.get_joy_guid(device_id).strip_edges()
if guid.is_empty():
return false
if _installed_compatibility_guids.has(guid):
return true
_installed_compatibility_guids[guid] = true
Input.add_joy_mapping(
build_muos_controller_mapping(guid, controller_name),
true,
)
return true
static func build_muos_controller_mapping(
guid: String,
controller_name: String,
) -> String:
return "%s,%s,%s" % [
guid.strip_edges(),
controller_name.strip_edges(),
MUOS_MAPPING_BINDINGS,
]
static func is_muos_controller_name(controller_name: String) -> bool:
return controller_name.strip_edges() in MUOS_CONTROLLER_NAMES
2026-08-04 08:54:21 -04:00
static func should_install_muos_compatibility_mapping(
controller_name: String,
is_known: bool,
) -> bool:
# Never update an already-recognized virtual controller at runtime. Godot
# can stop delivering its standardized input events after that replacement.
2026-08-04 08:54:21 -04:00
return is_muos_controller_name(controller_name) and not is_known
func _set_active_controller(device_id: int) -> void:
2026-08-04 09:48:11 -04:00
var previous_device_id: int = _active_device_id
var previous_profile_key: String = _active_profile_key
2026-08-04 09:48:11 -04:00
var previous_controller_name: String = _active_controller_name
_active_device_id = maxi(device_id, 0)
var controller_is_listed: bool = Input.get_connected_joypads().has(
_active_device_id
)
var guid: String = (
Input.get_joy_guid(_active_device_id).strip_edges()
if controller_is_listed else ""
)
var controller_name: String = (
Input.get_joy_name(_active_device_id).strip_edges()
if controller_is_listed else "controller"
)
if controller_name.is_empty():
controller_name = "controller"
_active_controller_name = controller_name
2026-08-04 08:28:23 -04:00
if guid.is_empty():
_active_profile_key = "name:" + controller_name
elif is_muos_controller_name(controller_name):
_active_profile_key = guid + ":" + MUOS_MAPPING_REVISION
else:
_active_profile_key = guid
if (
previous_profile_key == "name:controller"
and _active_profile_key != previous_profile_key
and _profiles.has(previous_profile_key)
and not _profiles.has(_active_profile_key)
):
var previous_profile: Variant = _profiles[previous_profile_key]
if typeof(previous_profile) == TYPE_DICTIONARY:
_profiles[_active_profile_key] = (
previous_profile as Dictionary
).duplicate(true)
_profiles.erase(previous_profile_key)
_save_profiles()
_sample_axis_rest_values(_active_device_id)
_apply_active_profile()
2026-08-04 09:48:11 -04:00
if (
previous_device_id != _active_device_id
or previous_profile_key != _active_profile_key
or previous_controller_name != _active_controller_name
):
active_controller_changed.emit(_active_controller_name)
func _axis_motion_claims_device(event: InputEventJoypadMotion) -> bool:
var device_key: String = str(event.device)
if not _axis_rest_by_device.has(device_key):
_sample_axis_rest_values(event.device)
return false
var rest: float = _axis_rest_value(event.device, event.axis)
return absf(event.axis_value - rest) >= ACTIVE_DEVICE_AXIS_THRESHOLD
func _event_belongs_to_active_device(event: InputEvent) -> bool:
var button := event as InputEventJoypadButton
if button != null:
return button.device == _active_device_id
var motion := event as InputEventJoypadMotion
return motion != null and motion.device == _active_device_id
func _sample_axis_rest_values(device_id: int) -> void:
var key: String = str(device_id)
if _axis_rest_by_device.has(key):
return
var values: Dictionary = {}
for axis: int in JOY_AXIS_MAX:
values[str(axis)] = Input.get_joy_axis(device_id, axis)
_axis_rest_by_device[key] = values
2026-08-04 09:48:11 -04:00
func _recalibrate_device(device_id: int) -> void:
_axis_rest_by_device.erase(str(device_id))
var connected: Array[int] = Input.get_connected_joypads()
if not connected.has(device_id) and not (
connected.is_empty() and device_id == 0
):
return
_sample_axis_rest_values(device_id)
func _axis_rest_value(device_id: int, axis: int) -> float:
_sample_axis_rest_values(device_id)
var values: Dictionary = _axis_rest_by_device.get(str(device_id), {})
return float(values.get(str(axis), 0.0))
func _capture_project_defaults() -> void:
for action: StringName in _all_managed_actions():
var joy_events: Array[InputEvent] = []
for event: InputEvent in InputMap.action_get_events(action):
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
joy_events.append(event.duplicate())
_default_joy_events[action] = joy_events
func _all_managed_actions() -> Array[StringName]:
var result: Array[StringName] = []
for source: Dictionary in [
BUTTON_ACTION_ROLES,
AXIS_ACTION_ROLES,
UI_DPAD_ACTION_ROLES,
]:
for action_value: Variant in source.keys():
var action: StringName = StringName(action_value)
if action not in result:
result.append(action)
return result
func _remove_managed_joy_events() -> void:
for action: StringName in _all_managed_actions():
if not InputMap.has_action(action):
continue
for event: InputEvent in InputMap.action_get_events(action):
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
InputMap.action_erase_event(action, event)
func _restore_project_defaults() -> void:
_remove_managed_joy_events()
for action_value: Variant in _default_joy_events:
var action: StringName = StringName(action_value)
for event: InputEvent in _default_joy_events[action]:
2026-08-04 09:48:11 -04:00
var active_event: InputEvent = event.duplicate()
active_event.device = _active_device_id
InputMap.action_add_event(action, active_event)
func _apply_active_profile() -> void:
if not has_custom_mapping():
_restore_project_defaults()
return
_remove_managed_joy_events()
var bindings: Dictionary = get_active_bindings()
for action_value: Variant in BUTTON_ACTION_ROLES:
var action: StringName = StringName(action_value)
_add_button_action_binding(
action,
bindings.get(str(BUTTON_ACTION_ROLES[action]), {}),
)
for action_value: Variant in AXIS_ACTION_ROLES:
var action: StringName = StringName(action_value)
var role_data: Array = AXIS_ACTION_ROLES[action]
_add_axis_action_binding(
action,
bindings.get(str(role_data[0]), {}),
float(role_data[1]),
)
for action_value: Variant in UI_DPAD_ACTION_ROLES:
var action: StringName = StringName(action_value)
_add_button_action_binding(
action,
bindings.get(str(UI_DPAD_ACTION_ROLES[action]), {}),
)
func _add_button_action_binding(action: StringName, value: Variant) -> void:
if not InputMap.has_action(action) or typeof(value) != TYPE_DICTIONARY:
return
var binding: Dictionary = value as Dictionary
if str(binding.get("kind", "")) != "button":
return
var event := InputEventJoypadButton.new()
2026-08-04 09:48:11 -04:00
event.device = _active_device_id
event.button_index = int(binding.get("button", -1)) as JoyButton
InputMap.action_add_event(action, event)
func _add_axis_action_binding(
action: StringName,
value: Variant,
logical_direction: float,
) -> void:
if not InputMap.has_action(action) or typeof(value) != TYPE_DICTIONARY:
return
var binding: Dictionary = value as Dictionary
if str(binding.get("kind", "")) != "axis":
return
var captured_negative_direction: float = signf(
float(binding.get("direction", -1.0))
)
var event := InputEventJoypadMotion.new()
2026-08-04 09:48:11 -04:00
event.device = _active_device_id
event.axis = int(binding.get("axis", -1)) as JoyAxis
event.axis_value = logical_direction * -captured_negative_direction
InputMap.action_add_event(action, event)
func _save_profiles() -> bool:
var file := FileAccess.open(PROFILE_TEMP_PATH, FileAccess.WRITE)
if file == null:
return false
file.store_string(JSON.stringify({
"format_version": FORMAT_VERSION,
"profiles": _profiles,
}, "\t"))
file.flush()
var write_error: Error = file.get_error()
file.close()
if write_error != OK:
_remove_if_present(PROFILE_TEMP_PATH)
return false
_remove_if_present(PROFILE_BACKUP_PATH)
var had_primary: bool = FileAccess.file_exists(PROFILE_PATH)
if had_primary and not _rename_file(PROFILE_PATH, PROFILE_BACKUP_PATH):
_remove_if_present(PROFILE_TEMP_PATH)
return false
if not _rename_file(PROFILE_TEMP_PATH, PROFILE_PATH):
if had_primary:
_rename_file(PROFILE_BACKUP_PATH, PROFILE_PATH)
return false
_remove_if_present(PROFILE_BACKUP_PATH)
return true
func _recover_interrupted_write() -> void:
if FileAccess.file_exists(PROFILE_PATH):
_remove_if_present(PROFILE_TEMP_PATH)
_remove_if_present(PROFILE_BACKUP_PATH)
return
if FileAccess.file_exists(PROFILE_BACKUP_PATH):
_rename_file(PROFILE_BACKUP_PATH, PROFILE_PATH)
_remove_if_present(PROFILE_TEMP_PATH)
func _rename_file(from_path: String, to_path: String) -> bool:
return DirAccess.rename_absolute(
ProjectSettings.globalize_path(from_path),
ProjectSettings.globalize_path(to_path),
) == OK
func _remove_if_present(path: String) -> void:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))