Improve Android controller and chat support

This commit is contained in:
Alexander Sellite 2026-08-03 20:49:19 -04:00
parent 3dc8ced5d2
commit ceef03eba5
24 changed files with 2485 additions and 128 deletions

View file

@ -1,5 +1,8 @@
extends SceneTree
const GameUIType = preload("res://ui/game_ui.gd")
const PlayerType = preload("res://player/player.gd")
func _init() -> void:
assert(
@ -34,11 +37,50 @@ func _init() -> void:
))
assert(_has_joypad_button(&"hotbar_previous", JOY_BUTTON_DPAD_LEFT))
assert(_has_joypad_button(&"hotbar_next", JOY_BUTTON_DPAD_RIGHT))
assert(_has_joypad_button(&"open_backpack", JOY_BUTTON_BACK))
assert(_has_joypad_button(&"open_backpack", JOY_BUTTON_X))
assert(_has_joypad_button(&"open_system_menu", JOY_BUTTON_START))
assert(_has_joypad_button(&"open_emotes", JOY_BUTTON_DPAD_UP))
assert(_has_joypad_button(&"open_quick_actions", JOY_BUTTON_DPAD_DOWN))
assert(_has_joypad_button(&"open_chat", JOY_BUTTON_LEFT_SHOULDER))
assert(_has_joypad_button(&"open_chat", JOY_BUTTON_BACK))
assert(_has_joypad_button(&"focus_gameplay", JOY_BUTTON_LEFT_SHOULDER))
assert(
GameUIType.VIRTUAL_MOUSE_TRIGGER_AXIS == JOY_AXIS_TRIGGER_RIGHT
)
assert(
GameUIType.VIRTUAL_MOUSE_SHARED_TRIGGER_AXIS
== JOY_AXIS_TRIGGER_LEFT
)
assert(
GameUIType.VIRTUAL_MOUSE_SECONDARY_CLICK_AXIS
== JOY_AXIS_TRIGGER_LEFT
)
assert(PlayerType.CONTROLLER_ZOOM_TRIGGER_AXIS == JOY_AXIS_TRIGGER_LEFT)
assert(is_zero_approx(
GameUIType.normalized_trigger_strength(-1.0, -1.0)
))
assert(is_equal_approx(
GameUIType.normalized_trigger_strength(1.0, -1.0),
1.0,
))
assert(is_equal_approx(
GameUIType.normalized_trigger_strength(-1.0, 0.0),
1.0,
))
assert(is_equal_approx(
GameUIType.normalized_trigger_strength(-1.0, 1.0),
1.0,
))
assert(is_equal_approx(
GameUIType.directional_trigger_strength(-1.0, 0.0, -1.0),
1.0,
))
assert(is_zero_approx(
GameUIType.directional_trigger_strength(1.0, 0.0, -1.0)
))
assert(is_equal_approx(
GameUIType.directional_trigger_strength(1.0, 0.0, 1.0),
1.0,
))
print("android readiness validation passed")
quit()

View file

