netfishing/settings/controller_mapping_manager.gd

986 lines
30 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

class_name ControllerMappingManager
extends Node
signal active_profile_changed
signal active_controller_changed(controller_name: String)
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
const CAPTURE_AXIS_RELEASE_THRESHOLD: float = 0.30
const ACTIVE_DEVICE_AXIS_THRESHOLD: float = 0.35
const MUOS_MAPPING_REVISION: String = "muos-v4"
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,"
+ "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,"
)
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"
# 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,
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",
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",
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,
]
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 = {}
var _installed_compatibility_guids: Dictionary = {}
func _ready() -> void:
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()
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
if button_event != null:
device_id = button_event.device
var motion_event := event as InputEventJoypadMotion
if motion_event != null:
device_id = motion_event.device
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)
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
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
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":
return _button_label(int(binding.get("button", -1)))
if str(binding.get("kind", "")) == "axis":
return "%s %s" % [
_axis_label(int(binding.get("axis", -1))),
"+" if float(binding.get("direction", 0.0)) > 0.0 else "",
]
return "unmapped"
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:
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:
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),
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()
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)
func _on_joy_connection_changed(device_id: int, connected: bool) -> void:
_axis_rest_by_device.erase(str(device_id))
if connected:
_install_known_controller_mapping(device_id)
call_deferred("_recalibrate_device", device_id)
_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()
if not should_install_muos_compatibility_mapping(
controller_name,
Input.is_joy_known(device_id),
):
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
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.
return is_muos_controller_name(controller_name) and not is_known
func _set_active_controller(device_id: int) -> void:
var previous_device_id: int = _active_device_id
var previous_profile_key: String = _active_profile_key
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
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()
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
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]:
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()
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()
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))