diff --git a/export_presets.cfg b/export_presets.cfg index 5f595cd..370bbc2 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -9,7 +9,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="builds/*,playtest/*,scripts/*" -export_path="builds/v0.4.0-prealpha/windows-x86_64/NETfishing.exe" +export_path="builds/v0.4.1-prealpha/windows-x86_64/NETfishing.exe" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -35,11 +35,11 @@ application/modify_resources=true application/icon="" application/console_wrapper_icon="" application/icon_interpolation=4 -application/file_version="0.4.0.0" -application/product_version="0.4.0.0" +application/file_version="0.4.1.0" +application/product_version="0.4.1.0" application/company_name="" application/product_name="NETfishing" -application/file_description="NETfishing v0.4.0-prealpha" +application/file_description="NETfishing v0.4.1-prealpha" application/copyright="" application/trademarks="" application/export_angle=0 @@ -83,8 +83,8 @@ architectures/armeabi-v7a=false architectures/arm64-v8a=true architectures/x86=false architectures/x86_64=false -version/code=40000 -version/name="0.4.0-prealpha" +version/code=40100 +version/name="0.4.1-prealpha" package/unique_name="io.makearmy.netfishing" package/name="NETfishing" package/signed=true @@ -121,7 +121,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="builds/*,playtest/*,scripts/*" -export_path="builds/v0.4.0-prealpha/linux-x86_64/NETfishing.x86_64" +export_path="builds/v0.4.1-prealpha/linux-x86_64/NETfishing.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" diff --git a/main/main.gd b/main/main.gd index 059d0b1..343435c 100644 --- a/main/main.gd +++ b/main/main.gd @@ -16,6 +16,9 @@ const PlayerSettingsManagerType = preload( "res://settings/player_settings_manager.gd" ) const PlayerSettingsType = preload("res://settings/player_settings.gd") +const ControllerMappingManagerType = preload( + "res://settings/controller_mapping_manager.gd" +) const TitleScreenType = preload("res://ui/title_screen.gd") const PauseMenuType = preload("res://ui/pause_menu.gd") const ItemCatalogType = preload("res://items/item_catalog.gd") @@ -111,6 +114,9 @@ const SHOP_PATTERN_SCALE: float = 1.75 @onready var _water_recovery: WaterRecoveryControllerType = %WaterRecovery @onready var _save_manager: PlayerSaveManagerType = %PlayerSaveManager @onready var _settings_manager: PlayerSettingsManagerType = %PlayerSettingsManager +@onready var _controller_mapping_manager: ControllerMappingManagerType = ( + %ControllerMappingManager +) @onready var _interface_fonts: InterfaceFontController = %InterfaceFontController @onready var _title_music: AudioStreamPlayer = %TitleMusic @onready var _ui_pixelation: UIPixelationPresenterType = %UIPresentation @@ -534,6 +540,7 @@ func _initialize_after_data_root() -> void: _network_session, _interface_fonts, ) + _game_ui.setup_controller_mapping(_controller_mapping_manager) _data_root.conflict_detected.connect(_on_portable_conflict) _data_root.status_changed.connect(_on_data_root_status) if ( @@ -927,6 +934,8 @@ func _on_peer_identity_observed(_peer_id: int, status: String) -> void: func _input(event: InputEvent) -> void: + if _game_ui.is_controller_mapping_capturing(): + return if ( not ( event.is_action_pressed("ui_cancel") @@ -935,6 +944,7 @@ func _input(event: InputEvent) -> void: or (event is InputEventKey and event.echo) ): return + var pause_open_requested: bool = _is_pause_open_request(event) var title_screen: TitleScreenType = _game_ui.get_title_screen() if title_screen.visible or not _gameplay_started: return @@ -953,7 +963,8 @@ func _input(event: InputEvent) -> void: get_viewport().set_input_as_handled() return if ( - not _water_recovery.is_recovery_active() + pause_open_requested + and not _water_recovery.is_recovery_active() and _fishing_spot.can_open_system_menu() ): _game_ui.close_player_menu_for_game_menu() @@ -961,6 +972,13 @@ func _input(event: InputEvent) -> void: get_viewport().set_input_as_handled() +func _is_pause_open_request(event: InputEvent) -> bool: + return ( + event.is_action_pressed("open_system_menu") + or event is InputEventKey + ) + + func _unhandled_input(event: InputEvent) -> void: if ( not _gameplay_started @@ -1002,6 +1020,7 @@ func _apply_runtime_settings(settings: PlayerSettingsType) -> void: _game_ui.set_edge_docks( settings.chat_dock_right, settings.paint_dock_right, + settings.chat_mobile_mode, ) diff --git a/main/main.tscn b/main/main.tscn index 8f66df1..64247ca 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -55,6 +55,7 @@ [ext_resource type="Script" path="res://network/network_job_service.gd" id="53_network_jobs"] [ext_resource type="Script" path="res://world/shoreline_ambience.gd" id="54_shoreline_ambience"] [ext_resource type="AudioStream" path="res://audio/ambience/waves.wav" id="55_waves"] +[ext_resource type="Script" path="res://settings/controller_mapping_manager.gd" id="56_controller_mapping"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_title_water_native"] shader = ExtResource("40_title_water") @@ -273,6 +274,10 @@ script = ExtResource("9_save") unique_name_in_owner = true script = ExtResource("10_settings") +[node name="ControllerMappingManager" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("56_controller_mapping") + [node name="TitleMusic" type="AudioStreamPlayer" parent="." unique_id=1292939956] unique_name_in_owner = true stream = ExtResource("13_title_music") diff --git a/player/player.gd b/player/player.gd index 99a8e65..e5a5076 100644 --- a/player/player.gd +++ b/player/player.gd @@ -24,6 +24,9 @@ const PlayerArtUnlocksType = preload( const PlayerExperienceType = preload( "res://progression/player_experience.gd" ) +const ControllerMappingManagerType = preload( + "res://settings/controller_mapping_manager.gd" +) const FishingRodAttachmentScene = preload( "res://player/fishing_rod_attachment.tscn" ) @@ -32,6 +35,10 @@ const CHARACTER_IDLE_ANIMATION: StringName = &"idle" const CHARACTER_WALKING_ANIMATION: StringName = &"walking" const CHARACTER_SITTING_ANIMATION: StringName = &"sitting" const BASE_REEL_SPEED: float = 0.16 +# The target Android handheld exposes its physical right trigger through +# Godot's left-trigger axis. Keep the role named here so the platform mapping +# remains isolated from camera behavior. +const CONTROLLER_ZOOM_TRIGGER_AXIS: JoyAxis = JOY_AXIS_TRIGGER_LEFT var appearance_snapshot: Dictionary = ( CharacterCustomizationCatalog.default_snapshot() @@ -160,6 +167,7 @@ var _character_animation_name: StringName = &"" var _sitting: bool = false var _fishing_rod: Node3D var _fishing_rod_tip: Marker3D +var _controller_mapping_manager: ControllerMappingManagerType func _ready() -> void: @@ -169,6 +177,12 @@ func _ready() -> void: _camera.current = local_control_enabled +func set_controller_mapping_manager( + mapping_manager: ControllerMappingManagerType, +) -> void: + _controller_mapping_manager = mapping_manager + + func _initialize_fishing_rod() -> void: var skeleton := get_node_or_null( "Visuals/CharacterRig/CharacterRig/Skeleton3D" @@ -287,13 +301,10 @@ func _process(delta: float) -> void: _camera_dragging = false if _camera_input_enabled: - var stick: Vector2 = Vector2( - Input.get_joy_axis(0, JOY_AXIS_RIGHT_X), - Input.get_joy_axis(0, JOY_AXIS_RIGHT_Y) - ) + var stick: Vector2 = _get_controller_camera_stick() if stick.length() > controller_camera_deadzone: if ( - Input.get_joy_axis(0, JOY_AXIS_TRIGGER_RIGHT) + _get_controller_zoom_strength() >= controller_trigger_threshold ): var vertical_zoom_input: float = _apply_axis_deadzone(stick.y) @@ -321,6 +332,36 @@ func _process(delta: float) -> void: ) +func _get_controller_camera_stick() -> Vector2: + if ( + _controller_mapping_manager != null + and _controller_mapping_manager.has_custom_mapping() + ): + return Vector2( + _controller_mapping_manager.get_role_axis( + ControllerMappingManagerType.ROLE_RIGHT_STICK_X + ), + _controller_mapping_manager.get_role_axis( + ControllerMappingManagerType.ROLE_RIGHT_STICK_Y + ), + ) + return Vector2( + Input.get_joy_axis(0, JOY_AXIS_RIGHT_X), + Input.get_joy_axis(0, JOY_AXIS_RIGHT_Y), + ) + + +func _get_controller_zoom_strength() -> float: + if ( + _controller_mapping_manager != null + and _controller_mapping_manager.has_custom_mapping() + ): + return _controller_mapping_manager.get_role_strength( + ControllerMappingManagerType.ROLE_RT + ) + return Input.get_joy_axis(0, CONTROLLER_ZOOM_TRIGGER_AXIS) + + func _update_character_animation() -> void: if _character_animation_player == null: return diff --git a/playtest/README-PLAYTEST.txt b/playtest/README-PLAYTEST.txt index 48ee3fd..7bc45ca 100644 --- a/playtest/README-PLAYTEST.txt +++ b/playtest/README-PLAYTEST.txt @@ -1,6 +1,6 @@ NETfishing -v0.4.0-prealpha -Pre-Alpha 0.4.0 +v0.4.1-prealpha +Pre-Alpha 0.4.1 Thank you for trying this early private playtest. diff --git a/project.godot b/project.godot index 9ec4067..4721204 100644 --- a/project.godot +++ b/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="NETFISHING" -config/version="0.4.0-prealpha" +config/version="0.4.1-prealpha" run/main_scene="res://main/main.tscn" config/features=PackedStringArray("4.7", "GL Compatibility") config/icon="res://icon.svg" @@ -162,7 +162,7 @@ hotbar_next={ open_backpack={ "deadzone": 0.2, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194306,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) -, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":4,"pressure":0.0,"pressed":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":2,"pressure":0.0,"pressed":false,"script":null) ] } open_system_menu={ @@ -183,6 +183,11 @@ open_quick_actions={ } open_chat={ "deadzone": 0.2, +"events": [Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":4,"pressure":0.0,"pressed":false,"script":null) +] +} +focus_gameplay={ +"deadzone": 0.2, "events": [Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":9,"pressure":0.0,"pressed":false,"script":null) ] } diff --git a/scripts/build_playtest.sh b/scripts/build_playtest.sh index 2c168c8..5871383 100755 --- a/scripts/build_playtest.sh +++ b/scripts/build_playtest.sh @@ -4,12 +4,12 @@ set -euo pipefail readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" -readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.4.0-prealpha" +readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.4.1-prealpha" readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64" readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64" readonly README_SOURCE="${PROJECT_ROOT}/playtest/README-PLAYTEST.txt" -readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.4.0-prealpha-windows-x86_64.zip" -readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.4.0-prealpha-linux-x86_64.zip" +readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.4.1-prealpha-windows-x86_64.zip" +readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.4.1-prealpha-linux-x86_64.zip" readonly GODOT_BIN="${GODOT_BIN:-godot}" if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then diff --git a/settings/controller_mapping_manager.gd b/settings/controller_mapping_manager.gd new file mode 100644 index 0000000..f352888 --- /dev/null +++ b/settings/controller_mapping_manager.gd @@ -0,0 +1,751 @@ +class_name ControllerMappingManager +extends Node + +signal active_profile_changed +signal active_controller_changed(controller_name: String) + +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 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" +const ROLE_LT: StringName = &"lt" +const ROLE_RT: StringName = &"rt" +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_LT, + ROLE_RT, + 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_LT: "virtual mouse", + ROLE_RT: "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_LT: "squeeze the left trigger", + ROLE_RT: "squeeze the right trigger", + 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_LT, ROLE_RT] + +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 = {} + + +func _ready() -> void: + _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 and button_event.pressed: + device_id = button_event.device + var motion_event := event as InputEventJoypadMotion + if ( + motion_event != null + and absf(motion_event.axis_value) >= CAPTURE_AXIS_THRESHOLD + ): + device_id = motion_event.device + if device_id >= 0 and device_id != _active_device_id: + _set_active_controller(device_id) + + +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_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 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 %d" % int(binding.get("button", -1)) + if str(binding.get("kind", "")) == "axis": + return "axis %d %s" % [ + int(binding.get("axis", -1)), + "+" if float(binding.get("direction", 0.0)) > 0.0 else "−", + ] + return "unmapped" + + +func get_role_strength(role: StringName) -> float: + if not has_custom_mapping(): + return 0.0 + 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: + if not has_custom_mapping(): + return 0.0 + 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 has_custom_mapping(): + 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 has_custom_mapping(): + 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_LT): _axis_binding(JOY_AXIS_TRIGGER_RIGHT, 1.0, 0.0), + str(ROLE_RT): _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() + _set_active_controller(connected[0] if not connected.is_empty() else 0) + + +func _set_active_controller(device_id: int) -> void: + var previous_profile_key: String = _active_profile_key + _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 + _active_profile_key = ( + guid + if not guid.is_empty() + else "name:" + controller_name + ) + 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() + active_controller_changed.emit(_active_controller_name) + + +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 _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]: + InputMap.action_add_event(action, event.duplicate()) + + +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 = -1 + event.button_index = int(binding.get("button", -1)) + 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 = -1 + event.axis = int(binding.get("axis", -1)) + 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)) diff --git a/settings/controller_mapping_manager.gd.uid b/settings/controller_mapping_manager.gd.uid new file mode 100644 index 0000000..3fcbc13 --- /dev/null +++ b/settings/controller_mapping_manager.gd.uid @@ -0,0 +1 @@ +uid://c80x8jkdnx0xy diff --git a/settings/player_settings.gd b/settings/player_settings.gd index c83e534..4a7343f 100644 --- a/settings/player_settings.gd +++ b/settings/player_settings.gd @@ -30,6 +30,7 @@ const UI_COMPACT_RENDER_HEIGHTS: Array[int] = [0, 408, 336, 264, 192] @export var chat_draft: String = "" @export var chat_collapsed: bool = false @export var chat_dock_right: bool = false +@export var chat_mobile_mode: bool = false @export var paint_dock_right: bool = true @export_range(1, 5, 1) var world_pixel_size: int = DEFAULT_WORLD_PIXEL_SIZE @export_range(1, 5, 1) var ui_pixel_size: int = DEFAULT_UI_PIXEL_SIZE @@ -65,6 +66,7 @@ func copy() -> PlayerSettings: result.chat_draft = chat_draft result.chat_collapsed = chat_collapsed result.chat_dock_right = chat_dock_right + result.chat_mobile_mode = chat_mobile_mode result.paint_dock_right = paint_dock_right result.world_pixel_size = world_pixel_size result.ui_pixel_size = ui_pixel_size diff --git a/settings/player_settings_manager.gd b/settings/player_settings_manager.gd index acaf23d..7597c82 100644 --- a/settings/player_settings_manager.gd +++ b/settings/player_settings_manager.gd @@ -61,6 +61,10 @@ func load_settings() -> bool: presentation.has("chat_dock_right") and typeof(presentation["chat_dock_right"]) != TYPE_BOOL ) + or ( + presentation.has("chat_mobile_mode") + and typeof(presentation["chat_mobile_mode"]) != TYPE_BOOL + ) or ( presentation.has("paint_dock_right") and typeof(presentation["paint_dock_right"]) != TYPE_BOOL @@ -107,6 +111,9 @@ func load_settings() -> bool: ) loaded.chat_collapsed = bool(presentation.get("chat_collapsed", false)) loaded.chat_dock_right = bool(presentation.get("chat_dock_right", false)) + loaded.chat_mobile_mode = bool( + presentation.get("chat_mobile_mode", false) + ) loaded.paint_dock_right = bool(presentation.get("paint_dock_right", true)) if not loaded.is_valid(): return _use_defaults_after_corruption("Player settings values are invalid.") @@ -181,6 +188,7 @@ func save_now() -> bool: "chat_draft": current_settings.chat_draft, "chat_collapsed": current_settings.chat_collapsed, "chat_dock_right": current_settings.chat_dock_right, + "chat_mobile_mode": current_settings.chat_mobile_mode, "paint_dock_right": current_settings.paint_dock_right, }, } diff --git a/tests/android_readiness_validation.gd b/tests/android_readiness_validation.gd index 480fcce..b383202 100644 --- a/tests/android_readiness_validation.gd +++ b/tests/android_readiness_validation.gd @@ -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() diff --git a/tests/art_tools_validation.gd b/tests/art_tools_validation.gd index ab04d4a..6cab7f7 100644 --- a/tests/art_tools_validation.gd +++ b/tests/art_tools_validation.gd @@ -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)) diff --git a/tests/controller_mapping_validation.gd b/tests/controller_mapping_validation.gd new file mode 100644 index 0000000..280c2c7 --- /dev/null +++ b/tests/controller_mapping_validation.gd @@ -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 diff --git a/tests/controller_mapping_validation.gd.uid b/tests/controller_mapping_validation.gd.uid new file mode 100644 index 0000000..7840fe3 --- /dev/null +++ b/tests/controller_mapping_validation.gd.uid @@ -0,0 +1 @@ +uid://dhwkjojmpjc13 diff --git a/ui/chat_ui.gd b/ui/chat_ui.gd index 58b7c81..0d61506 100644 --- a/ui/chat_ui.gd +++ b/ui/chat_ui.gd @@ -20,6 +20,11 @@ const HANDLE_SIZE := Vector2(34, 42) const HANDLE_GAP: float = 6.0 const COLLAPSED_REVEAL_WIDTH: float = 8.0 const HINT_EDGE_MARGIN: float = 4.0 +const MOBILE_COMPACT_WIDTH: float = 620.0 +const MOBILE_EXPANDED_WIDTH: float = 820.0 +const MOBILE_COMPACT_HEIGHT: float = 220.0 +const MOBILE_EXPANDED_HEIGHT: float = 390.0 +const MOBILE_EDGE_MARGIN: float = 12.0 const CLOCK_SIZE := Vector2(116.0, 34.0) const CLOCK_EDGE_MARGIN: float = 10.0 const WEATHER_ICON_SIZE := Vector2(34.0, 34.0) @@ -72,8 +77,10 @@ var _send_pending: bool = false var _pending_send_body: String = "" var _prior_movement: bool = true var _prior_camera: bool = true +var _controller_refocused: bool = false var _output_scale: float = 1.0 var _dock_right: bool = false +var _mobile_mode: bool = false var _world_time: WorldTimeServiceType var _world_weather: WorldWeatherServiceType @@ -125,6 +132,7 @@ func setup( _world_weather.get_weather(), _world_weather.get_seconds_remaining(), ) + set_mobile_mode(_settings.current_settings.chat_mobile_mode) set_dock_right(_settings.current_settings.chat_dock_right) _service.message_received.connect(_on_message) _service.local_message_confirmed.connect(_on_local_message_confirmed) @@ -151,6 +159,7 @@ func open_chat() -> void: return if _presentation_state == PresentationState.COLLAPSED: _set_presentation_state(_visible_state_before_collapse, true, true) + _controller_refocused = false _opened = true text_entry_ownership_changed.emit(true) _prior_movement = _player.is_movement_enabled() @@ -162,8 +171,10 @@ func open_chat() -> void: # prediction would let a remote host continue the last held movement. _session.submit_neutral_local_movement() _entry.show() + _entry.virtual_keyboard_enabled = false _entry.grab_focus() _hint.hide() + _refresh_input_ownership() _update_panel_opacity(true) Input.mouse_mode = Input.MOUSE_MODE_VISIBLE @@ -184,6 +195,7 @@ func close_chat() -> void: return _opened = false text_entry_ownership_changed.emit(false) + _entry.virtual_keyboard_enabled = false _entry.release_focus() _entry.hide() _player.set_movement_enabled(_prior_movement) @@ -191,14 +203,62 @@ func close_chat() -> void: _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, false) _flush_draft() _refresh_visibility() + _refresh_input_ownership() + + +func toggle_chat() -> void: + if _presentation_state == PresentationState.COLLAPSED: + open_chat() + return + if _opened: + close_chat() + _set_presentation_state(PresentationState.COLLAPSED, true, true) + return + if _controller_refocused: + _controller_refocused = false + _set_presentation_state(PresentationState.COLLAPSED, true, true) + return + open_chat() + + +func refocus_gameplay() -> void: + close_chat() + _controller_refocused = true + get_viewport().gui_release_focus() + + +func toggle_focus() -> void: + if _presentation_state == PresentationState.COLLAPSED: + return + if _opened: + refocus_gameplay() + else: + open_chat() + + +func request_virtual_keyboard() -> bool: + if not _opened or not _entry.has_focus(): + return false + _entry.virtual_keyboard_enabled = true + # Re-entering focus after the controller accept event is the explicit + # request Android uses to display its keyboard. Merely focusing Chat keeps + # the keyboard disabled so the world remains readable. + _entry.release_focus() + _entry.call_deferred("grab_focus") + return true + + +func is_open() -> bool: + return _opened + + +func is_collapsed() -> bool: + return _presentation_state == PresentationState.COLLAPSED func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("open_chat"): - if _opened: - close_chat() - elif _available: - open_chat() + toggle_chat() get_viewport().set_input_as_handled() return if event is InputEventKey and event.pressed and not event.echo: @@ -404,14 +464,21 @@ func _build_ui() -> void: _unread_indicator.hide() _hint.hide() _layout_presentation(false) + _refresh_input_ownership() func _chat_panel_style() -> StyleBoxFlat: var style := _borderless_style(Color(0.025, 0.13, 0.19, 0.94)) - style.corner_radius_top_left = 10 if _dock_right else 0 - style.corner_radius_bottom_left = 10 if _dock_right else 0 - style.corner_radius_top_right = 0 if _dock_right else 10 - style.corner_radius_bottom_right = 0 if _dock_right else 10 + if _mobile_mode: + style.corner_radius_top_left = 0 + style.corner_radius_top_right = 0 + style.corner_radius_bottom_left = 10 + style.corner_radius_bottom_right = 10 + else: + style.corner_radius_top_left = 10 if _dock_right else 0 + style.corner_radius_bottom_left = 10 if _dock_right else 0 + style.corner_radius_top_right = 0 if _dock_right else 10 + style.corner_radius_bottom_right = 0 if _dock_right else 10 style.content_margin_left = 12 style.content_margin_right = 12 style.content_margin_top = 10 @@ -419,6 +486,18 @@ func _chat_panel_style() -> StyleBoxFlat: return style +func _refresh_input_ownership() -> void: + if _panel == null or _history == null: + return + var filter: Control.MouseFilter = ( + Control.MOUSE_FILTER_STOP + if _opened + else Control.MOUSE_FILTER_IGNORE + ) + _panel.mouse_filter = filter + _history.mouse_filter = filter + + func _clock_panel_style() -> StyleBoxFlat: var style := _borderless_style(Color(0.025, 0.13, 0.19, 0.94)) style.set_corner_radius_all(12) @@ -620,7 +699,10 @@ func _refresh_visibility() -> void: _collapse_button.show() var collapsed := _presentation_state == PresentationState.COLLAPSED _panel.show() - _height_button.show() + _height_button.visible = not _mobile_mode + _height_button.focus_mode = ( + Control.FOCUS_NONE if _mobile_mode else Control.FOCUS_ALL + ) _unread_indicator.visible = collapsed and _collapsed_has_unread _hint.visible = not _opened @@ -670,10 +752,31 @@ func is_docked_right() -> bool: return _dock_right +func set_mobile_mode(enabled: bool) -> void: + _mobile_mode = enabled + if not is_node_ready() or _panel == null: + return + if _mobile_mode: + if _presentation_state == PresentationState.EXPANDED: + _presentation_state = PresentationState.COMPACT + if _visible_state_before_collapse == PresentationState.EXPANDED: + _visible_state_before_collapse = PresentationState.COMPACT + _panel.add_theme_stylebox_override("panel", _chat_panel_style()) + _refresh_handle_labels(_presentation_state) + _refresh_visibility() + _layout_presentation(false) + + +func is_mobile_mode() -> bool: + return _mobile_mode + + func _refresh_handle_labels(state: PresentationState) -> void: var collapsed := state == PresentationState.COLLAPSED var height_state := _visible_state_before_collapse if collapsed else state - if _dock_right: + if _mobile_mode: + _collapse_button.text = "v" if collapsed else "^" + elif _dock_right: _collapse_button.text = "<" if collapsed else ">" else: _collapse_button.text = ">" if collapsed else "<" @@ -798,32 +901,101 @@ func _layout_presentation(animate: bool) -> void: var viewport_height := size.y if viewport_height <= 0.0: viewport_height = 720.0 + var layout_state := ( + _visible_state_before_collapse + if _presentation_state == PresentationState.COLLAPSED + else _presentation_state + ) + var collapsed := _presentation_state == PresentationState.COLLAPSED var bottom_margin := minf( BOTTOM_MARGIN, maxf(MIN_TOP_MARGIN, (viewport_height - MIN_HISTORY_HEIGHT) * 0.25), ) var bottom := viewport_height - bottom_margin var height := COMPACT_HEIGHT - var layout_state := ( - _visible_state_before_collapse - if _presentation_state == PresentationState.COLLAPSED - else _presentation_state - ) - if layout_state == PresentationState.EXPANDED: - height = maxf(MIN_HISTORY_HEIGHT, viewport_height - 2.0 * bottom_margin) - height = minf(height, maxf(MIN_HISTORY_HEIGHT, bottom - MIN_TOP_MARGIN)) - var collapsed := _presentation_state == PresentationState.COLLAPSED - var panel_x: float - if _dock_right: - panel_x = ( - viewport_width - COLLAPSED_REVEAL_WIDTH - if collapsed - else viewport_width - PANEL_WIDTH + var panel_width := PANEL_WIDTH + var panel_x: float = 0.0 + var panel_y: float = 0.0 + var collapse_position := Vector2.ZERO + var height_position := Vector2.ZERO + var unread_position := Vector2.ZERO + if _mobile_mode: + panel_width = ( + MOBILE_EXPANDED_WIDTH + if layout_state == PresentationState.EXPANDED + else MOBILE_COMPACT_WIDTH ) + panel_width = minf( + panel_width, + maxf(1.0, viewport_width - MOBILE_EDGE_MARGIN * 2.0), + ) + height = ( + MOBILE_EXPANDED_HEIGHT + if layout_state == PresentationState.EXPANDED + else MOBILE_COMPACT_HEIGHT + ) + height = minf( + height, + maxf(MIN_HISTORY_HEIGHT, viewport_height - HANDLE_SIZE.y), + ) + panel_x = (viewport_width - panel_width) * 0.5 + panel_y = -(height - COLLAPSED_REVEAL_WIDTH) if collapsed else 0.0 + var mobile_handle_y: float = ( + COLLAPSED_REVEAL_WIDTH if collapsed else height + ) + collapse_position = Vector2( + panel_x + panel_width - HANDLE_SIZE.x, + mobile_handle_y, + ) + height_position = Vector2( + collapse_position.x - HANDLE_SIZE.x - HANDLE_GAP, + mobile_handle_y, + ) + unread_position = collapse_position + Vector2(6.0, -18.0) else: - panel_x = -(PANEL_WIDTH - COLLAPSED_REVEAL_WIDTH) if collapsed else 0.0 - var target_position := Vector2(panel_x, bottom - height) - var target_size := Vector2(PANEL_WIDTH, height) + if layout_state == PresentationState.EXPANDED: + height = maxf( + MIN_HISTORY_HEIGHT, + viewport_height - 2.0 * bottom_margin, + ) + height = minf( + height, + maxf(MIN_HISTORY_HEIGHT, bottom - MIN_TOP_MARGIN), + ) + if _dock_right: + panel_x = ( + viewport_width - COLLAPSED_REVEAL_WIDTH + if collapsed + else viewport_width - PANEL_WIDTH + ) + else: + panel_x = ( + -(PANEL_WIDTH - COLLAPSED_REVEAL_WIDTH) + if collapsed + else 0.0 + ) + panel_y = bottom - height + var handle_stack_height := HANDLE_SIZE.y * 2.0 + HANDLE_GAP + var handle_stack_top := ( + bottom - COMPACT_HEIGHT * 0.5 - handle_stack_height * 0.5 + ) + var handle_x: float = ( + panel_x - HANDLE_SIZE.x + if _dock_right + else COLLAPSED_REVEAL_WIDTH if collapsed else PANEL_WIDTH + ) + collapse_position = Vector2( + handle_x, + handle_stack_top + HANDLE_SIZE.y + HANDLE_GAP, + ) + height_position = Vector2(handle_x, handle_stack_top) + var unread_offset_x := -18.0 if _dock_right else 6.0 + unread_position = collapse_position + Vector2( + unread_offset_x, + -18.0, + ) + var target_position := Vector2(panel_x, panel_y) + var target_size := Vector2(panel_width, height) var clock_x: float = ( viewport_width - CLOCK_SIZE.x - CLOCK_EDGE_MARGIN if _dock_right else CLOCK_EDGE_MARGIN @@ -840,22 +1012,6 @@ func _layout_presentation(animate: bool) -> void: var weather_icon_position := Vector2( weather_icon_x, clock_position.y ) - var handle_stack_height := HANDLE_SIZE.y * 2.0 + HANDLE_GAP - var handle_stack_top := ( - bottom - COMPACT_HEIGHT * 0.5 - handle_stack_height * 0.5 - ) - var handle_x: float - if _dock_right: - handle_x = panel_x - HANDLE_SIZE.x - else: - handle_x = COLLAPSED_REVEAL_WIDTH if collapsed else PANEL_WIDTH - var collapse_position := Vector2( - handle_x, - handle_stack_top + HANDLE_SIZE.y + HANDLE_GAP, - ) - var height_position := Vector2(handle_x, handle_stack_top) - var unread_offset_x := -18.0 if _dock_right else 6.0 - var unread_position := collapse_position + Vector2(unread_offset_x, -18.0) var hint_position := Vector2( ( viewport_width - _hint.size.x - HINT_EDGE_MARGIN diff --git a/ui/controller_mapping_panel.gd b/ui/controller_mapping_panel.gd new file mode 100644 index 0000000..538a4aa --- /dev/null +++ b/ui/controller_mapping_panel.gd @@ -0,0 +1,450 @@ +class_name ControllerMappingPanel +extends Control + +signal closed + +const ControllerMappingManagerType = preload( + "res://settings/controller_mapping_manager.gd" +) +const UtilityPageStyleType = preload("res://ui/utility_page_style.gd") + +var _mapping_manager: ControllerMappingManagerType +var _binding_buttons: Dictionary = {} +var _controller_label: Label +var _instruction_label: Label +var _progress_label: Label +var _auto_map_button: Button +var _reset_button: Button +var _close_button: Button +var _capturing_role: StringName = &"" +var _auto_map_active: bool = false +var _auto_map_index: int = -1 +var _auto_map_draft: Dictionary = {} + + +func _ready() -> void: + set_process_input(true) + _build_interface() + hide() + + +func setup(mapping_manager: ControllerMappingManagerType) -> void: + _mapping_manager = mapping_manager + if not _mapping_manager.active_controller_changed.is_connected( + _on_active_controller_changed + ): + _mapping_manager.active_controller_changed.connect( + _on_active_controller_changed + ) + if not _mapping_manager.active_profile_changed.is_connected( + _refresh_bindings + ): + _mapping_manager.active_profile_changed.connect(_refresh_bindings) + _refresh_bindings() + + +func open_panel() -> void: + if _mapping_manager == null: + return + _cancel_capture() + show() + _refresh_bindings() + _auto_map_button.grab_focus() + + +func is_open() -> bool: + return visible + + +func is_capturing() -> bool: + return visible and not _capturing_role.is_empty() + + +func request_back() -> void: + if not visible: + return + if not _capturing_role.is_empty(): + _cancel_capture() + return + close_panel() + + +func close_panel() -> void: + _cancel_capture() + hide() + closed.emit() + + +func _input(event: InputEvent) -> void: + if not visible or _mapping_manager == null: + return + if _capturing_role.is_empty(): + return + var key_event := event as InputEventKey + if ( + key_event != null + and key_event.pressed + and not key_event.echo + and key_event.keycode == KEY_ESCAPE + ): + get_viewport().set_input_as_handled() + _cancel_capture() + _progress_label.text = "controller mapping cancelled" + return + if not ( + event is InputEventJoypadButton + or event is InputEventJoypadMotion + ): + return + var binding: Dictionary = _mapping_manager.binding_from_event( + _capturing_role, + event, + ) + if binding.is_empty(): + return + if not _mapping_manager.validate_binding(_capturing_role, binding): + return + get_viewport().set_input_as_handled() + _accept_captured_binding(binding) + + +func _build_interface() -> void: + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + mouse_filter = Control.MOUSE_FILTER_STOP + z_index = 100 + UtilityPageStyleType.apply_page(self) + + var frame := PanelContainer.new() + frame.set_anchors_preset(Control.PRESET_FULL_RECT) + frame.offset_left = 18.0 + frame.offset_top = 18.0 + frame.offset_right = -18.0 + frame.offset_bottom = -18.0 + frame.add_theme_stylebox_override( + "panel", + UtilityPageStyleType.rounded_style( + UtilityPageStyleType.OCEAN_PANEL_DEEP, + 20, + ), + ) + add_child(frame) + + var outer_margin := MarginContainer.new() + outer_margin.add_theme_constant_override("margin_left", 24) + outer_margin.add_theme_constant_override("margin_top", 20) + outer_margin.add_theme_constant_override("margin_right", 24) + outer_margin.add_theme_constant_override("margin_bottom", 20) + frame.add_child(outer_margin) + + var page := VBoxContainer.new() + page.add_theme_constant_override("separation", 12) + outer_margin.add_child(page) + + var heading_row := HBoxContainer.new() + heading_row.add_theme_constant_override("separation", 12) + page.add_child(heading_row) + + var title := Label.new() + title.text = "controller mapping" + title.add_theme_font_size_override("font_size", 28) + title.add_theme_color_override( + "font_color", UtilityPageStyleType.OCEAN_TEXT_PRIMARY + ) + title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + heading_row.add_child(title) + + _controller_label = Label.new() + _controller_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + _controller_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _controller_label.add_theme_font_size_override("font_size", 15) + _controller_label.add_theme_color_override( + "font_color", UtilityPageStyleType.OCEAN_TEXT_SECONDARY + ) + heading_row.add_child(_controller_label) + + _instruction_label = Label.new() + _instruction_label.text = ( + "auto-map walks through a standard xbox-style controller. " + + "select any row afterward to override it." + ) + _instruction_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _instruction_label.add_theme_font_size_override("font_size", 15) + _instruction_label.add_theme_color_override( + "font_color", UtilityPageStyleType.OCEAN_TEXT_SECONDARY + ) + page.add_child(_instruction_label) + + _progress_label = Label.new() + _progress_label.custom_minimum_size.y = 34.0 + _progress_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _progress_label.add_theme_font_size_override("font_size", 18) + _progress_label.add_theme_color_override( + "font_color", UtilityPageStyleType.OCEAN_TEXT_PRIMARY + ) + page.add_child(_progress_label) + + var scroll := ScrollContainer.new() + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + page.add_child(scroll) + + var role_grid := GridContainer.new() + role_grid.columns = 2 + role_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL + role_grid.add_theme_constant_override("h_separation", 12) + role_grid.add_theme_constant_override("v_separation", 7) + scroll.add_child(role_grid) + + for role: StringName in ControllerMappingManagerType.ROLE_ORDER: + var role_label := Label.new() + role_label.text = ControllerMappingManagerType.ROLE_LABELS.get( + role, + str(role), + ) + role_label.custom_minimum_size = Vector2(330.0, 42.0) + role_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + role_label.add_theme_font_size_override("font_size", 16) + role_label.add_theme_color_override( + "font_color", UtilityPageStyleType.OCEAN_TEXT_PRIMARY + ) + role_grid.add_child(role_label) + + var binding_button := Button.new() + binding_button.custom_minimum_size = Vector2(210.0, 42.0) + binding_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + binding_button.focus_mode = Control.FOCUS_ALL + binding_button.tooltip_text = ( + "change " + str(ControllerMappingManagerType.ROLE_LABELS.get( + role, + str(role), + )) + ) + UtilityPageStyleType.apply_compact_ocean_button(binding_button) + binding_button.pressed.connect(_begin_manual_capture.bind(role)) + role_grid.add_child(binding_button) + _binding_buttons[role] = binding_button + + var footer := HBoxContainer.new() + footer.alignment = BoxContainer.ALIGNMENT_END + footer.add_theme_constant_override("separation", 10) + page.add_child(footer) + + _auto_map_button = Button.new() + _auto_map_button.text = "auto-map controller" + _auto_map_button.tooltip_text = ( + "walk through every input on a standard xbox-style controller" + ) + UtilityPageStyleType.apply_ocean_button(_auto_map_button) + _auto_map_button.pressed.connect(_begin_auto_map) + footer.add_child(_auto_map_button) + + _reset_button = Button.new() + _reset_button.text = "restore defaults" + _reset_button.tooltip_text = "remove this controller's custom mapping" + UtilityPageStyleType.apply_ocean_button(_reset_button) + _reset_button.pressed.connect(_reset_mapping) + footer.add_child(_reset_button) + + _close_button = Button.new() + _close_button.text = "done" + UtilityPageStyleType.apply_ocean_button(_close_button) + _close_button.pressed.connect(close_panel) + footer.add_child(_close_button) + + +func _begin_auto_map() -> void: + if _mapping_manager == null: + return + _auto_map_active = true + _auto_map_index = 0 + _auto_map_draft = {} + _set_capture_role(ControllerMappingManagerType.ROLE_ORDER[_auto_map_index]) + + +func _begin_manual_capture(role: StringName) -> void: + _auto_map_active = false + _auto_map_index = -1 + _auto_map_draft.clear() + _set_capture_role(role) + + +func _set_capture_role(role: StringName) -> void: + _capturing_role = role + _set_action_buttons_disabled(true) + 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), + ] + return + var input_instruction: String = "press any controller button" + if _mapping_manager.role_expects_axis(role): + input_instruction = "move any controller axis" + elif _mapping_manager.role_accepts_axis(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), + ] + + +func _accept_captured_binding(binding: Dictionary) -> void: + if _auto_map_active: + _auto_map_draft[str(_capturing_role)] = binding.duplicate(true) + _auto_map_index += 1 + if _auto_map_index < ControllerMappingManagerType.ROLE_ORDER.size(): + _set_capture_role( + ControllerMappingManagerType.ROLE_ORDER[_auto_map_index] + ) + return + var saved: bool = _mapping_manager.replace_active_bindings( + _auto_map_draft + ) + _cancel_capture() + _progress_label.text = ( + "controller mapped successfully" + if saved else "could not save the controller mapping" + ) + _refresh_bindings() + return + var role: StringName = _capturing_role + var saved: bool = _mapping_manager.set_binding(role, binding) + _cancel_capture() + _progress_label.text = ( + "updated " + _mapping_manager.get_role_label(role) + if saved else "could not save that controller input" + ) + _refresh_bindings() + +func _cancel_capture() -> void: + _capturing_role = &"" + _auto_map_active = false + _auto_map_index = -1 + _auto_map_draft.clear() + _set_action_buttons_disabled(false) + if _progress_label != null: + _progress_label.text = "" + + +func _set_action_buttons_disabled(disabled: bool) -> void: + if _auto_map_button != null: + _auto_map_button.disabled = disabled + if _reset_button != null: + _reset_button.disabled = disabled + for button_value: Variant in _binding_buttons.values(): + var button := button_value as Button + if button != null: + button.disabled = disabled + + +func _reset_mapping() -> void: + if _mapping_manager == null: + return + var reset: bool = _mapping_manager.reset_active_profile() + _progress_label.text = ( + "restored the project controller defaults" + if reset else "could not restore controller defaults" + ) + _refresh_bindings() + + +func _refresh_bindings() -> void: + if _mapping_manager == null or _controller_label == null: + return + _controller_label.text = _mapping_manager.get_active_controller_name() + var bindings: Dictionary = _mapping_manager.get_active_bindings() + for role: StringName in ControllerMappingManagerType.ROLE_ORDER: + var button := _binding_buttons.get(role) as Button + if button == null: + continue + var raw_binding: Variant = bindings.get(str(role), {}) + button.text = ( + _mapping_manager.binding_label(raw_binding as Dictionary) + if typeof(raw_binding) == TYPE_DICTIONARY else "unmapped" + ) + var conflict_role: StringName = _find_binding_conflict( + role, + raw_binding as Dictionary, + bindings, + ) + _apply_binding_button_style(button, not conflict_role.is_empty()) + button.tooltip_text = ( + "warning: also assigned to " + + _mapping_manager.get_role_label(conflict_role) + if not conflict_role.is_empty() + else "change " + _mapping_manager.get_role_label(role) + ) + _reset_button.disabled = not _mapping_manager.has_custom_mapping() + + +func _find_binding_conflict( + role: StringName, + binding: Dictionary, + bindings: Dictionary, +) -> StringName: + if binding.is_empty(): + return &"" + for other_role: StringName in ControllerMappingManagerType.ROLE_ORDER: + if other_role == role: + continue + var raw_other: Variant = bindings.get(str(other_role), {}) + if typeof(raw_other) != TYPE_DICTIONARY: + continue + var other := raw_other as Dictionary + if _bindings_conflict(role, binding, other_role, other): + return other_role + return &"" + + +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 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 not opposite_trigger_halves + + +func _apply_binding_button_style(button: Button, has_conflict: bool) -> void: + UtilityPageStyleType.apply_compact_ocean_button(button) + if not has_conflict: + return + var colors: Dictionary = { + &"normal": UtilityPageStyleType.OCEAN_DANGER, + &"hover": UtilityPageStyleType.OCEAN_DANGER.lightened(0.12), + &"pressed": UtilityPageStyleType.OCEAN_DANGER.darkened(0.12), + &"focus": UtilityPageStyleType.OCEAN_DANGER.lightened(0.12), + } + for state: StringName in colors: + var style := UtilityPageStyleType.ocean_button_style( + colors[state] as Color + ) + style.content_margin_left = 10.0 + style.content_margin_right = 10.0 + style.content_margin_top = 4.0 + style.content_margin_bottom = 4.0 + button.add_theme_stylebox_override(state, style) + + +func _on_active_controller_changed(_controller_name: String) -> void: + if not _capturing_role.is_empty(): + _cancel_capture() + _refresh_bindings() diff --git a/ui/controller_mapping_panel.gd.uid b/ui/controller_mapping_panel.gd.uid new file mode 100644 index 0000000..41f5e25 --- /dev/null +++ b/ui/controller_mapping_panel.gd.uid @@ -0,0 +1 @@ +uid://b0rl6lgpc0h85 diff --git a/ui/emote_radial_menu.gd b/ui/emote_radial_menu.gd index e0c35de..165ceac 100644 --- a/ui/emote_radial_menu.gd +++ b/ui/emote_radial_menu.gd @@ -14,11 +14,21 @@ const SIT_SECTOR: int = 0 const RING_RADIUS: float = 172.0 const BUBBLE_SIZE: Vector2 = Vector2(92.0, 88.0) const CONTROLLER_SELECTION_DEADZONE: float = 0.35 +const ControllerMappingManagerType = preload( + "res://settings/controller_mapping_manager.gd" +) var _buttons: Array[BubbleButton] = [] var _is_open: bool = false var _selected_sector: int = SIT_SECTOR var _controller_selection_mode: bool = false +var _controller_mapping_manager: ControllerMappingManagerType + + +func setup_controller_mapping( + mapping_manager: ControllerMappingManagerType, +) -> void: + _controller_mapping_manager = mapping_manager func _ready() -> void: @@ -92,10 +102,7 @@ func _layout_bubbles() -> void: func _update_selection() -> void: - var stick: Vector2 = Vector2( - Input.get_joy_axis(0, JOY_AXIS_RIGHT_X), - Input.get_joy_axis(0, JOY_AXIS_RIGHT_Y), - ) + var stick: Vector2 = _get_selection_stick() if stick.length() >= CONTROLLER_SELECTION_DEADZONE: _select_sector_from_offset(stick) return @@ -104,6 +111,25 @@ func _update_selection() -> void: _update_mouse_selection() +func _get_selection_stick() -> Vector2: + if ( + _controller_mapping_manager != null + and _controller_mapping_manager.has_custom_mapping() + ): + return Vector2( + _controller_mapping_manager.get_role_axis( + ControllerMappingManagerType.ROLE_RIGHT_STICK_X + ), + _controller_mapping_manager.get_role_axis( + ControllerMappingManagerType.ROLE_RIGHT_STICK_Y + ), + ) + return Vector2( + Input.get_joy_axis(0, JOY_AXIS_RIGHT_X), + Input.get_joy_axis(0, JOY_AXIS_RIGHT_Y), + ) + + func _update_mouse_selection() -> void: var offset: Vector2 = get_local_mouse_position() - size * 0.5 if offset.length() < 24.0: diff --git a/ui/game_ui.gd b/ui/game_ui.gd index 8b2b02d..a7e7906 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -44,6 +44,9 @@ const SurfaceDrawingToolbarType = preload( const PlayerSettingsManagerType = preload( "res://settings/player_settings_manager.gd" ) +const ControllerMappingManagerType = preload( + "res://settings/controller_mapping_manager.gd" +) const WorldTimeServiceType = preload("res://world/world_time_service.gd") const WorldWeatherServiceType = preload( "res://world/world_weather_service.gd" @@ -64,8 +67,15 @@ signal shop_backdrop_visibility_changed(is_visible: bool) const VIRTUAL_MOUSE_INPUT_OWNER: StringName = &"controller_virtual_mouse" 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 +# 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. +const VIRTUAL_MOUSE_TRIGGER_AXIS: JoyAxis = JOY_AXIS_TRIGGER_RIGHT +const VIRTUAL_MOUSE_SHARED_TRIGGER_AXIS: JoyAxis = JOY_AXIS_TRIGGER_LEFT +const VIRTUAL_MOUSE_SECONDARY_CLICK_AXIS: JoyAxis = JOY_AXIS_TRIGGER_LEFT @onready var _status_label: Label = %StatusLabel @onready var _gameplay_transient_hud: Control = %GameplayTransientHUD @@ -137,7 +147,12 @@ var _virtual_mouse_prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE var _virtual_mouse_window_position: Vector2 = Vector2.ZERO var _virtual_mouse_button_mask: int = 0 var _virtual_mouse_right_pressed: bool = false -var _injecting_virtual_mouse_event: bool = false +var _virtual_mouse_device_id: int = 0 +var _virtual_mouse_trigger_strength: float = 0.0 +var _virtual_mouse_stick: Vector2 = Vector2.ZERO +var _virtual_mouse_trigger_rest_by_device: Dictionary[int, float] = {} +var _shared_trigger_rest_by_device: Dictionary[int, float] = {} +var _controller_mapping_manager: ControllerMappingManagerType func _ready() -> void: @@ -294,21 +309,17 @@ func setup( set_edge_docks( settings_manager.current_settings.chat_dock_right, settings_manager.current_settings.paint_dock_right, + settings_manager.current_settings.chat_mobile_mode, ) func _input(event: InputEvent) -> void: - if ( - _virtual_mouse_active - and _injecting_virtual_mouse_event - and event is InputEventMouseMotion - ): - _update_virtual_cursor_position( - (event as InputEventMouseMotion).position - ) if _handle_virtual_mouse_input(event): get_viewport().set_input_as_handled() return + if _handle_controller_chat_controls(event): + get_viewport().set_input_as_handled() + return if ( _surface_drawing_toolbar != null and _surface_drawing_toolbar.owns_pointer_event(event) @@ -378,6 +389,55 @@ func _input(event: InputEvent) -> void: get_viewport().set_input_as_handled() +func _handle_controller_chat_controls(event: InputEvent) -> bool: + var button_event: InputEventJoypadButton = event as InputEventJoypadButton + if ( + button_event == null + or not button_event.pressed + or not _gameplay_ui_enabled + or _system_menu_open + or _player_menu_open + or _shop_open + ): + return false + var use_mapping: bool = ( + _controller_mapping_manager != null + and _controller_mapping_manager.has_custom_mapping() + ) + var select_pressed: bool = ( + _controller_mapping_manager.event_matches_role( + event, ControllerMappingManagerType.ROLE_SELECT + ) + if use_mapping + else button_event.button_index == JOY_BUTTON_BACK + ) + var focus_pressed: bool = ( + _controller_mapping_manager.event_matches_role( + event, ControllerMappingManagerType.ROLE_LB + ) + if use_mapping + else button_event.button_index == JOY_BUTTON_LEFT_SHOULDER + ) + var accept_pressed: bool = ( + _controller_mapping_manager.event_matches_role( + event, ControllerMappingManagerType.ROLE_A + ) + if use_mapping + else button_event.button_index == JOY_BUTTON_A + ) + if select_pressed: + # Handle Select before focused LineEdit controls consume it. Select owns + # chat visibility while LB only returns input ownership to the world. + _chat_ui.toggle_chat() + return true + if focus_pressed: + _chat_ui.toggle_focus() + return true + if accept_pressed: + return _chat_ui.request_virtual_keyboard() + return false + + func _on_emote_selected(emote_id: StringName) -> void: if emote_id == &"sit" and _player != null: _player.toggle_sitting() @@ -404,24 +464,59 @@ func _on_quick_action_selected(action_id: StringName) -> void: func _handle_virtual_mouse_input(event: InputEvent) -> bool: + if ( + _controller_mapping_manager != null + and _controller_mapping_manager.has_custom_mapping() + ): + return _handle_mapped_virtual_mouse_input(event) var motion_event: InputEventJoypadMotion = event as InputEventJoypadMotion if motion_event != null: - if motion_event.axis == JOY_AXIS_TRIGGER_LEFT: + if motion_event.axis in [ + VIRTUAL_MOUSE_TRIGGER_AXIS, + VIRTUAL_MOUSE_SHARED_TRIGGER_AXIS, + ]: + if ( + _virtual_mouse_active + and motion_event.device != _virtual_mouse_device_id + ): + return false + _sample_trigger_rest_values(motion_event.device) + _virtual_mouse_trigger_strength = ( + _virtual_mouse_strength_for_device(motion_event.device) + ) var was_active: bool = _virtual_mouse_active - if motion_event.axis_value >= VIRTUAL_MOUSE_TRIGGER_THRESHOLD: + if ( + _virtual_mouse_trigger_strength + >= VIRTUAL_MOUSE_TRIGGER_THRESHOLD + ): if not _virtual_mouse_active and _can_start_virtual_mouse(): - _begin_virtual_mouse() - elif _virtual_mouse_active: + _begin_virtual_mouse(motion_event.device) + elif ( + _virtual_mouse_active + and _virtual_mouse_trigger_strength + < VIRTUAL_MOUSE_TRIGGER_RELEASE_THRESHOLD + ): _end_virtual_mouse() + elif _virtual_mouse_active: + _set_virtual_mouse_button( + MOUSE_BUTTON_RIGHT, + _secondary_click_strength_for_device( + motion_event.device + ) >= VIRTUAL_MOUSE_TRIGGER_THRESHOLD, + ) return was_active or _virtual_mouse_active if ( _virtual_mouse_active - and motion_event.axis == JOY_AXIS_TRIGGER_RIGHT + and motion_event.device == _virtual_mouse_device_id + and motion_event.axis in [ + JOY_AXIS_RIGHT_X, + JOY_AXIS_RIGHT_Y, + ] ): - _set_virtual_mouse_button( - MOUSE_BUTTON_RIGHT, - motion_event.axis_value >= VIRTUAL_MOUSE_TRIGGER_THRESHOLD, - ) + if motion_event.axis == JOY_AXIS_RIGHT_X: + _virtual_mouse_stick.x = motion_event.axis_value + else: + _virtual_mouse_stick.y = motion_event.axis_value return true var button_event: InputEventJoypadButton = event as InputEventJoypadButton if ( @@ -429,34 +524,124 @@ func _handle_virtual_mouse_input(event: InputEvent) -> bool: and button_event.button_index == JOY_BUTTON_RIGHT_SHOULDER and ( _virtual_mouse_active - or Input.get_joy_axis(0, JOY_AXIS_TRIGGER_LEFT) - >= VIRTUAL_MOUSE_TRIGGER_THRESHOLD + or _virtual_mouse_trigger_strength + >= VIRTUAL_MOUSE_TRIGGER_THRESHOLD ) ): if not _virtual_mouse_active and _can_start_virtual_mouse(): - _begin_virtual_mouse() + _begin_virtual_mouse(button_event.device) if _virtual_mouse_active: _set_virtual_mouse_button(MOUSE_BUTTON_LEFT, button_event.pressed) return true return false +func _handle_mapped_virtual_mouse_input(event: InputEvent) -> bool: + var uses_activation: bool = _controller_mapping_manager.event_uses_role( + event, + ControllerMappingManagerType.ROLE_LT, + ) + if uses_activation: + var was_active: bool = _virtual_mouse_active + _virtual_mouse_trigger_strength = ( + _controller_mapping_manager.get_role_strength( + ControllerMappingManagerType.ROLE_LT + ) + ) + if ( + _virtual_mouse_trigger_strength + >= VIRTUAL_MOUSE_TRIGGER_THRESHOLD + and not _virtual_mouse_active + and _can_start_virtual_mouse() + ): + _begin_virtual_mouse( + _controller_mapping_manager.get_active_device_id() + ) + elif ( + _virtual_mouse_active + and _virtual_mouse_trigger_strength + < VIRTUAL_MOUSE_TRIGGER_RELEASE_THRESHOLD + ): + _end_virtual_mouse() + return was_active or _virtual_mouse_active + if not _virtual_mouse_active: + return false + if ( + _controller_mapping_manager.event_uses_role( + event, + ControllerMappingManagerType.ROLE_RIGHT_STICK_X, + ) + or _controller_mapping_manager.event_uses_role( + event, + ControllerMappingManagerType.ROLE_RIGHT_STICK_Y, + ) + ): + _virtual_mouse_stick = _mapped_virtual_mouse_stick() + return true + var button_event := event as InputEventJoypadButton + if button_event != null and _controller_mapping_manager.event_uses_role( + event, + ControllerMappingManagerType.ROLE_RB, + ): + _set_virtual_mouse_button(MOUSE_BUTTON_LEFT, button_event.pressed) + return true + if _controller_mapping_manager.event_uses_role( + event, + ControllerMappingManagerType.ROLE_RT, + ): + _set_virtual_mouse_button( + MOUSE_BUTTON_RIGHT, + _controller_mapping_manager.get_role_strength( + ControllerMappingManagerType.ROLE_RT + ) >= VIRTUAL_MOUSE_TRIGGER_THRESHOLD, + ) + return true + return false + + +func _mapped_virtual_mouse_stick() -> Vector2: + return Vector2( + _controller_mapping_manager.get_role_axis( + ControllerMappingManagerType.ROLE_RIGHT_STICK_X + ), + _controller_mapping_manager.get_role_axis( + ControllerMappingManagerType.ROLE_RIGHT_STICK_Y + ), + ) + + func _can_start_virtual_mouse() -> bool: return ( _gameplay_ui_enabled and not _showcase_active + and not _system_menu_open + and not _player_menu_open + and not _shop_open + and not _chat_input_open and _player != null and _fishing_spot != null - and _fishing_spot.can_use_surface_drawing() + and _fishing_spot.can_open_system_menu() and not _emote_radial_menu.is_open() and not _quick_radial_menu.is_open() ) -func _begin_virtual_mouse() -> void: +func _begin_virtual_mouse(device_id: int) -> void: if _virtual_mouse_active: return _virtual_mouse_active = true + _virtual_mouse_device_id = maxi(device_id, 0) + _virtual_mouse_stick = ( + _mapped_virtual_mouse_stick() + if ( + _controller_mapping_manager != null + and _controller_mapping_manager.has_custom_mapping() + ) + else Vector2( + Input.get_joy_axis(_virtual_mouse_device_id, JOY_AXIS_RIGHT_X), + Input.get_joy_axis(_virtual_mouse_device_id, JOY_AXIS_RIGHT_Y), + ) + ) _virtual_mouse_prior_camera_input_enabled = ( _player.is_camera_input_enabled() ) @@ -470,8 +655,11 @@ func _begin_virtual_mouse() -> void: Input.mouse_mode = Input.MOUSE_MODE_HIDDEN _virtual_mouse_button_mask = 0 _virtual_mouse_right_pressed = false - _virtual_mouse_window_position = Vector2(get_window().size) * 0.5 + _virtual_mouse_window_position = _clamp_virtual_mouse_window_position( + Vector2(get_window().size) * 0.5 + ) _controller_virtual_cursor.visible = true + _update_virtual_cursor_position(_virtual_mouse_window_position) _emit_virtual_mouse_motion(Vector2.ZERO) @@ -483,6 +671,8 @@ func _end_virtual_mouse() -> void: if _virtual_mouse_right_pressed: _set_virtual_mouse_button(MOUSE_BUTTON_RIGHT, false) _virtual_mouse_active = false + _virtual_mouse_trigger_strength = 0.0 + _virtual_mouse_stick = Vector2.ZERO _controller_virtual_cursor.visible = false if _fishing_spot != null: _fishing_spot.set_local_menu_input_suppressed( @@ -500,15 +690,12 @@ func _update_virtual_mouse(delta: float) -> void: if not _virtual_mouse_active: return if ( - Input.get_joy_axis(0, JOY_AXIS_TRIGGER_LEFT) - < VIRTUAL_MOUSE_TRIGGER_THRESHOLD + _virtual_mouse_trigger_strength + < VIRTUAL_MOUSE_TRIGGER_RELEASE_THRESHOLD ): _end_virtual_mouse() return - var stick: Vector2 = Vector2( - Input.get_joy_axis(0, JOY_AXIS_RIGHT_X), - Input.get_joy_axis(0, JOY_AXIS_RIGHT_Y), - ) + var stick: Vector2 = _virtual_mouse_stick var stick_length: float = stick.length() if stick_length <= VIRTUAL_MOUSE_STICK_DEADZONE: return @@ -526,22 +713,166 @@ func _update_virtual_mouse(delta: float) -> void: * display_scale * delta ) - var window_size: Vector2 = Vector2(get_window().size) - _virtual_mouse_window_position = Vector2( - clampf( - _virtual_mouse_window_position.x + relative_motion.x, - 0.0, - window_size.x, - ), - clampf( - _virtual_mouse_window_position.y + relative_motion.y, - 0.0, - window_size.y, - ), + _virtual_mouse_window_position = _clamp_virtual_mouse_window_position( + _virtual_mouse_window_position + relative_motion ) + _update_virtual_cursor_position(_virtual_mouse_window_position) _emit_virtual_mouse_motion(relative_motion) +func _poll_virtual_mouse_controller_state() -> void: + if ( + _controller_mapping_manager != null + and _controller_mapping_manager.has_custom_mapping() + ): + _poll_mapped_virtual_mouse_controller_state() + return + var device_ids: Array[int] = Input.get_connected_joypads() + # Some Android controller backends deliver device-0 axes without including + # that device in get_connected_joypads(). Player camera input already uses + # this same primary-device fallback. + if device_ids.is_empty(): + device_ids.append(0) + for device_id: int in device_ids: + _sample_trigger_rest_values(device_id) + if _virtual_mouse_active: + if not device_ids.has(_virtual_mouse_device_id): + _end_virtual_mouse() + return + _virtual_mouse_trigger_strength = _virtual_mouse_strength_for_device( + _virtual_mouse_device_id + ) + _set_virtual_mouse_button( + MOUSE_BUTTON_RIGHT, + _secondary_click_strength_for_device( + _virtual_mouse_device_id + ) >= VIRTUAL_MOUSE_TRIGGER_THRESHOLD, + ) + _virtual_mouse_stick = Vector2( + Input.get_joy_axis( + _virtual_mouse_device_id, + JOY_AXIS_RIGHT_X, + ), + Input.get_joy_axis( + _virtual_mouse_device_id, + JOY_AXIS_RIGHT_Y, + ), + ) + return + if not _can_start_virtual_mouse(): + return + for device_id: int in device_ids: + var trigger_strength: float = _virtual_mouse_strength_for_device( + device_id + ) + if trigger_strength < VIRTUAL_MOUSE_TRIGGER_THRESHOLD: + continue + _virtual_mouse_trigger_strength = trigger_strength + _begin_virtual_mouse(device_id) + return + + +func _poll_mapped_virtual_mouse_controller_state() -> void: + var trigger_strength: float = _controller_mapping_manager.get_role_strength( + ControllerMappingManagerType.ROLE_LT + ) + if _virtual_mouse_active: + _virtual_mouse_trigger_strength = trigger_strength + if trigger_strength < VIRTUAL_MOUSE_TRIGGER_RELEASE_THRESHOLD: + _end_virtual_mouse() + return + _virtual_mouse_stick = _mapped_virtual_mouse_stick() + _set_virtual_mouse_button( + MOUSE_BUTTON_RIGHT, + _controller_mapping_manager.get_role_strength( + ControllerMappingManagerType.ROLE_RT + ) >= VIRTUAL_MOUSE_TRIGGER_THRESHOLD, + ) + return + if ( + trigger_strength >= VIRTUAL_MOUSE_TRIGGER_THRESHOLD + and _can_start_virtual_mouse() + ): + _virtual_mouse_trigger_strength = trigger_strength + _begin_virtual_mouse( + _controller_mapping_manager.get_active_device_id() + ) + + +func _sample_trigger_rest_values(device_id: int) -> void: + if not _virtual_mouse_trigger_rest_by_device.has(device_id): + _virtual_mouse_trigger_rest_by_device[device_id] = Input.get_joy_axis( + device_id, + VIRTUAL_MOUSE_TRIGGER_AXIS, + ) + if not _shared_trigger_rest_by_device.has(device_id): + _shared_trigger_rest_by_device[device_id] = Input.get_joy_axis( + device_id, + VIRTUAL_MOUSE_SHARED_TRIGGER_AXIS, + ) + + +func _virtual_mouse_strength_for_device(device_id: int) -> float: + _sample_trigger_rest_values(device_id) + var dedicated_rest: float = ( + _virtual_mouse_trigger_rest_by_device[device_id] + ) + var shared_rest: float = _shared_trigger_rest_by_device[device_id] + var dedicated_strength: float = normalized_trigger_strength( + Input.get_joy_axis(device_id, VIRTUAL_MOUSE_TRIGGER_AXIS), + dedicated_rest, + ) + var shared_negative_strength: float = directional_trigger_strength( + Input.get_joy_axis(device_id, VIRTUAL_MOUSE_SHARED_TRIGGER_AXIS), + shared_rest, + -1.0, + ) + return maxf(dedicated_strength, shared_negative_strength) + + +func _secondary_click_strength_for_device(device_id: int) -> float: + _sample_trigger_rest_values(device_id) + return directional_trigger_strength( + Input.get_joy_axis(device_id, VIRTUAL_MOUSE_SECONDARY_CLICK_AXIS), + _shared_trigger_rest_by_device[device_id], + 1.0, + ) + + +static func normalized_trigger_strength( + axis_value: float, + resting_value: float, +) -> float: + var negative_travel: float = absf(-1.0 - resting_value) + var positive_travel: float = absf(1.0 - resting_value) + var available_travel: float = maxf(negative_travel, positive_travel) + if available_travel <= 0.001: + return 0.0 + return clampf( + absf(axis_value - resting_value) / available_travel, + 0.0, + 1.0, + ) + + +static func directional_trigger_strength( + axis_value: float, + resting_value: float, + direction: float, +) -> float: + var normalized_direction: float = signf(direction) + if is_zero_approx(normalized_direction): + return 0.0 + var endpoint: float = normalized_direction + var available_travel: float = absf(endpoint - resting_value) + if available_travel <= 0.001: + return 0.0 + var directed_travel: float = ( + (axis_value - resting_value) * normalized_direction + ) + return clampf(directed_travel / available_travel, 0.0, 1.0) + + func _set_virtual_mouse_button(button: MouseButton, pressed: bool) -> void: var mask: int = ( MOUSE_BUTTON_MASK_LEFT @@ -577,16 +908,40 @@ func _emit_virtual_mouse_motion(relative_motion: Vector2) -> void: func _parse_virtual_mouse_event(event: InputEventMouse) -> void: - _injecting_virtual_mouse_event = true Input.parse_input_event(event) - _injecting_virtual_mouse_event = false func _update_virtual_cursor_position(viewport_position: Vector2) -> void: - var root_transform: Transform2D = _ui_root.get_global_transform_with_canvas() - _controller_virtual_cursor.set_pointer_position( - root_transform.affine_inverse() * viewport_position + var output_scale: float = UIReferencePresentationType.get_scale( + Vector2(get_window().size) ) + _controller_virtual_cursor.set_pointer_position( + viewport_position / output_scale + ) + + +func _clamp_virtual_mouse_window_position( + window_position: Vector2, +) -> Vector2: + var bounds: Rect2 = get_virtual_mouse_window_bounds( + Vector2(get_window().size) + ) + return Vector2( + clampf(window_position.x, bounds.position.x, bounds.end.x), + clampf(window_position.y, bounds.position.y, bounds.end.y), + ) + + +static func get_virtual_mouse_window_bounds(window_size: Vector2) -> Rect2: + var output_scale: float = UIReferencePresentationType.get_scale(window_size) + var cursor_margin: Vector2 = ( + ControllerVirtualCursorType.CURSOR_SIZE * output_scale * 0.5 + ) + var bounds_size: Vector2 = Vector2( + maxf(window_size.x - cursor_margin.x * 2.0, 0.0), + maxf(window_size.y - cursor_margin.y * 2.0, 0.0), + ) + return Rect2(cursor_margin, bounds_size) func _toggle_surface_drawing() -> void: @@ -619,7 +974,29 @@ func setup_data_and_identity( ) +func setup_controller_mapping( + mapping_manager: ControllerMappingManagerType, +) -> void: + _controller_mapping_manager = mapping_manager + _player.set_controller_mapping_manager(_controller_mapping_manager) + _emote_radial_menu.setup_controller_mapping(_controller_mapping_manager) + _quick_radial_menu.setup_controller_mapping(_controller_mapping_manager) + _player_menu.setup_controller_mapping(_controller_mapping_manager) + for panel: SettingsPanelType in [ + _title_settings_panel, _pause_settings_panel + ]: + panel.setup_controller_mapping(_controller_mapping_manager) + + +func is_controller_mapping_capturing() -> bool: + return ( + _title_settings_panel.is_controller_mapping_capturing() + or _pause_settings_panel.is_controller_mapping_capturing() + ) + + func _process(delta: float) -> void: + _poll_virtual_mouse_controller_state() _update_virtual_mouse(delta) _update_experience_bubble_position() if _item_effects == null or not _gameplay_ui_enabled: @@ -753,8 +1130,13 @@ func set_effective_ui_pixel_size(pixel_size: int) -> void: _pause_settings_panel.set_effective_ui_pixel_size(pixel_size) -func set_edge_docks(chat_dock_right: bool, paint_dock_right: bool) -> void: +func set_edge_docks( + chat_dock_right: bool, + paint_dock_right: bool, + chat_mobile_mode: bool, +) -> void: _chat_ui.set_dock_right(chat_dock_right) + _chat_ui.set_mobile_mode(chat_mobile_mode) _surface_drawing_toolbar.set_dock_right(paint_dock_right) @@ -1131,6 +1513,8 @@ func _update_experience_bubble_position() -> void: func _on_player_menu_visibility_changed(is_open: bool) -> void: _player_menu_open = is_open + if is_open: + _end_virtual_mouse() if is_open and _surface_drawing != null: _surface_drawing.deactivate() _gameplay_transient_hud.visible = _gameplay_ui_enabled and not is_open diff --git a/ui/player_menu.gd b/ui/player_menu.gd index d3ccfcf..208d329 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -30,6 +30,9 @@ const PlayerCoolerCapacityType = preload( const PlayerExperienceType = preload( "res://progression/player_experience.gd" ) +const ControllerMappingManagerType = preload( + "res://settings/controller_mapping_manager.gd" +) const FishBatchSelectionType = preload( "res://ui/fish_batch_selection.gd" ) @@ -280,6 +283,7 @@ var _network_mail_service: NetworkMailService var _reservations: PlayerAssetReservationService var _network_profile_service: NetworkProfileService var _network_player_list: NetworkPlayerListService +var _controller_mapping_manager: ControllerMappingManagerType var _default_buyer: FishBuyerProfileType var _sale_buyer_override: FishBuyerProfileType var _shop_cooler_context_active: bool = false @@ -564,9 +568,19 @@ func setup( _refresh_all() +func setup_controller_mapping( + mapping_manager: ControllerMappingManagerType, +) -> void: + _controller_mapping_manager = mapping_manager + _profile_page.setup_controller_mapping(_controller_mapping_manager) + + func _input(event: InputEvent) -> void: if event is InputEventKey and event.echo: return + if _handle_controller_page_switch(event): + get_viewport().set_input_as_handled() + return if _handle_direct_page_shortcut(event): get_viewport().set_input_as_handled() return @@ -585,6 +599,71 @@ func _input(event: InputEvent) -> void: get_viewport().set_input_as_handled() +func _handle_controller_page_switch(event: InputEvent) -> bool: + var button_event: InputEventJoypadButton = event as InputEventJoypadButton + var use_mapping: bool = ( + _controller_mapping_manager != null + and _controller_mapping_manager.has_custom_mapping() + ) + var uses_left_bumper: bool = ( + _controller_mapping_manager.event_uses_role( + event, ControllerMappingManagerType.ROLE_LB + ) + if use_mapping + else ( + button_event != null + and button_event.button_index == JOY_BUTTON_LEFT_SHOULDER + ) + ) + var uses_right_bumper: bool = ( + _controller_mapping_manager.event_uses_role( + event, ControllerMappingManagerType.ROLE_RB + ) + if use_mapping + else ( + button_event != null + and button_event.button_index == JOY_BUTTON_RIGHT_SHOULDER + ) + ) + if ( + button_event == null + or not (uses_left_bumper or uses_right_bumper) + or not visible + ): + return false + if not button_event.pressed: + return true + # Shoulder input belongs to the Player Menu while it is visible, even when + # a transition or modal temporarily prevents changing pages. This keeps LB + # from opening Chat and RB from leaking into gameplay behind the menu. + if ( + _transitioning + or _page_transitioning + or _sale_confirmation.visible + or get_viewport().gui_is_dragging() + ): + return true + var sections: Array[Section] = [ + _last_inventory_section, + Section.LOGBOOK, + Section.NET, + Section.MAIL, + Section.PROFILE, + Section.PLAYERS, + ] + var current_index: int = 0 + if not _is_inventory_section(_current_section): + current_index = sections.find(_current_section) + if current_index < 0: + current_index = 0 + var direction: int = ( + -1 if uses_left_bumper else 1 + ) + var next_index: int = wrapi(current_index + direction, 0, sections.size()) + _show_section(sections[next_index]) + return true + + func _handle_direct_page_shortcut(event: InputEvent) -> bool: var key_event := event as InputEventKey if ( diff --git a/ui/profile_page.gd b/ui/profile_page.gd index dac52b3..f7cc710 100644 --- a/ui/profile_page.gd +++ b/ui/profile_page.gd @@ -2,6 +2,9 @@ class_name ProfilePage extends Control const CHECK_DEBOUNCE_SECONDS: float = 0.4 +const ControllerMappingManagerType = preload( + "res://settings/controller_mapping_manager.gd" +) var _service: NetworkProfileService var _experience: PlayerExperience @@ -70,12 +73,20 @@ func setup( ) +func setup_controller_mapping( + mapping_manager: ControllerMappingManagerType, +) -> void: + if _preview != null: + _preview.setup_controller_mapping(mapping_manager) + + func activate() -> void: visible = true UtilityPageStyle.animate_in(self) if _service != null: _load_persisted() - _name_edit.grab_focus() + # Keep controller page switches on the Profile navigation bubble. Focusing + # the name field here summons the Android keyboard during LB/RB traversal. func deactivate() -> void: diff --git a/ui/profile_preview.gd b/ui/profile_preview.gd index c0bef1c..eaec042 100644 --- a/ui/profile_preview.gd +++ b/ui/profile_preview.gd @@ -1,6 +1,10 @@ class_name ProfilePreview extends SubViewportContainer +const ControllerMappingManagerType = preload( + "res://settings/controller_mapping_manager.gd" +) + @export_range(0.1, 2.0, 0.05) var drag_sensitivity: float = 0.012 @export_range(0.1, 4.0, 0.1) var keyboard_speed: float = 1.8 @@ -8,6 +12,13 @@ extends SubViewportContainer var _dragging: bool = false var _visuals: Node3D +var _controller_mapping_manager: ControllerMappingManagerType + + +func setup_controller_mapping( + mapping_manager: ControllerMappingManagerType, +) -> void: + _controller_mapping_manager = mapping_manager func _ready() -> void: @@ -36,7 +47,16 @@ func _process(delta: float) -> void: if not has_focus(): return var axis := Input.get_axis("ui_left", "ui_right") - var right_stick := Input.get_joy_axis(0, JOY_AXIS_RIGHT_X) + var right_stick: float = ( + _controller_mapping_manager.get_role_axis( + ControllerMappingManagerType.ROLE_RIGHT_STICK_X + ) + if ( + _controller_mapping_manager != null + and _controller_mapping_manager.has_custom_mapping() + ) + else Input.get_joy_axis(0, JOY_AXIS_RIGHT_X) + ) if absf(right_stick) > 0.2: axis = right_stick if absf(axis) > 0.1: diff --git a/ui/quick_radial_menu.gd b/ui/quick_radial_menu.gd index 585bae2..88b461d 100644 --- a/ui/quick_radial_menu.gd +++ b/ui/quick_radial_menu.gd @@ -13,6 +13,9 @@ const SECTOR_COUNT: int = 8 const RING_RADIUS: float = 172.0 const BUBBLE_SIZE: Vector2 = Vector2(92.0, 88.0) const CONTROLLER_SELECTION_DEADZONE: float = 0.35 +const ControllerMappingManagerType = preload( + "res://settings/controller_mapping_manager.gd" +) const ACTIONS: Array[StringName] = [ &"stuff", &"logbook", @@ -38,6 +41,13 @@ var _buttons: Array[BubbleButton] = [] var _is_open: bool = false var _selected_sector: int = 0 var _controller_selection_mode: bool = false +var _controller_mapping_manager: ControllerMappingManagerType + + +func setup_controller_mapping( + mapping_manager: ControllerMappingManagerType, +) -> void: + _controller_mapping_manager = mapping_manager func _ready() -> void: @@ -114,10 +124,7 @@ func _layout_bubbles() -> void: func _update_selection() -> void: - var stick: Vector2 = Vector2( - Input.get_joy_axis(0, JOY_AXIS_RIGHT_X), - Input.get_joy_axis(0, JOY_AXIS_RIGHT_Y), - ) + var stick: Vector2 = _get_selection_stick() if stick.length() >= CONTROLLER_SELECTION_DEADZONE: _select_sector_from_offset(stick) return @@ -126,6 +133,25 @@ func _update_selection() -> void: _update_mouse_selection() +func _get_selection_stick() -> Vector2: + if ( + _controller_mapping_manager != null + and _controller_mapping_manager.has_custom_mapping() + ): + return Vector2( + _controller_mapping_manager.get_role_axis( + ControllerMappingManagerType.ROLE_RIGHT_STICK_X + ), + _controller_mapping_manager.get_role_axis( + ControllerMappingManagerType.ROLE_RIGHT_STICK_Y + ), + ) + return Vector2( + Input.get_joy_axis(0, JOY_AXIS_RIGHT_X), + Input.get_joy_axis(0, JOY_AXIS_RIGHT_Y), + ) + + func _update_mouse_selection() -> void: var offset: Vector2 = get_local_mouse_position() - size * 0.5 if offset.length() < 24.0: diff --git a/ui/settings_panel.gd b/ui/settings_panel.gd index 05b0c07..a29cade 100644 --- a/ui/settings_panel.gd +++ b/ui/settings_panel.gd @@ -18,6 +18,12 @@ const PIXELATION_NAMES: PackedStringArray = [ const SettingsManagerType = preload( "res://settings/player_settings_manager.gd" ) +const ControllerMappingManagerType = preload( + "res://settings/controller_mapping_manager.gd" +) +const ControllerMappingPanelType = preload( + "res://ui/controller_mapping_panel.gd" +) signal applied signal closed @@ -42,6 +48,7 @@ enum PresentationMode { @onready var _world_value: BubbleButton = %WorldValue @onready var _ui_value: BubbleButton = %UIValue @onready var _chat_dock: BubbleButton = %ChatDock +@onready var _chat_mode: BubbleButton = %ChatMode @onready var _paint_dock: BubbleButton = %PaintDock @onready var _mouse_value: BubbleButton = %MouseValue @onready var _controller_value: BubbleButton = %ControllerValue @@ -84,6 +91,8 @@ var _identity_backups: IdentityBackupService var _player_identity: PlayerIdentityStore var _host_identity: HostIdentityStore var _interface_fonts: InterfaceFontController +var _controller_mapping_manager: ControllerMappingManagerType +var _controller_mapping_panel: ControllerMappingPanelType var _data_folder_dialog: FileDialog var _backup_file_dialog: FileDialog var _export_file_dialog: FileDialog @@ -114,6 +123,7 @@ func _ready() -> void: %RootBackButton.pressed.connect(close_panel) %DisplayBackButton.pressed.connect(handle_back) %ControlsBackButton.pressed.connect(handle_back) + %ControllerMapping.pressed.connect(_open_controller_mapping) %AccessibilityBackButton.pressed.connect(handle_back) %DataBackButton.pressed.connect(handle_back) %RootBackButton.gui_input.connect(_on_back_bubble_gui_input) @@ -136,6 +146,7 @@ func _ready() -> void: _set_ui_pixelation.bind(index + 1) ) _chat_dock.pressed.connect(_toggle_chat_dock) + _chat_mode.pressed.connect(_toggle_chat_mode) _paint_dock.pressed.connect(_toggle_paint_dock) %MouseDecrease.pressed.connect(_adjust_mouse_sensitivity.bind(-1)) %MouseIncrease.pressed.connect(_adjust_mouse_sensitivity.bind(1)) @@ -178,6 +189,11 @@ func _ready() -> void: parent_control.resized.connect(_refresh_panel_size) for page: SettingsBubblePage in _pages.values(): page.hide_page() + _controller_mapping_panel = ControllerMappingPanelType.new() + add_child(_controller_mapping_panel) + _controller_mapping_panel.closed.connect( + _on_controller_mapping_panel_closed + ) _style_data_page() call_deferred("_refresh_panel_size") @@ -190,6 +206,13 @@ func setup_network_profile( _network_session = session +func setup_controller_mapping( + mapping_manager: ControllerMappingManagerType, +) -> void: + _controller_mapping_manager = mapping_manager + _controller_mapping_panel.setup(_controller_mapping_manager) + + func setup_data_and_identity( data_root: PlayerDataRoot, identity_backups: IdentityBackupService, @@ -263,6 +286,11 @@ func close_panel(immediate: bool = false) -> void: func _finish_panel_close(applied_result: bool) -> void: _cancel_page_transition() + if ( + _controller_mapping_panel != null + and _controller_mapping_panel.is_open() + ): + _controller_mapping_panel.close_panel() for page: SettingsBubblePage in _pages.values(): page.hide_page() _page_stack.clear() @@ -276,6 +304,12 @@ func _finish_panel_close(applied_result: bool) -> void: func handle_back() -> void: + if ( + _controller_mapping_panel != null + and _controller_mapping_panel.is_open() + ): + _controller_mapping_panel.request_back() + return if _page_transition_active: return if _page_stack.size() > 1: @@ -284,10 +318,28 @@ func handle_back() -> void: close_panel() +func _open_controller_mapping() -> void: + if _controller_mapping_manager == null: + _feedback.text = "no controller mapping service is available" + return + _controller_mapping_panel.open_panel() + + +func _on_controller_mapping_panel_closed() -> void: + %ControllerMapping.grab_focus() + + func get_active_page_id() -> StringName: return _page_stack.back() if not _page_stack.is_empty() else StringName() +func is_controller_mapping_capturing() -> bool: + return ( + _controller_mapping_panel != null + and _controller_mapping_panel.is_capturing() + ) + + func _push_page(page_id: StringName) -> void: if ( _page_transition_active @@ -689,6 +741,9 @@ func _refresh_value_labels() -> void: + _pixel_size_label(settings.ui_pixel_size) ) _chat_dock.text = "chat dock\n" + ("right" if settings.chat_dock_right else "left") + _chat_mode.text = ( + "chat mode\n" + ("mobile" if settings.chat_mobile_mode else "desktop") + ) _paint_dock.text = ( "paint dock\n" + ("right" if settings.paint_dock_right else "left") ) @@ -774,6 +829,17 @@ func _toggle_chat_dock() -> void: _refresh_value_labels() +func _toggle_chat_mode() -> void: + if _settings_manager == null: + return + var edited: PlayerSettings = _settings_manager.current_settings.copy() + edited.chat_mobile_mode = not edited.chat_mobile_mode + if not _settings_manager.apply_settings(edited): + _feedback.text = "failed to save chat mode setting." + return + _refresh_value_labels() + + func _toggle_paint_dock() -> void: if _settings_manager == null: return diff --git a/ui/settings_panel.tscn b/ui/settings_panel.tscn index 90e2a13..e821c82 100644 --- a/ui/settings_panel.tscn +++ b/ui/settings_panel.tscn @@ -157,8 +157,8 @@ grow_horizontal = 2 grow_vertical = 2 script = ExtResource("3_page") page_id = &"display" -bubble_paths = Array[NodePath]([NodePath("BubbleCluster/WorldValue"), NodePath("BubbleCluster/WorldLegible"), NodePath("BubbleCluster/WorldCute"), NodePath("BubbleCluster/WorldRetro"), NodePath("BubbleCluster/WorldHardcore"), NodePath("BubbleCluster/WorldWtf"), NodePath("BubbleCluster/UIValue"), NodePath("BubbleCluster/UILegible"), NodePath("BubbleCluster/UICute"), NodePath("BubbleCluster/UIRetro"), NodePath("BubbleCluster/UIHardcore"), NodePath("BubbleCluster/UIWtf"), NodePath("BubbleCluster/ChatDock"), NodePath("BubbleCluster/PaintDock"), NodePath("BubbleCluster/DisplayBackButton")]) -focus_paths = Array[NodePath]([NodePath("BubbleCluster/WorldLegible"), NodePath("BubbleCluster/WorldCute"), NodePath("BubbleCluster/WorldRetro"), NodePath("BubbleCluster/WorldHardcore"), NodePath("BubbleCluster/WorldWtf"), NodePath("BubbleCluster/UILegible"), NodePath("BubbleCluster/UICute"), NodePath("BubbleCluster/UIRetro"), NodePath("BubbleCluster/UIHardcore"), NodePath("BubbleCluster/UIWtf"), NodePath("BubbleCluster/ChatDock"), NodePath("BubbleCluster/PaintDock"), NodePath("BubbleCluster/DisplayBackButton")]) +bubble_paths = Array[NodePath]([NodePath("BubbleCluster/WorldValue"), NodePath("BubbleCluster/WorldLegible"), NodePath("BubbleCluster/WorldCute"), NodePath("BubbleCluster/WorldRetro"), NodePath("BubbleCluster/WorldHardcore"), NodePath("BubbleCluster/WorldWtf"), NodePath("BubbleCluster/UIValue"), NodePath("BubbleCluster/UILegible"), NodePath("BubbleCluster/UICute"), NodePath("BubbleCluster/UIRetro"), NodePath("BubbleCluster/UIHardcore"), NodePath("BubbleCluster/UIWtf"), NodePath("BubbleCluster/ChatDock"), NodePath("BubbleCluster/ChatMode"), NodePath("BubbleCluster/PaintDock"), NodePath("BubbleCluster/DisplayBackButton")]) +focus_paths = Array[NodePath]([NodePath("BubbleCluster/WorldLegible"), NodePath("BubbleCluster/WorldCute"), NodePath("BubbleCluster/WorldRetro"), NodePath("BubbleCluster/WorldHardcore"), NodePath("BubbleCluster/WorldWtf"), NodePath("BubbleCluster/UILegible"), NodePath("BubbleCluster/UICute"), NodePath("BubbleCluster/UIRetro"), NodePath("BubbleCluster/UIHardcore"), NodePath("BubbleCluster/UIWtf"), NodePath("BubbleCluster/ChatDock"), NodePath("BubbleCluster/ChatMode"), NodePath("BubbleCluster/PaintDock"), NodePath("BubbleCluster/DisplayBackButton")]) initial_focus_path = NodePath("BubbleCluster/WorldRetro") back_focus_path = NodePath("BubbleCluster/DisplayBackButton") compact_maximum_layout_size = Vector2(544, 400) @@ -337,36 +337,48 @@ unique_name_in_owner = true custom_minimum_size = Vector2(0, 0) text = "chat dock\nleft" neutral_size = Vector2(96, 96) -desktop_anchor = Vector2(190, 435) -compact_anchor = Vector2(160, 378) +desktop_anchor = Vector2(105, 435) +compact_anchor = Vector2(76, 378) compact_minimum_size = Vector2(82, 82) minimum_font_size = 12 maximum_font_size = 16 motion_phase = 1.1 +[node name="ChatMode" parent="DisplayPage/BubbleCluster" instance=ExtResource("4_bubble")] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 0) +text = "chat mode\ndesktop" +neutral_size = Vector2(96, 96) +desktop_anchor = Vector2(275, 435) +compact_anchor = Vector2(225, 378) +compact_minimum_size = Vector2(82, 82) +minimum_font_size = 12 +maximum_font_size = 16 +motion_phase = 1.4 + [node name="PaintDock" parent="DisplayPage/BubbleCluster" instance=ExtResource("4_bubble")] unique_name_in_owner = true custom_minimum_size = Vector2(0, 0) text = "paint dock\nright" neutral_size = Vector2(96, 96) -desktop_anchor = Vector2(530, 435) -compact_anchor = Vector2(448, 378) +desktop_anchor = Vector2(445, 435) +compact_anchor = Vector2(380, 378) compact_minimum_size = Vector2(82, 82) minimum_font_size = 12 maximum_font_size = 16 -motion_phase = 1.65 +motion_phase = 1.7 [node name="DisplayBackButton" parent="DisplayPage/BubbleCluster" instance=ExtResource("4_bubble")] unique_name_in_owner = true custom_minimum_size = Vector2(0, 0) text = "back" neutral_size = Vector2(96, 90) -desktop_anchor = Vector2(350, 445) -compact_anchor = Vector2(315, 390) +desktop_anchor = Vector2(615, 435) +compact_anchor = Vector2(530, 378) compact_minimum_size = Vector2(82, 78) minimum_font_size = 13 maximum_font_size = 16 -motion_phase = 2.2 +motion_phase = 2.0 [node name="ControlsPage" type="Control" parent="."] unique_name_in_owner = true @@ -379,8 +391,8 @@ grow_horizontal = 2 grow_vertical = 2 script = ExtResource("3_page") page_id = &"controls" -bubble_paths = Array[NodePath]([NodePath("BubbleCluster/MouseDecrease"), NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/MouseIncrease"), NodePath("BubbleCluster/ControllerDecrease"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/ControllerIncrease"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/ControlsBackButton")]) -focus_paths = Array[NodePath]([NodePath("BubbleCluster/MouseDecrease"), NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/MouseIncrease"), NodePath("BubbleCluster/ControllerDecrease"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/ControllerIncrease"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/ControlsBackButton")]) +bubble_paths = Array[NodePath]([NodePath("BubbleCluster/MouseDecrease"), NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/MouseIncrease"), NodePath("BubbleCluster/ControllerDecrease"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/ControllerIncrease"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/ControlsBackButton")]) +focus_paths = Array[NodePath]([NodePath("BubbleCluster/MouseDecrease"), NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/MouseIncrease"), NodePath("BubbleCluster/ControllerDecrease"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/ControllerIncrease"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/ControlsBackButton")]) initial_focus_path = NodePath("BubbleCluster/MouseValue") back_focus_path = NodePath("BubbleCluster/ControlsBackButton") @@ -470,24 +482,36 @@ unique_name_in_owner = true custom_minimum_size = Vector2(0, 0) text = "invert\nvertical\ncamera\noff" neutral_size = Vector2(176, 170) -desktop_anchor = Vector2(525, 230) -compact_anchor = Vector2(485, 225) compact_minimum_size = Vector2(170, 164) minimum_font_size = 14 maximum_font_size = 24 +desktop_anchor = Vector2(525, 145) +compact_anchor = Vector2(485, 135) motion_phase = 4.5 +[node name="ControllerMapping" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 0) +text = "map\ncontroller" +neutral_size = Vector2(160, 150) +desktop_anchor = Vector2(525, 330) +compact_anchor = Vector2(485, 300) +compact_minimum_size = Vector2(148, 138) +minimum_font_size = 14 +maximum_font_size = 23 +motion_phase = 4.9 + [node name="ControlsBackButton" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")] unique_name_in_owner = true custom_minimum_size = Vector2(0, 0) text = "back" neutral_size = Vector2(100, 94) -desktop_anchor = Vector2(525, 425) -compact_anchor = Vector2(540, 395) +desktop_anchor = Vector2(635, 445) +compact_anchor = Vector2(555, 405) compact_minimum_size = Vector2(84, 80) minimum_font_size = 14 maximum_font_size = 17 -motion_phase = 5.2 +motion_phase = 5.5 [node name="AccessibilityPage" type="Control" parent="."] unique_name_in_owner = true diff --git a/ui/title_screen.gd b/ui/title_screen.gd index d950da8..4531b3f 100644 --- a/ui/title_screen.gd +++ b/ui/title_screen.gd @@ -490,6 +490,11 @@ func _process(delta: float) -> void: func _input(event: InputEvent) -> void: if not visible: return + if ( + _settings_panel.visible + and _settings_panel.is_controller_mapping_capturing() + ): + return if _join_game_page.visible: if event.is_action_pressed("ui_cancel"): _close_join_game() diff --git a/ui/title_screen.tscn b/ui/title_screen.tscn index 6c46d28..516f20e 100644 --- a/ui/title_screen.tscn +++ b/ui/title_screen.tscn @@ -206,7 +206,7 @@ unique_name_in_owner = true layout_mode = 2 theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1) theme_override_font_sizes/font_size = 22 -text = "v0.4.0-prealpha" +text = "v0.4.1-prealpha" horizontal_alignment = 1 [node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"]