@ -37,17 +37,33 @@ func _run() -> void:
var chat_ui := game_ui.get_node("%ChatUI") as ChatUI
assert(player != null and service != null and toolbar != null)
assert(chat_ui != null)
assert(bool(game_ui.call("_can_start_virtual_mouse")))
game_ui.call("_begin_virtual_mouse", 0)
var virtual_cursor := game_ui.get_node(
"%ControllerVirtualCursor"
) as ControllerVirtualCursor
assert(virtual_cursor.visible)
assert(not player.is_camera_input_enabled())
game_ui.call("_end_virtual_mouse")
assert(not virtual_cursor.visible)
assert(player.is_camera_input_enabled())
var settings_panel := game_ui.get(
"_pause_settings_panel"
) as SettingsPanel
var chat_dock_button := settings_panel.get_node("%ChatDock") as BubbleButton
var chat_mode_button := settings_panel.get_node("%ChatMode") as BubbleButton
var paint_dock_button := settings_panel.get_node("%PaintDock") as BubbleButton
assert(chat_dock_button.neutral_size.x == chat_dock_button.neutral_size.y)
assert(chat_mode_button.neutral_size.x == chat_mode_button.neutral_size.y)
assert(paint_dock_button.neutral_size.x == paint_dock_button.neutral_size.y)
assert(
chat_dock_button.compact_minimum_size.x
== chat_dock_button.compact_minimum_size.y
)
assert(
chat_mode_button.compact_minimum_size.x
== chat_mode_button.compact_minimum_size.y
)
assert(
paint_dock_button.compact_minimum_size.x
== paint_dock_button.compact_minimum_size.y
@ -60,24 +76,85 @@ func _run() -> void:
assert(settings_manager != null)
var changed_docks: PlayerSettings = settings_manager.current_settings.copy()
changed_docks.chat_dock_right = true
changed_docks.chat_mobile_mode = true
changed_docks.paint_dock_right = false
assert(settings_manager.apply_settings(changed_docks))
await process_frame
assert(chat_ui.is_docked_right())
assert(chat_ui.is_mobile_mode())
assert(not toolbar.is_docked_right())
var chat_panel := chat_ui.get_node("ChatPanel") as PanelContainer
var chat_height_button := chat_ui.get_node("ChatHeightButton") as Button
assert(is_zero_approx(chat_panel.position.y))
assert(is_equal_approx(chat_panel.size.x, ChatUI.MOBILE_COMPACT_WIDTH))
assert(not chat_height_button.visible)
var reloaded_settings := PlayerSettingsManager.new()
root.add_child(reloaded_settings)
assert(reloaded_settings.load_settings())
assert(reloaded_settings.current_settings.chat_dock_right)
assert(reloaded_settings.current_settings.chat_mobile_mode)
assert(not reloaded_settings.current_settings.paint_dock_right)
reloaded_settings.queue_free()
var default_docks: PlayerSettings = settings_manager.current_settings.copy()
default_docks.chat_dock_right = false
default_docks.chat_mobile_mode = false
default_docks.paint_dock_right = true
assert(settings_manager.apply_settings(default_docks))
await process_frame
assert(not chat_ui.is_docked_right())
assert(not chat_ui.is_mobile_mode())
assert(toolbar.is_docked_right())
assert(chat_height_button.visible)
var select_button := InputEventJoypadButton.new()
select_button.button_index = JOY_BUTTON_BACK
select_button.pressed = true
assert(bool(game_ui.call("_handle_controller_chat_controls", select_button)))
assert(chat_ui.is_open())
assert(chat_panel.mouse_filter == Control.MOUSE_FILTER_STOP)
var typed_chat_entry := chat_ui.find_child(
"ChatEntry", true, false
) as LineEdit
assert(typed_chat_entry != null)
assert(not typed_chat_entry.virtual_keyboard_enabled)
var accept_button := InputEventJoypadButton.new()
accept_button.button_index = JOY_BUTTON_A
accept_button.pressed = true
assert(bool(game_ui.call("_handle_controller_chat_controls", accept_button)))
assert(typed_chat_entry.virtual_keyboard_enabled)
var left_bumper := InputEventJoypadButton.new()
left_bumper.button_index = JOY_BUTTON_LEFT_SHOULDER
left_bumper.pressed = true
assert(bool(game_ui.call("_handle_controller_chat_controls", left_bumper)))
assert(not chat_ui.is_open())
assert(chat_panel.mouse_filter == Control.MOUSE_FILTER_IGNORE)
assert(not typed_chat_entry.virtual_keyboard_enabled)
assert(bool(game_ui.call("_handle_controller_chat_controls", left_bumper)))
assert(chat_ui.is_open())
assert(not typed_chat_entry.virtual_keyboard_enabled)
assert(bool(game_ui.call("_handle_controller_chat_controls", left_bumper)))
assert(not chat_ui.is_open())
assert(bool(game_ui.call("_handle_controller_chat_controls", select_button)))
assert(not chat_ui.is_open())
assert(chat_ui.is_collapsed())
assert(bool(game_ui.call("_handle_controller_chat_controls", select_button)))
assert(chat_ui.is_open())
chat_ui.refocus_gameplay()
var cancel_button := InputEventJoypadButton.new()
cancel_button.button_index = JOY_BUTTON_B
cancel_button.pressed = true
assert(not bool(main.call("_is_pause_open_request", cancel_button)))
var start_button := InputEventJoypadButton.new()
start_button.button_index = JOY_BUTTON_START
start_button.pressed = true
assert(bool(main.call("_is_pause_open_request", start_button)))
var player_menu := game_ui.get_node("%PlayerMenu") as PlayerMenu
player_menu.open_section(PlayerMenu.Section.PROFILE)
for _frame: int in 12:
await process_frame
assert(root.gui_get_focus_owner() is not LineEdit)
player_menu.close_menu()
for _frame: int in 12:
await process_frame
assert(not service.can_activate())
assert(not service.is_active() and not toolbar.visible)
assert(player.bag.add_item(ArtShopStock.ART_KIT_ITEM_ID, 1))

View file

@ -0,0 +1,157 @@
extends SceneTree
const ControllerMappingManagerType = preload(
"res://settings/controller_mapping_manager.gd"
)
const ControllerMappingPanelType = preload(
"res://ui/controller_mapping_panel.gd"
)
const GameUIType = preload("res://ui/game_ui.gd")
func _init() -> void:
call_deferred("_run")
func _run() -> void:
var manager := ControllerMappingManagerType.new()
root.add_child(manager)
await process_frame
var failure: String = _validate_manager(manager)
if failure.is_empty():
failure = _validate_auto_map(manager)
if failure.is_empty():
failure = _validate_virtual_mouse_bounds()
if failure.is_empty():
print("controller mapping validation passed")
quit(0)
return
push_error(failure)
quit(1)
func _validate_manager(manager: ControllerMappingManagerType) -> String:
var defaults: Dictionary = manager.get_active_bindings()
if defaults.size() != ControllerMappingManagerType.ROLE_ORDER.size():
return "default mapping covers %d of %d controller roles: %s" % [
defaults.size(),
ControllerMappingManagerType.ROLE_ORDER.size(),
str(defaults.keys()),
]
var trigger_button := InputEventJoypadButton.new()
trigger_button.button_index = JOY_BUTTON_MISC1
trigger_button.pressed = true
var trigger_binding: Dictionary = manager.binding_from_event(
ControllerMappingManagerType.ROLE_LT,
trigger_button,
)
if str(trigger_binding.get("kind", "")) != "button":
return "trigger role did not accept a button-backed handheld trigger"
var trigger_motion := InputEventJoypadMotion.new()
trigger_motion.axis = JOY_AXIS_TRIGGER_RIGHT
trigger_motion.axis_value = 1.0
trigger_binding = manager.binding_from_event(
ControllerMappingManagerType.ROLE_LT,
trigger_motion,
)
if str(trigger_binding.get("kind", "")) != "axis":
return "trigger role did not accept an axis-backed controller trigger"
var stick_button := manager.binding_from_event(
ControllerMappingManagerType.ROLE_RIGHT_STICK_X,
trigger_button,
)
if not stick_button.is_empty():
return "stick axis role accepted a button"
var keyboard_events_before: int = _keyboard_event_count(&"jump")
var custom: Dictionary = defaults.duplicate(true)
custom[str(ControllerMappingManagerType.ROLE_A)] = {
"kind": "button",
"button": int(JOY_BUTTON_X),
}
custom[str(ControllerMappingManagerType.ROLE_LT)] = trigger_binding
if not manager.replace_active_bindings(custom):
return "valid custom mapping could not be saved"
if not manager.has_custom_mapping():
return "saved custom mapping did not become active"
if _keyboard_event_count(&"jump") != keyboard_events_before:
return "controller remapping changed keyboard bindings"
if not FileAccess.file_exists(
ControllerMappingManagerType.PROFILE_PATH
):
return "controller mapping was not stored device-locally"
return ""
func _validate_auto_map(manager: ControllerMappingManagerType) -> String:
var panel := ControllerMappingPanelType.new()
root.add_child(panel)
panel.setup(manager)
panel.open_panel()
panel._begin_auto_map()
for role: StringName in ControllerMappingManagerType.ROLE_ORDER:
var binding := (
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.button_index = int(binding.get("button", -1))
button.pressed = true
panel._input(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)
if panel._auto_map_active:
return "auto-map did not complete after every requested input"
if not manager.has_custom_mapping():
return "completed auto-map did not activate its profile"
if panel._binding_buttons.size() != (
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"
panel._begin_manual_capture(ControllerMappingManagerType.ROLE_LT)
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()
return ""
func _validate_virtual_mouse_bounds() -> String:
var window_size := Vector2(3840.0, 2160.0)
var bounds: Rect2 = GameUIType.get_virtual_mouse_window_bounds(window_size)
var expected_margin := Vector2(42.0, 42.0)
if not bounds.position.is_equal_approx(expected_margin):
return "4K virtual cursor minimum bounds are incorrect: %s" % bounds
if not bounds.end.is_equal_approx(window_size - expected_margin):
return "4K virtual cursor maximum bounds are incorrect: %s" % bounds
return ""
func _keyboard_event_count(action: StringName) -> int:
var count: int = 0
for event: InputEvent in InputMap.action_get_events(action):
if event is InputEventKey or event is InputEventMouseButton:
count += 1
return count

View file

@ -0,0 +1 @@
uid://dhwkjojmpjc13