From 5daf97967a450d4cfac78af7f5c74b04ea89c033 Mon Sep 17 00:00:00 2001 From: Voyager Date: Sun, 16 Aug 2026 17:57:04 -0400 Subject: [PATCH] Reject conflicting controller bindings --- settings/controller_mapping_manager.gd | 51 +++++++++++++++++++- tests/controller_mapping_validation.gd | 65 +++++++++++++++++--------- ui/controller_mapping_panel.gd | 22 ++------- 3 files changed, 98 insertions(+), 40 deletions(-) diff --git a/settings/controller_mapping_manager.gd b/settings/controller_mapping_manager.gd index 17e8643..8c17e7f 100644 --- a/settings/controller_mapping_manager.gd +++ b/settings/controller_mapping_manager.gd @@ -282,7 +282,7 @@ func _validated_profiles(raw_profiles: Dictionary) -> Dictionary: ): complete = false break - if not complete: + if not complete or bindings_have_conflicts(bindings): continue validated[str(key_value)] = { "controller_name": str( @@ -402,6 +402,8 @@ func replace_active_bindings(bindings: Dictionary) -> bool: return false if not validate_binding(role, binding as Dictionary): return false + if bindings_have_conflicts(bindings): + return false var previous: Dictionary = _profiles.duplicate(true) _profiles[_active_profile_key] = { "controller_name": _active_controller_name, @@ -484,6 +486,53 @@ func validate_binding(role: StringName, binding: Dictionary) -> bool: ) +static func bindings_have_conflicts(bindings: Dictionary) -> bool: + for role_index: int in ROLE_ORDER.size(): + var first_role: StringName = ROLE_ORDER[role_index] + var first_value: Variant = bindings.get(str(first_role), {}) + if typeof(first_value) != TYPE_DICTIONARY: + continue + for other_index: int in range(role_index + 1, ROLE_ORDER.size()): + var second_role: StringName = ROLE_ORDER[other_index] + var second_value: Variant = bindings.get(str(second_role), {}) + if typeof(second_value) != TYPE_DICTIONARY: + continue + if bindings_conflict( + first_role, + first_value as Dictionary, + second_role, + second_value as Dictionary, + ): + return true + return false + + +static func bindings_conflict( + first_role: StringName, + first: Dictionary, + second_role: StringName, + second: Dictionary, +) -> bool: + var binding_kind: String = str(first.get("kind", "")) + if binding_kind != str(second.get("kind", "")): + return false + if binding_kind == "button": + return int(first.get("button", -1)) == int( + second.get("button", -1) + ) + if binding_kind != "axis": + return false + if int(first.get("axis", -1)) != int(second.get("axis", -1)): + return false + var opposite_trigger_halves: bool = ( + first_role in TRIGGER_ROLES + and second_role in TRIGGER_ROLES + and signf(float(first.get("direction", 0.0))) + != signf(float(second.get("direction", 0.0))) + ) + return not opposite_trigger_halves + + func binding_label(binding: Dictionary) -> String: if str(binding.get("kind", "")) == "button": return _button_label(int(binding.get("button", -1))) diff --git a/tests/controller_mapping_validation.gd b/tests/controller_mapping_validation.gd index 2a6383a..b93d475 100644 --- a/tests/controller_mapping_validation.gd +++ b/tests/controller_mapping_validation.gd @@ -152,6 +152,10 @@ func _validate_manager(manager: ControllerMappingManagerType) -> String: "kind": "button", "button": int(JOY_BUTTON_X), } + custom[str(ControllerMappingManagerType.ROLE_X)] = { + "kind": "button", + "button": int(JOY_BUTTON_A), + } custom[str(ControllerMappingManagerType.ROLE_POINTER_MODIFIER)] = ( trigger_binding ) @@ -159,6 +163,41 @@ func _validate_manager(manager: ControllerMappingManagerType) -> String: return "valid custom mapping could not be saved" if not manager.has_custom_mapping(): return "saved custom mapping did not become active" + var duplicate: Dictionary = custom.duplicate(true) + duplicate[str(ControllerMappingManagerType.ROLE_X)] = ( + duplicate[str(ControllerMappingManagerType.ROLE_A)] as Dictionary + ).duplicate(true) + if manager.replace_active_bindings(duplicate): + return "duplicate controller buttons were accepted" + var shared_trigger_axis: Dictionary = custom.duplicate(true) + shared_trigger_axis[ + str(ControllerMappingManagerType.ROLE_POINTER_MODIFIER) + ] = { + "kind": "axis", + "axis": int(JOY_AXIS_TRIGGER_LEFT), + "direction": 1.0, + "rest": 0.0, + } + shared_trigger_axis[ + str(ControllerMappingManagerType.ROLE_CAMERA_ZOOM) + ] = { + "kind": "axis", + "axis": int(JOY_AXIS_TRIGGER_LEFT), + "direction": -1.0, + "rest": 0.0, + } + if ControllerMappingManagerType.bindings_have_conflicts( + shared_trigger_axis + ): + return "opposite halves of a shared trigger axis conflict" + var malformed_profiles: Dictionary = manager._validated_profiles({ + "name:controller": { + "controller_name": "controller", + "bindings": duplicate, + }, + }) + if not malformed_profiles.is_empty(): + return "a stored controller profile with duplicate buttons was accepted" if _keyboard_event_count(&"jump") != keyboard_events_before: return "controller remapping changed keyboard bindings" var active_button := InputEventJoypadButton.new() @@ -309,12 +348,6 @@ func _validate_auto_map(manager: ControllerMappingManagerType) -> String: ControllerMappingManagerType.default_bindings()[str(role)] as Dictionary ) - if role == ControllerMappingManagerType.ROLE_B: - binding = ( - ControllerMappingManagerType.default_bindings()[ - str(ControllerMappingManagerType.ROLE_A) - ] as Dictionary - ) if str(binding.get("kind", "")) == "button": var button := InputEventJoypadButton.new() button.device = manager.get_active_device_id() @@ -335,24 +368,12 @@ func _validate_auto_map(manager: ControllerMappingManagerType) -> String: ControllerMappingManagerType.ROLE_ORDER.size() ): return "manual override list does not expose every mapped role" - var conflict_button := panel._binding_buttons.get( - ControllerMappingManagerType.ROLE_A - ) as Button - var conflict_style := conflict_button.get_theme_stylebox( - &"normal" - ) as StyleBoxFlat - if ( - conflict_style == null - or conflict_style.bg_color - != UtilityPageStyle.OCEAN_DANGER - ): - return "duplicate controller bindings were not marked in red" - manager.set_binding( + var duplicate_saved: bool = manager.set_binding( ControllerMappingManagerType.ROLE_B, - ControllerMappingManagerType.default_bindings()[ - str(ControllerMappingManagerType.ROLE_B) - ] as Dictionary, + manager.get_binding(ControllerMappingManagerType.ROLE_A), ) + if duplicate_saved: + return "manual mapping accepted a controller button already in use" panel._begin_manual_capture( ControllerMappingManagerType.ROLE_POINTER_MODIFIER ) diff --git a/ui/controller_mapping_panel.gd b/ui/controller_mapping_panel.gd index 1312019..16080a7 100644 --- a/ui/controller_mapping_panel.gd +++ b/ui/controller_mapping_panel.gd @@ -580,24 +580,12 @@ func _bindings_conflict( second_role: StringName, second: Dictionary, ) -> bool: - var binding_kind: String = str(first.get("kind", "")) - if binding_kind != str(second.get("kind", "")): - return false - if binding_kind == "button": - return int(first.get("button", -1)) == int( - second.get("button", -1) - ) - if binding_kind != "axis": - return false - if int(first.get("axis", -1)) != int(second.get("axis", -1)): - return false - var opposite_trigger_halves: bool = ( - first_role in ControllerMappingManagerType.TRIGGER_ROLES - and second_role in ControllerMappingManagerType.TRIGGER_ROLES - and signf(float(first.get("direction", 0.0))) - != signf(float(second.get("direction", 0.0))) + return ControllerMappingManagerType.bindings_conflict( + first_role, + first, + second_role, + second, ) - return not opposite_trigger_halves func _apply_binding_button_style(button: Button, has_conflict: bool) -> void: