Improve Android controller and chat support

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

View file

@ -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,
)

View file

@ -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")

View file

@ -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

View file

@ -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)
]
}

View file

@ -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))

View file

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

View file

@ -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

View file

@ -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,
},
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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

View file

@ -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()

View file

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

View file

@ -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:

View file

@ -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

View file

@ -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 (

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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()