diff --git a/settings/controller_mapping_manager.gd b/settings/controller_mapping_manager.gd index f352888..dad32b4 100644 --- a/settings/controller_mapping_manager.gd +++ b/settings/controller_mapping_manager.gd @@ -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 diff --git a/tests/controller_mapping_validation.gd b/tests/controller_mapping_validation.gd index 280c2c7..e155a71 100644 --- a/tests/controller_mapping_validation.gd +++ b/tests/controller_mapping_validation.gd @@ -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): diff --git a/ui/controller_mapping_panel.gd b/ui/controller_mapping_panel.gd index 538a4aa..8089d2a 100644 --- a/ui/controller_mapping_panel.gd +++ b/ui/controller_mapping_panel.gd @@ -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 = "" diff --git a/ui/game_ui.gd b/ui/game_ui.gd index a7e7906..b4e8518 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -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()