Improve handheld controller mapping

This commit is contained in:
Alexander Sellite 2026-08-04 08:28:23 -04:00
parent 8ca2c10f71
commit 1e56781e23
4 changed files with 347 additions and 20 deletions

View file

@ -3,6 +3,7 @@ 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"
@ -10,6 +11,21 @@ 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 MUOS_MAPPING_REVISION: String = "muos-v1"
const MUOS_CONTROLLER_NAMES: Array[String] = [
"muOS-Keys",
"Deeplay-keys",
]
const MUOS_MAPPING_BINDINGS: String = (
"a:b3,b:b4,x:b6,y:b5,"
+ "leftshoulder:b7,rightshoulder:b8,"
+ "lefttrigger:b13,righttrigger:b14,"
+ "guide:b11,start:b10,back:b9,"
+ "dpup:h0.1,dpleft:h0.8,dpright:h0.2,dpdown:h0.4,"
+ "leftx:a0,lefty:a1,leftstick:b12,"
+ "rightx:a2,righty:a3,rightstick:b15,platform:Linux,"
)
const ROLE_A: StringName = &"a"
const ROLE_B: StringName = &"b"
const ROLE_X: StringName = &"x"
@ -149,9 +165,15 @@ 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()
_capture_project_defaults()
load_profiles()
_refresh_active_controller()
@ -160,16 +182,15 @@ func _ready() -> void:
func _input(event: InputEvent) -> void:
var device_id: int = -1
var button_event := event as InputEventJoypadButton
if button_event != null and button_event.pressed:
if button_event != null:
device_id = button_event.device
var motion_event := event as InputEventJoypadMotion
if (
motion_event != null
and absf(motion_event.axis_value) >= CAPTURE_AXIS_THRESHOLD
):
if motion_event != null:
device_id = motion_event.device
if 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:
@ -278,6 +299,31 @@ 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 = (
@ -546,6 +592,49 @@ func _refresh_active_controller() -> void:
_set_active_controller(connected[0] if not connected.is_empty() else 0)
func _on_joy_connection_changed(device_id: int, connected: bool) -> void:
if connected:
_install_known_controller_mapping(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 controller_name not in MUOS_CONTROLLER_NAMES:
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
func _set_active_controller(device_id: int) -> void:
var previous_profile_key: String = _active_profile_key
_active_device_id = maxi(device_id, 0)
@ -563,11 +652,12 @@ func _set_active_controller(device_id: int) -> void:
if controller_name.is_empty():
controller_name = "controller"
_active_controller_name = controller_name
_active_profile_key = (
guid
if not guid.is_empty()
else "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

View file

@ -22,6 +22,8 @@ func _run() -> void:
failure = _validate_auto_map(manager)
if failure.is_empty():
failure = _validate_virtual_mouse_bounds()
if failure.is_empty():
failure = _validate_controller_scroll_target()
if failure.is_empty():
print("controller mapping validation passed")
quit(0)
@ -31,6 +33,34 @@ func _run() -> void:
func _validate_manager(manager: ControllerMappingManagerType) -> String:
var muos_mapping: String = (
ControllerMappingManagerType.build_muos_controller_mapping(
"19004ca6010000000100000000010000",
"muOS-Keys",
)
)
for expected_binding: String in [
"a:b3",
"b:b4",
"x:b6",
"y:b5",
"leftx:a0",
"righty:a3",
]:
if expected_binding not in muos_mapping:
return "muOS compatibility mapping omitted " + expected_binding
if not muos_mapping.begins_with(
"19004ca6010000000100000000010000,muOS-Keys,"
):
return "muOS compatibility mapping does not use the runtime guid"
if not ControllerMappingManagerType.is_muos_controller_name(
"muOS-Keys"
):
return "muOS controller name was not recognized"
if ControllerMappingManagerType.is_muos_controller_name(
"ordinary controller"
):
return "muOS mapping would affect unrelated controllers"
var defaults: Dictionary = manager.get_active_bindings()
if defaults.size() != ControllerMappingManagerType.ROLE_ORDER.size():
return "default mapping covers %d of %d controller roles: %s" % [
@ -89,6 +119,7 @@ func _validate_auto_map(manager: ControllerMappingManagerType) -> String:
panel.open_panel()
panel._begin_auto_map()
for role: StringName in ControllerMappingManagerType.ROLE_ORDER:
panel._process(ControllerMappingPanelType.CAPTURE_NEUTRAL_SECONDS)
var binding := (
ControllerMappingManagerType.default_bindings()[str(role)]
as Dictionary
@ -103,12 +134,12 @@ func _validate_auto_map(manager: ControllerMappingManagerType) -> String:
var button := InputEventJoypadButton.new()
button.button_index = int(binding.get("button", -1))
button.pressed = true
panel._input(button)
manager.controller_input_observed.emit(button)
else:
var motion := InputEventJoypadMotion.new()
motion.axis = int(binding.get("axis", -1))
motion.axis_value = float(binding.get("direction", -1.0))
panel._input(motion)
manager.controller_input_observed.emit(motion)
if panel._auto_map_active:
return "auto-map did not complete after every requested input"
if not manager.has_custom_mapping():
@ -130,11 +161,31 @@ func _validate_auto_map(manager: ControllerMappingManagerType) -> String:
):
return "duplicate controller bindings were not marked in red"
panel._begin_manual_capture(ControllerMappingManagerType.ROLE_LT)
panel._process(ControllerMappingPanelType.CAPTURE_NEUTRAL_SECONDS)
if "left trigger" in panel._progress_label.text.to_lower():
return "manual remapping still dictates a specific physical input"
if "any button" not in panel._progress_label.text.to_lower():
return "manual remapping does not request a generic controller input"
panel._cancel_capture()
panel._begin_auto_map()
panel._process(ControllerMappingPanelType.CAPTURE_NEUTRAL_SECONDS)
var held_left := InputEventJoypadMotion.new()
held_left.axis = JOY_AXIS_LEFT_X
held_left.axis_value = -1.0
for _index: int in 4:
manager.controller_input_observed.emit(held_left)
if panel._auto_map_index != 0:
return "auto-map accepted repeated analog motion without a neutral gate"
var first_button := InputEventJoypadButton.new()
first_button.button_index = JOY_BUTTON_A
first_button.pressed = true
manager.controller_input_observed.emit(first_button)
if panel._auto_map_index != 1 or not panel._waiting_for_neutral:
return "auto-map did not gate the next step after a captured input"
manager.controller_input_observed.emit(first_button)
if panel._auto_map_index != 1:
return "held input advanced more than one auto-map step"
panel._cancel_capture()
return ""
@ -149,6 +200,38 @@ func _validate_virtual_mouse_bounds() -> String:
return ""
func _validate_controller_scroll_target() -> String:
var game_ui := GameUIType.new()
var page := VBoxContainer.new()
var scroll := ScrollContainer.new()
var content := VBoxContainer.new()
var button := Button.new()
var footer_button := Button.new()
root.add_child(page)
page.add_child(scroll)
scroll.add_child(content)
content.add_child(button)
page.add_child(footer_button)
var target: ScrollContainer = game_ui._focused_scroll_container(button)
if target != scroll:
game_ui.free()
page.free()
return "focused menu content did not resolve its scroll container"
target = game_ui._focused_scroll_container(footer_button)
if target != scroll:
game_ui.free()
page.free()
return "menu footer focus did not resolve the page's only scroll container"
scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
if game_ui._focused_scroll_container(button) != null:
game_ui.free()
page.free()
return "controller scrolling ignored a disabled scroll container"
game_ui.free()
page.free()
return ""
func _keyboard_event_count(action: StringName) -> int:
var count: int = 0
for event: InputEvent in InputMap.action_get_events(action):

View file

@ -7,6 +7,7 @@ const ControllerMappingManagerType = preload(
"res://settings/controller_mapping_manager.gd"
)
const UtilityPageStyleType = preload("res://ui/utility_page_style.gd")
const CAPTURE_NEUTRAL_SECONDS: float = 0.22
var _mapping_manager: ControllerMappingManagerType
var _binding_buttons: Dictionary = {}
@ -20,10 +21,14 @@ var _capturing_role: StringName = &""
var _auto_map_active: bool = false
var _auto_map_index: int = -1
var _auto_map_draft: Dictionary = {}
var _capture_device_id: int = 0
var _waiting_for_neutral: bool = false
var _neutral_elapsed: float = 0.0
func _ready() -> void:
set_process_input(true)
set_process(true)
_build_interface()
hide()
@ -40,6 +45,12 @@ func setup(mapping_manager: ControllerMappingManagerType) -> void:
_refresh_bindings
):
_mapping_manager.active_profile_changed.connect(_refresh_bindings)
if not _mapping_manager.controller_input_observed.is_connected(
_on_controller_input_observed
):
_mapping_manager.controller_input_observed.connect(
_on_controller_input_observed
)
_refresh_bindings()
@ -91,11 +102,61 @@ func _input(event: InputEvent) -> void:
_cancel_capture()
_progress_label.text = "controller mapping cancelled"
return
if not (
event is InputEventJoypadButton
or event is InputEventJoypadMotion
func _process(delta: float) -> void:
if (
not visible
or _mapping_manager == null
or _capturing_role.is_empty()
):
return
if _waiting_for_neutral:
if not _mapping_manager.are_capture_inputs_neutral(_capture_device_id):
_neutral_elapsed = 0.0
return
_neutral_elapsed += delta
if _neutral_elapsed < CAPTURE_NEUTRAL_SECONDS:
return
_waiting_for_neutral = false
_neutral_elapsed = 0.0
_refresh_capture_prompt()
return
var pressed_button: int = _mapping_manager.get_pressed_capture_button(
_capture_device_id
)
if pressed_button < 0:
return
var button_event := InputEventJoypadButton.new()
button_event.device = _capture_device_id
button_event.button_index = pressed_button as JoyButton
button_event.pressed = true
_try_capture_event(button_event)
func _on_controller_input_observed(event: InputEvent) -> void:
if not visible or _mapping_manager == null:
return
if _capturing_role.is_empty():
return
get_viewport().set_input_as_handled()
if _waiting_for_neutral:
return
var button_event := event as InputEventJoypadButton
var motion_event := event as InputEventJoypadMotion
var event_device: int = -1
if button_event != null:
event_device = button_event.device
elif motion_event != null:
event_device = motion_event.device
else:
return
if event_device != _capture_device_id:
return
_try_capture_event(event)
func _try_capture_event(event: InputEvent) -> void:
var binding: Dictionary = _mapping_manager.binding_from_event(
_capturing_role,
event,
@ -104,7 +165,6 @@ func _input(event: InputEvent) -> void:
return
if not _mapping_manager.validate_binding(_capturing_role, binding):
return
get_viewport().set_input_as_handled()
_accept_captured_binding(binding)
@ -258,6 +318,7 @@ func _begin_auto_map() -> void:
_auto_map_active = true
_auto_map_index = 0
_auto_map_draft = {}
_capture_device_id = _mapping_manager.get_active_device_id()
_set_capture_role(ControllerMappingManagerType.ROLE_ORDER[_auto_map_index])
@ -265,27 +326,36 @@ func _begin_manual_capture(role: StringName) -> void:
_auto_map_active = false
_auto_map_index = -1
_auto_map_draft.clear()
_capture_device_id = _mapping_manager.get_active_device_id()
_set_capture_role(role)
func _set_capture_role(role: StringName) -> void:
_capturing_role = role
_waiting_for_neutral = true
_neutral_elapsed = 0.0
_set_action_buttons_disabled(true)
_progress_label.text = "release all controller inputs"
func _refresh_capture_prompt() -> void:
if _capturing_role.is_empty():
return
if _auto_map_active:
_progress_label.text = "step %d of %d: %s" % [
_auto_map_index + 1,
ControllerMappingManagerType.ROLE_ORDER.size(),
_mapping_manager.get_role_prompt(role),
_mapping_manager.get_role_prompt(_capturing_role),
]
return
var input_instruction: String = "press any controller button"
if _mapping_manager.role_expects_axis(role):
if _mapping_manager.role_expects_axis(_capturing_role):
input_instruction = "move any controller axis"
elif _mapping_manager.role_accepts_axis(role):
elif _mapping_manager.role_accepts_axis(_capturing_role):
input_instruction = "press any button or move any controller axis"
_progress_label.text = "%s for %s" % [
input_instruction,
_mapping_manager.get_role_label(role),
_mapping_manager.get_role_label(_capturing_role),
]
@ -322,6 +392,8 @@ func _cancel_capture() -> void:
_auto_map_active = false
_auto_map_index = -1
_auto_map_draft.clear()
_waiting_for_neutral = false
_neutral_elapsed = 0.0
_set_action_buttons_disabled(false)
if _progress_label != null:
_progress_label.text = ""

View file

@ -70,6 +70,8 @@ const VIRTUAL_MOUSE_TRIGGER_THRESHOLD: float = 0.55
const VIRTUAL_MOUSE_TRIGGER_RELEASE_THRESHOLD: float = 0.35
const VIRTUAL_MOUSE_STICK_DEADZONE: float = 0.18
const VIRTUAL_MOUSE_SPEED: float = 720.0
const CONTROLLER_MENU_SCROLL_DEADZONE: float = 0.25
const CONTROLLER_MENU_SCROLL_SPEED: float = 660.0
# Android controller mappings may expose LT on its own axis or as the negative
# half of the same signed axis used by RT. Support both without letting the RT
# zoom direction enter virtual-pointer mode.
@ -998,6 +1000,7 @@ func is_controller_mapping_capturing() -> bool:
func _process(delta: float) -> void:
_poll_virtual_mouse_controller_state()
_update_virtual_mouse(delta)
_update_controller_menu_scroll(delta)
_update_experience_bubble_position()
if _item_effects == null or not _gameplay_ui_enabled:
_effect_status.hide()
@ -1026,6 +1029,85 @@ func _process(delta: float) -> void:
_effect_status.visible = not parts.is_empty()
func _update_controller_menu_scroll(delta: float) -> void:
if (
_controller_mapping_manager == null
or _virtual_mouse_active
or is_controller_mapping_capturing()
):
return
var stick_y: float = _controller_menu_scroll_axis()
var magnitude: float = absf(stick_y)
if magnitude <= CONTROLLER_MENU_SCROLL_DEADZONE:
return
var focus_owner: Control = get_viewport().gui_get_focus_owner()
var scroll: ScrollContainer = _focused_scroll_container(focus_owner)
if scroll == null:
return
var adjusted_axis: float = (
signf(stick_y)
* (magnitude - CONTROLLER_MENU_SCROLL_DEADZONE)
/ (1.0 - CONTROLLER_MENU_SCROLL_DEADZONE)
)
scroll.scroll_vertical += roundi(
adjusted_axis * CONTROLLER_MENU_SCROLL_SPEED * delta
)
func _focused_scroll_container(focus_owner: Control) -> ScrollContainer:
if focus_owner == null or not focus_owner.is_visible_in_tree():
return null
var current: Node = focus_owner
while current != null:
var scroll := current as ScrollContainer
if scroll != null:
if _controller_scroll_container_is_available(scroll):
return scroll
return null
var candidates: Array[ScrollContainer] = []
_collect_controller_scroll_containers(current, candidates)
if candidates.size() == 1:
return candidates[0]
if candidates.size() > 1:
return null
current = current.get_parent()
return null
func _collect_controller_scroll_containers(
root_node: Node,
result: Array[ScrollContainer],
) -> void:
for child: Node in root_node.get_children():
var canvas_item := child as CanvasItem
if canvas_item != null and not canvas_item.is_visible_in_tree():
continue
var scroll := child as ScrollContainer
if scroll != null and _controller_scroll_container_is_available(scroll):
result.append(scroll)
_collect_controller_scroll_containers(child, result)
func _controller_scroll_container_is_available(
scroll: ScrollContainer,
) -> bool:
return (
scroll.is_visible_in_tree()
and scroll.vertical_scroll_mode != ScrollContainer.SCROLL_MODE_DISABLED
)
func _controller_menu_scroll_axis() -> float:
if _controller_mapping_manager.has_custom_mapping():
return _controller_mapping_manager.get_role_axis(
ControllerMappingManagerType.ROLE_RIGHT_STICK_Y
)
return Input.get_joy_axis(
_controller_mapping_manager.get_active_device_id(),
JOY_AXIS_RIGHT_Y,
)
func close_player_menu() -> void:
_player_menu.close_menu()