feat: overhaul controller menu accessibility

This commit is contained in:
Alexander Sellite 2026-08-15 19:20:42 -04:00
parent a7837c3711
commit 4f9c85989b
46 changed files with 4091 additions and 396 deletions

View file

@ -19,6 +19,9 @@ const PlayerSettingsType = preload("res://settings/player_settings.gd")
const ControllerMappingManagerType = preload( const ControllerMappingManagerType = preload(
"res://settings/controller_mapping_manager.gd" "res://settings/controller_mapping_manager.gd"
) )
const KeyboardMouseMappingManagerType = preload(
"res://settings/keyboard_mouse_mapping_manager.gd"
)
const TitleScreenType = preload("res://ui/title_screen.gd") const TitleScreenType = preload("res://ui/title_screen.gd")
const PauseMenuType = preload("res://ui/pause_menu.gd") const PauseMenuType = preload("res://ui/pause_menu.gd")
const ItemCatalogType = preload("res://items/item_catalog.gd") const ItemCatalogType = preload("res://items/item_catalog.gd")
@ -139,6 +142,9 @@ const SHOP_PATTERN_SCALE: float = 1.75
@onready var _controller_mapping_manager: ControllerMappingManagerType = ( @onready var _controller_mapping_manager: ControllerMappingManagerType = (
%ControllerMappingManager %ControllerMappingManager
) )
@onready var _keyboard_mouse_mapping_manager: KeyboardMouseMappingManagerType = (
%KeyboardMouseMappingManager
)
@onready var _interface_fonts: InterfaceFontController = %InterfaceFontController @onready var _interface_fonts: InterfaceFontController = %InterfaceFontController
@onready var _title_music: AudioStreamPlayer = %TitleMusic @onready var _title_music: AudioStreamPlayer = %TitleMusic
@onready var _dusk_music: AudioStreamPlayer = %DuskMusic @onready var _dusk_music: AudioStreamPlayer = %DuskMusic
@ -279,6 +285,10 @@ func _ready() -> void:
_settings_manager.settings_changed.connect(_apply_runtime_settings) _settings_manager.settings_changed.connect(_apply_runtime_settings)
_settings_manager.load_settings() _settings_manager.load_settings()
_interface_fonts.enforce_standard_font() _interface_fonts.enforce_standard_font()
_interface_fonts.set_controller_text_entry_request(
Callable(_game_ui, "request_controller_text_entry_for"),
Callable(_game_ui, "is_controller_text_entry_open"),
)
if _data_root.resolve(): if _data_root.resolve():
_configure_portable_stores() _configure_portable_stores()
_initialize_after_data_root() _initialize_after_data_root()
@ -760,6 +770,7 @@ func _initialize_application(dedicated: bool) -> void:
_interface_fonts, _interface_fonts,
) )
_game_ui.setup_controller_mapping(_controller_mapping_manager) _game_ui.setup_controller_mapping(_controller_mapping_manager)
_game_ui.setup_keyboard_mouse_mapping(_keyboard_mouse_mapping_manager)
_data_root.conflict_detected.connect(_on_portable_conflict) _data_root.conflict_detected.connect(_on_portable_conflict)
_data_root.status_changed.connect(_on_data_root_status) _data_root.status_changed.connect(_on_data_root_status)
if ( if (
@ -919,13 +930,13 @@ func _focus_data_setup_actions() -> void:
actions.append(_data_setup_dialog.get_cancel_button()) actions.append(_data_setup_dialog.get_cancel_button())
for index: int in actions.size(): for index: int in actions.size():
var action: Button = actions[index] var action: Button = actions[index]
var previous: Button = actions[posmod(index - 1, actions.size())] var previous: Button = actions[maxi(index - 1, 0)]
var next: Button = actions[(index + 1) % actions.size()] var next: Button = actions[mini(index + 1, actions.size() - 1)]
action.focus_mode = Control.FOCUS_ALL action.focus_mode = Control.FOCUS_ALL
action.focus_neighbor_left = action.get_path_to(previous) action.focus_neighbor_left = action.get_path_to(previous)
action.focus_neighbor_top = action.focus_neighbor_left
action.focus_neighbor_right = action.get_path_to(next) action.focus_neighbor_right = action.get_path_to(next)
action.focus_neighbor_bottom = action.focus_neighbor_right action.focus_neighbor_top = action.get_path_to(action)
action.focus_neighbor_bottom = action.get_path_to(action)
_data_setup_dialog.get_ok_button().grab_focus() _data_setup_dialog.get_ok_button().grab_focus()
@ -1072,12 +1083,16 @@ func _show_existing_root_choice(path: String) -> void:
_interface_fonts.apply_utility_theme(dialog) _interface_fonts.apply_utility_theme(dialog)
add_child(dialog) add_child(dialog)
dialog.popup_centered(Vector2i(680, 360)) dialog.popup_centered(Vector2i(680, 360))
_configure_popup_dialog.call_deferred(
dialog, dialog.get_cancel_button()
)
func _show_data_error(message: String) -> void: func _show_data_error(message: String) -> void:
if _data_setup_dialog != null: if _data_setup_dialog != null:
_data_setup_dialog.dialog_text = message _data_setup_dialog.dialog_text = message
_data_setup_dialog.popup_centered(Vector2i(640, 300)) _data_setup_dialog.popup_centered(Vector2i(640, 300))
_focus_data_setup_actions.call_deferred()
func _on_portable_conflict(message: String, _path: String) -> void: func _on_portable_conflict(message: String, _path: String) -> void:
@ -1096,6 +1111,7 @@ func _on_portable_conflict(message: String, _path: String) -> void:
dialog.confirmed.connect(dialog.queue_free) dialog.confirmed.connect(dialog.queue_free)
_game_ui.add_child(dialog) _game_ui.add_child(dialog)
dialog.popup_centered(Vector2i(560, 300)) dialog.popup_centered(Vector2i(560, 300))
_configure_popup_dialog.call_deferred(dialog, dialog.get_ok_button())
func _on_data_root_status(message: String) -> void: func _on_data_root_status(message: String) -> void:
@ -1113,6 +1129,7 @@ func _on_data_root_status(message: String) -> void:
dialog.confirmed.connect(dialog.queue_free) dialog.confirmed.connect(dialog.queue_free)
_game_ui.add_child(dialog) _game_ui.add_child(dialog)
dialog.popup_centered(Vector2i(560, 260)) dialog.popup_centered(Vector2i(560, 260))
_configure_popup_dialog.call_deferred(dialog, dialog.get_ok_button())
func _on_server_trust_required( func _on_server_trust_required(
@ -1153,6 +1170,10 @@ func _on_server_trust_required(
) )
_server_trust_dialog.ok_button_text = "Trust & Connect" _server_trust_dialog.ok_button_text = "Trust & Connect"
_server_trust_dialog.popup_centered(Vector2i(560, 360)) _server_trust_dialog.popup_centered(Vector2i(560, 360))
_configure_popup_dialog.call_deferred(
_server_trust_dialog,
_server_trust_dialog.get_cancel_button(),
)
func _confirm_server_trust() -> void: func _confirm_server_trust() -> void:
@ -1164,6 +1185,10 @@ func _confirm_server_trust() -> void:
) )
_server_trust_dialog.ok_button_text = "Replace Pin" _server_trust_dialog.ok_button_text = "Replace Pin"
_server_trust_dialog.popup_centered(Vector2i(520, 260)) _server_trust_dialog.popup_centered(Vector2i(520, 260))
_configure_popup_dialog.call_deferred(
_server_trust_dialog,
_server_trust_dialog.get_cancel_button(),
)
return return
if _server_trust_dialog != null: if _server_trust_dialog != null:
_server_trust_dialog.hide() _server_trust_dialog.hide()
@ -1182,13 +1207,27 @@ func _on_peer_identity_observed(_peer_id: int, status: String) -> void:
+ "This may be a different player using the same display name." + "This may be a different player using the same display name."
) )
_identity_notice_dialog.popup_centered(Vector2i(480, 240)) _identity_notice_dialog.popup_centered(Vector2i(480, 240))
_configure_popup_dialog.call_deferred(
_identity_notice_dialog,
_identity_notice_dialog.get_ok_button(),
)
func _configure_popup_dialog(
dialog: Window,
preferred_control: Control = null,
) -> void:
if dialog != null and is_instance_valid(dialog) and dialog.visible:
FileDialogControllerNavigation.configure_scope(
dialog, preferred_control
)
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if _handle_data_root_controller_input(event): if _handle_data_root_controller_input(event):
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
return return
if _game_ui.is_controller_mapping_capturing(): if _game_ui.is_input_mapping_capturing():
return return
if ( if (
not ( not (
@ -1227,6 +1266,8 @@ func _input(event: InputEvent) -> void:
func _handle_data_root_controller_input(event: InputEvent) -> bool: func _handle_data_root_controller_input(event: InputEvent) -> bool:
if _game_ui.is_controller_text_entry_open():
return false
var button_event := event as InputEventJoypadButton var button_event := event as InputEventJoypadButton
if button_event == null or not button_event.pressed: if button_event == null or not button_event.pressed:
return false return false
@ -1238,6 +1279,11 @@ func _handle_data_root_controller_input(event: InputEvent) -> bool:
) )
if not setup_visible and not picker_visible: if not setup_visible and not picker_visible:
return false return false
var picker_scope: Window = (
FileDialogControllerNavigation.active_scope(_data_folder_dialog)
if picker_visible
else null
)
var use_mapping: bool = _controller_mapping_manager != null var use_mapping: bool = _controller_mapping_manager != null
var accept_pressed: bool = ( var accept_pressed: bool = (
_controller_mapping_manager.event_matches_role( _controller_mapping_manager.event_matches_role(
@ -1263,14 +1309,17 @@ func _handle_data_root_controller_input(event: InputEvent) -> bool:
) )
if cancel_pressed: if cancel_pressed:
if picker_visible: if picker_visible:
_on_data_folder_picker_canceled() if picker_scope != null and picker_scope != _data_folder_dialog:
picker_scope.hide()
else:
_on_data_folder_picker_canceled()
else: else:
_data_setup_dialog.get_cancel_button().pressed.emit() _data_setup_dialog.get_cancel_button().pressed.emit()
return true return true
if not accept_pressed: if not accept_pressed:
return false return false
var focused: Control = ( var focused: Control = (
_data_folder_dialog.gui_get_focus_owner() picker_scope.gui_get_focus_owner()
if picker_visible if picker_visible
else _data_setup_dialog.gui_get_focus_owner() else _data_setup_dialog.gui_get_focus_owner()
) )
@ -1278,6 +1327,11 @@ func _handle_data_root_controller_input(event: InputEvent) -> bool:
if focused_button != null and not focused_button.disabled: if focused_button != null and not focused_button.disabled:
focused_button.pressed.emit() focused_button.pressed.emit()
return true return true
if (
(focused is LineEdit or focused is TextEdit)
and _game_ui.request_controller_text_entry_for(focused)
):
return true
if setup_visible: if setup_visible:
_data_setup_dialog.get_ok_button().pressed.emit() _data_setup_dialog.get_ok_button().pressed.emit()
return true return true

View file

@ -55,6 +55,7 @@
[ext_resource type="Script" uid="uid://b0c3o2vy76fg3" path="res://world/shoreline_ambience.gd" id="54_shoreline_ambience"] [ext_resource type="Script" uid="uid://b0c3o2vy76fg3" path="res://world/shoreline_ambience.gd" id="54_shoreline_ambience"]
[ext_resource type="AudioStream" uid="uid://dck3tf8qkadea" path="res://audio/ambience/waves.wav" id="55_waves"] [ext_resource type="AudioStream" uid="uid://dck3tf8qkadea" path="res://audio/ambience/waves.wav" id="55_waves"]
[ext_resource type="Script" uid="uid://c80x8jkdnx0xy" path="res://settings/controller_mapping_manager.gd" id="56_controller_mapping"] [ext_resource type="Script" uid="uid://c80x8jkdnx0xy" path="res://settings/controller_mapping_manager.gd" id="56_controller_mapping"]
[ext_resource type="Script" path="res://settings/keyboard_mouse_mapping_manager.gd" id="keyboard_mouse_mapping"]
[ext_resource type="Script" uid="uid://d0jv8n2nfqia0" path="res://network/discovery_client.gd" id="57_discovery"] [ext_resource type="Script" uid="uid://d0jv8n2nfqia0" path="res://network/discovery_client.gd" id="57_discovery"]
[ext_resource type="AudioStream" uid="uid://c4y8puv0n6xk8" path="res://audio/music/world/craft.mp3" id="dusk_music"] [ext_resource type="AudioStream" uid="uid://c4y8puv0n6xk8" path="res://audio/music/world/craft.mp3" id="dusk_music"]
[ext_resource type="Resource" path="res://gathering/catalog/gatherable_catalog.tres" id="58_gatherable_catalog"] [ext_resource type="Resource" path="res://gathering/catalog/gatherable_catalog.tres" id="58_gatherable_catalog"]
@ -391,6 +392,10 @@ script = ExtResource("10_settings")
unique_name_in_owner = true unique_name_in_owner = true
script = ExtResource("56_controller_mapping") script = ExtResource("56_controller_mapping")
[node name="KeyboardMouseMappingManager" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("keyboard_mouse_mapping")
[node name="TitleMusic" type="AudioStreamPlayer" parent="." unique_id=1292939956] [node name="TitleMusic" type="AudioStreamPlayer" parent="." unique_id=1292939956]
unique_name_in_owner = true unique_name_in_owner = true
stream = ExtResource("13_title_music") stream = ExtResource("13_title_music")

View file

@ -87,6 +87,7 @@ jump={
sneak={ sneak={
"deadzone": 0.2, "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":4194326,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) "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":4194326,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":8,"pressure":0.0,"pressed":false,"script":null)
] ]
} }
slow_walk={ slow_walk={
@ -205,7 +206,8 @@ open_quick_actions={
} }
open_chat={ open_chat={
"deadzone": 0.2, "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) "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":84,"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)
] ]
} }
focus_gameplay={ focus_gameplay={

View file

@ -10,15 +10,23 @@ readonly RUN_ROOT="$(mktemp -d -t netfishing-validations.XXXXXX)"
readonly -a QUICK_TESTS=( readonly -a QUICK_TESTS=(
"tests/android_readiness_validation.gd" "tests/android_readiness_validation.gd"
"tests/controller_focus_presentation_validation.gd"
"tests/controller_focus_recovery_validation.gd"
"tests/controller_menu_accessibility_validation.gd"
"tests/controller_mapping_validation.gd"
"tests/controller_ui_navigation_validation.gd"
"tests/dedicated_server_config_validation.gd" "tests/dedicated_server_config_validation.gd"
"tests/exported_decal_hotfix_validation.gd" "tests/exported_decal_hotfix_validation.gd"
"tests/file_dialog_controller_navigation_validation.gd"
"tests/fish_catalog_content_validation.gd" "tests/fish_catalog_content_validation.gd"
"tests/fish_quality_validation.gd" "tests/fish_quality_validation.gd"
"tests/fishing_audio_validation.gd" "tests/fishing_audio_validation.gd"
"tests/fishing_surface_validation.gd" "tests/fishing_surface_validation.gd"
"tests/fur_pattern_validation.gd" "tests/fur_pattern_validation.gd"
"tests/keyboard_mouse_mapping_validation.gd"
"tests/logbook_validation.gd" "tests/logbook_validation.gd"
"tests/network_player_animation_protocol_validation.gd" "tests/network_player_animation_protocol_validation.gd"
"tests/on_screen_keyboard_validation.gd"
"tests/player_experience_validation.gd" "tests/player_experience_validation.gd"
"tests/shoreline_ambience_validation.gd" "tests/shoreline_ambience_validation.gd"
"tests/surface_drawing_validation.gd" "tests/surface_drawing_validation.gd"
@ -56,6 +64,7 @@ readonly -a NETWORK_TESTS=(
"tests/job_multiplayer_validation.gd" "tests/job_multiplayer_validation.gd"
"tests/movement_multiplayer_validation.gd" "tests/movement_multiplayer_validation.gd"
"tests/operator_multiplayer_validation.gd" "tests/operator_multiplayer_validation.gd"
"tests/profile_multiplayer_validation.gd"
"tests/surface_drawing_multiplayer_validation.gd" "tests/surface_drawing_multiplayer_validation.gd"
"tests/world_time_multiplayer_validation.gd" "tests/world_time_multiplayer_validation.gd"
"tests/world_spawn_multiplayer_validation.gd" "tests/world_spawn_multiplayer_validation.gd"

View file

@ -88,7 +88,7 @@ const ROLE_LABELS: Dictionary = {
ROLE_SELECT: "chat", ROLE_SELECT: "chat",
ROLE_START: "pause", ROLE_START: "pause",
ROLE_LEFT_STICK_CLICK: "sprint", ROLE_LEFT_STICK_CLICK: "sprint",
ROLE_RIGHT_STICK_CLICK: "unassigned stick click", ROLE_RIGHT_STICK_CLICK: "sneak",
ROLE_DPAD_UP: "emote wheel", ROLE_DPAD_UP: "emote wheel",
ROLE_DPAD_DOWN: "quick menu", ROLE_DPAD_DOWN: "quick menu",
ROLE_DPAD_LEFT: "previous hotbar slot", ROLE_DPAD_LEFT: "previous hotbar slot",
@ -106,8 +106,8 @@ const ROLE_PROMPTS: Dictionary = {
ROLE_Y: "press the y button", ROLE_Y: "press the y button",
ROLE_LB: "press the left bumper", ROLE_LB: "press the left bumper",
ROLE_RB: "press the right bumper", ROLE_RB: "press the right bumper",
ROLE_POINTER_MODIFIER: "press or squeeze the virtual mouse modifier", ROLE_POINTER_MODIFIER: "press or squeeze LT",
ROLE_CAMERA_ZOOM: "press or squeeze the camera zoom control", ROLE_CAMERA_ZOOM: "press or squeeze RT",
ROLE_SELECT: "press select / back", ROLE_SELECT: "press select / back",
ROLE_START: "press start", ROLE_START: "press start",
ROLE_LEFT_STICK_CLICK: "click the left stick", ROLE_LEFT_STICK_CLICK: "click the left stick",
@ -144,6 +144,7 @@ const BUTTON_ACTION_ROLES: Dictionary = {
&"open_chat": ROLE_SELECT, &"open_chat": ROLE_SELECT,
&"open_system_menu": ROLE_START, &"open_system_menu": ROLE_START,
&"sprint": ROLE_LEFT_STICK_CLICK, &"sprint": ROLE_LEFT_STICK_CLICK,
&"sneak": ROLE_RIGHT_STICK_CLICK,
&"open_emotes": ROLE_DPAD_UP, &"open_emotes": ROLE_DPAD_UP,
&"open_quick_actions": ROLE_DPAD_DOWN, &"open_quick_actions": ROLE_DPAD_DOWN,
&"hotbar_previous": ROLE_DPAD_LEFT, &"hotbar_previous": ROLE_DPAD_LEFT,

View file

@ -0,0 +1,448 @@
class_name KeyboardMouseMappingManager
extends Node
signal mapping_changed
const FORMAT_VERSION: int = 1
const MAPPING_PATH: String = "user://keyboard_mouse_bindings.json"
const MAPPING_TEMP_PATH: String = "user://keyboard_mouse_bindings.json.tmp"
const MAPPING_BACKUP_PATH: String = (
"user://keyboard_mouse_bindings.json.backup"
)
const MAX_MAPPING_BYTES: int = 256 * 1024
const ROLE_MOVE_FORWARD: StringName = &"move_forward"
const ROLE_MOVE_BACKWARD: StringName = &"move_backward"
const ROLE_MOVE_LEFT: StringName = &"move_left"
const ROLE_MOVE_RIGHT: StringName = &"move_right"
const ROLE_SPRINT: StringName = &"sprint"
const ROLE_JUMP: StringName = &"jump"
const ROLE_SNEAK: StringName = &"sneak"
const ROLE_SLOW_WALK: StringName = &"slow_walk"
const ROLE_INTERACT: StringName = &"interact"
const ROLE_PRIMARY_ACTION: StringName = &"fish_primary"
const ROLE_CAMERA_DRAG: StringName = &"camera_drag"
const ROLE_CAMERA_ZOOM_IN: StringName = &"camera_zoom_in"
const ROLE_CAMERA_ZOOM_OUT: StringName = &"camera_zoom_out"
const ROLE_PLAYER_MENU: StringName = &"open_backpack"
const ROLE_TACKLE_BOX: StringName = &"open_tacklebox"
const ROLE_PROP_BOOK: StringName = &"open_props"
const ROLE_EMOTE_WHEEL: StringName = &"open_emotes"
const ROLE_CHARACTER_CALL: StringName = &"character_call"
const ROLE_CHAT: StringName = &"open_chat"
const ROLE_FREE_CAMERA: StringName = &"toggle_free_camera"
const ROLE_HIDE_HUD: StringName = &"toggle_hud"
const ROLE_MENU_ACCEPT: StringName = &"ui_accept"
const ROLE_MENU_BACK: StringName = &"ui_cancel"
const ROLE_ORDER: Array[StringName] = [
ROLE_MOVE_FORWARD,
ROLE_MOVE_BACKWARD,
ROLE_MOVE_LEFT,
ROLE_MOVE_RIGHT,
ROLE_SPRINT,
ROLE_JUMP,
ROLE_SNEAK,
ROLE_SLOW_WALK,
ROLE_INTERACT,
ROLE_PRIMARY_ACTION,
ROLE_CAMERA_DRAG,
ROLE_CAMERA_ZOOM_IN,
ROLE_CAMERA_ZOOM_OUT,
ROLE_PLAYER_MENU,
ROLE_TACKLE_BOX,
ROLE_PROP_BOOK,
ROLE_EMOTE_WHEEL,
ROLE_CHARACTER_CALL,
ROLE_CHAT,
ROLE_FREE_CAMERA,
ROLE_HIDE_HUD,
&"hotbar_1",
&"hotbar_2",
&"hotbar_3",
&"hotbar_4",
&"hotbar_5",
&"hotbar_6",
&"hotbar_7",
&"hotbar_8",
&"hotbar_9",
ROLE_MENU_ACCEPT,
ROLE_MENU_BACK,
]
const ROLE_LABELS: Dictionary = {
ROLE_MOVE_FORWARD: "move forward",
ROLE_MOVE_BACKWARD: "move backward",
ROLE_MOVE_LEFT: "move left",
ROLE_MOVE_RIGHT: "move right",
ROLE_SPRINT: "sprint",
ROLE_JUMP: "jump",
ROLE_SNEAK: "sneak",
ROLE_SLOW_WALK: "slow walk",
ROLE_INTERACT: "interact",
ROLE_PRIMARY_ACTION: "primary action",
ROLE_CAMERA_DRAG: "rotate camera",
ROLE_CAMERA_ZOOM_IN: "camera zoom in",
ROLE_CAMERA_ZOOM_OUT: "camera zoom out",
ROLE_PLAYER_MENU: "player menu",
ROLE_TACKLE_BOX: "tackle box",
ROLE_PROP_BOOK: "prop book",
ROLE_EMOTE_WHEEL: "emote wheel",
ROLE_CHARACTER_CALL: "character call",
ROLE_CHAT: "chat",
ROLE_FREE_CAMERA: "free camera",
ROLE_HIDE_HUD: "hide hud",
&"hotbar_1": "hotbar 1",
&"hotbar_2": "hotbar 2",
&"hotbar_3": "hotbar 3",
&"hotbar_4": "hotbar 4",
&"hotbar_5": "hotbar 5",
&"hotbar_6": "hotbar 6",
&"hotbar_7": "hotbar 7",
&"hotbar_8": "hotbar 8",
&"hotbar_9": "hotbar 9",
ROLE_MENU_ACCEPT: "menu accept",
ROLE_MENU_BACK: "menu back / pause",
}
var _default_events: Dictionary = {}
var _default_bindings: Dictionary = {}
var _bindings: Dictionary = {}
func _ready() -> void:
_capture_project_defaults()
load_mapping()
_apply_active_mapping()
func load_mapping() -> bool:
_recover_interrupted_write()
if not FileAccess.file_exists(MAPPING_PATH):
_bindings = {}
return true
var file := FileAccess.open(MAPPING_PATH, FileAccess.READ)
if file == null:
_bindings = {}
return false
if file.get_length() > MAX_MAPPING_BYTES:
file.close()
push_warning("Keyboard bindings are too large; using defaults.")
_bindings = {}
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("Keyboard bindings are malformed; using defaults.")
_bindings = {}
return false
var data := json.data as Dictionary
if int(data.get("format_version", -1)) != FORMAT_VERSION:
push_warning("Keyboard binding version is unsupported; using defaults.")
_bindings = {}
return false
var raw_bindings: Variant = data.get("bindings", {})
if (
typeof(raw_bindings) != TYPE_DICTIONARY
or not _validate_complete_bindings(raw_bindings as Dictionary)
):
push_warning("Keyboard bindings are incomplete; using defaults.")
_bindings = {}
return false
_bindings = (raw_bindings as Dictionary).duplicate(true)
return true
func has_custom_mapping() -> bool:
return not _bindings.is_empty()
func get_active_bindings() -> Dictionary:
if has_custom_mapping():
return _bindings.duplicate(true)
return _default_bindings.duplicate(true)
func get_role_label(role: StringName) -> String:
return str(ROLE_LABELS.get(role, str(role).replace("_", " ")))
func binding_from_event(event: InputEvent) -> Dictionary:
var key_event := event as InputEventKey
if key_event != null:
if not key_event.pressed or key_event.echo:
return {}
if key_event.keycode in [KEY_SHIFT, KEY_CTRL, KEY_ALT, KEY_META]:
return {}
if key_event.keycode == KEY_NONE and (
key_event.physical_keycode == KEY_NONE
):
return {}
return {
"kind": "key",
"keycode": int(key_event.keycode),
"physical_keycode": int(key_event.physical_keycode),
"alt": key_event.alt_pressed,
"shift": key_event.shift_pressed,
"ctrl": key_event.ctrl_pressed,
"meta": key_event.meta_pressed,
"location": int(key_event.location),
}
var mouse_event := event as InputEventMouseButton
if mouse_event == null or not mouse_event.pressed:
return {}
return {
"kind": "mouse_button",
"button": int(mouse_event.button_index),
}
func validate_binding(binding: Dictionary) -> bool:
var kind: String = str(binding.get("kind", ""))
if kind == "mouse_button":
var button: int = int(binding.get("button", 0))
return (
button >= int(MOUSE_BUTTON_LEFT)
and button <= int(MOUSE_BUTTON_XBUTTON2)
)
if kind != "key":
return false
var keycode: int = int(binding.get("keycode", int(KEY_NONE)))
var physical_keycode: int = int(
binding.get("physical_keycode", int(KEY_NONE))
)
if keycode == int(KEY_NONE) and physical_keycode == int(KEY_NONE):
return false
for modifier: String in ["alt", "shift", "ctrl", "meta"]:
if typeof(binding.get(modifier, false)) != TYPE_BOOL:
return false
return true
func set_binding(role: StringName, binding: Dictionary) -> bool:
if role not in ROLE_ORDER or not validate_binding(binding):
return false
var updated: Dictionary = get_active_bindings()
updated[str(role)] = binding.duplicate(true)
return replace_active_bindings(updated)
func replace_active_bindings(bindings: Dictionary) -> bool:
if not _validate_complete_bindings(bindings):
return false
var previous: Dictionary = _bindings.duplicate(true)
_bindings = bindings.duplicate(true)
if not _save_mapping():
_bindings = previous
return false
_apply_active_mapping()
mapping_changed.emit()
return true
func reset_mapping() -> bool:
var previous: Dictionary = _bindings.duplicate(true)
_bindings = {}
if not _remove_if_present(MAPPING_PATH):
_bindings = previous
return false
_remove_if_present(MAPPING_TEMP_PATH)
_remove_if_present(MAPPING_BACKUP_PATH)
_restore_project_defaults()
mapping_changed.emit()
return true
func binding_label(binding: Dictionary) -> String:
if str(binding.get("kind", "")) == "mouse_button":
return _mouse_button_label(int(binding.get("button", 0)))
if str(binding.get("kind", "")) != "key":
return "unmapped"
var event := _event_from_binding(binding) as InputEventKey
if event == null:
return "unmapped"
var label: String = event.as_text_physical_keycode()
if label.is_empty():
label = event.as_text_keycode()
return label.to_lower() if not label.is_empty() else "unknown key"
static func bindings_conflict(first: Dictionary, second: Dictionary) -> bool:
if str(first.get("kind", "")) != str(second.get("kind", "")):
return false
if str(first.get("kind", "")) == "mouse_button":
return int(first.get("button", 0)) == int(second.get("button", 0))
if str(first.get("kind", "")) != "key":
return false
return (
int(first.get("keycode", 0)) == int(second.get("keycode", 0))
and int(first.get("physical_keycode", 0))
== int(second.get("physical_keycode", 0))
and bool(first.get("alt", false)) == bool(second.get("alt", false))
and bool(first.get("shift", false)) == bool(second.get("shift", false))
and bool(first.get("ctrl", false)) == bool(second.get("ctrl", false))
and bool(first.get("meta", false)) == bool(second.get("meta", false))
)
func _capture_project_defaults() -> void:
_default_events.clear()
_default_bindings.clear()
for role: StringName in ROLE_ORDER:
var action: StringName = role
var events: Array[InputEvent] = []
if InputMap.has_action(action):
for event: InputEvent in InputMap.action_get_events(action):
if not _is_keyboard_mouse_event(event):
continue
events.append(event.duplicate())
if not _default_bindings.has(str(role)):
var binding: Dictionary = binding_from_event(
_pressed_copy(event)
)
if not binding.is_empty():
_default_bindings[str(role)] = binding
_default_events[action] = events
func _validate_complete_bindings(bindings: Dictionary) -> bool:
for role: StringName in ROLE_ORDER:
var raw_binding: Variant = bindings.get(str(role), {})
if (
typeof(raw_binding) != TYPE_DICTIONARY
or not validate_binding(raw_binding as Dictionary)
):
return false
return true
func _apply_active_mapping() -> void:
if not has_custom_mapping():
_restore_project_defaults()
return
_remove_managed_keyboard_mouse_events()
for role: StringName in ROLE_ORDER:
var binding: Variant = _bindings.get(str(role), {})
if typeof(binding) != TYPE_DICTIONARY:
continue
var event: InputEvent = _event_from_binding(binding as Dictionary)
if event != null:
InputMap.action_add_event(role, event)
func _restore_project_defaults() -> void:
_remove_managed_keyboard_mouse_events()
for action_value: Variant in _default_events:
var action: StringName = StringName(action_value)
for event: InputEvent in _default_events[action]:
InputMap.action_add_event(action, event.duplicate())
func _remove_managed_keyboard_mouse_events() -> void:
for action: StringName in ROLE_ORDER:
if not InputMap.has_action(action):
continue
for event: InputEvent in InputMap.action_get_events(action):
if _is_keyboard_mouse_event(event):
InputMap.action_erase_event(action, event)
func _event_from_binding(binding: Dictionary) -> InputEvent:
if not validate_binding(binding):
return null
if str(binding.get("kind", "")) == "mouse_button":
var mouse_event := InputEventMouseButton.new()
mouse_event.button_index = int(binding.get("button", 0)) as MouseButton
return mouse_event
var key_event := InputEventKey.new()
key_event.keycode = int(binding.get("keycode", 0)) as Key
key_event.physical_keycode = int(
binding.get("physical_keycode", 0)
) as Key
key_event.alt_pressed = bool(binding.get("alt", false))
key_event.shift_pressed = bool(binding.get("shift", false))
key_event.ctrl_pressed = bool(binding.get("ctrl", false))
key_event.meta_pressed = bool(binding.get("meta", false))
key_event.location = int(binding.get("location", 0)) as KeyLocation
return key_event
static func _pressed_copy(event: InputEvent) -> InputEvent:
var result: InputEvent = event.duplicate()
if result is InputEventKey:
(result as InputEventKey).pressed = true
elif result is InputEventMouseButton:
(result as InputEventMouseButton).pressed = true
return result
static func _is_keyboard_mouse_event(event: InputEvent) -> bool:
return event is InputEventKey or event is InputEventMouseButton
static func _mouse_button_label(button: int) -> String:
var labels: Dictionary = {
int(MOUSE_BUTTON_LEFT): "mouse left",
int(MOUSE_BUTTON_RIGHT): "mouse right",
int(MOUSE_BUTTON_MIDDLE): "mouse middle",
int(MOUSE_BUTTON_WHEEL_UP): "wheel up",
int(MOUSE_BUTTON_WHEEL_DOWN): "wheel down",
int(MOUSE_BUTTON_WHEEL_LEFT): "wheel left",
int(MOUSE_BUTTON_WHEEL_RIGHT): "wheel right",
int(MOUSE_BUTTON_XBUTTON1): "mouse 4",
int(MOUSE_BUTTON_XBUTTON2): "mouse 5",
}
return str(labels.get(button, "mouse %d" % button))
func _save_mapping() -> bool:
var file := FileAccess.open(MAPPING_TEMP_PATH, FileAccess.WRITE)
if file == null:
return false
file.store_string(JSON.stringify({
"format_version": FORMAT_VERSION,
"bindings": _bindings,
}, "\t"))
file.flush()
var write_error: Error = file.get_error()
file.close()
if write_error != OK:
_remove_if_present(MAPPING_TEMP_PATH)
return false
_remove_if_present(MAPPING_BACKUP_PATH)
var had_primary: bool = FileAccess.file_exists(MAPPING_PATH)
if had_primary and not _rename_file(MAPPING_PATH, MAPPING_BACKUP_PATH):
_remove_if_present(MAPPING_TEMP_PATH)
return false
if not _rename_file(MAPPING_TEMP_PATH, MAPPING_PATH):
if had_primary:
_rename_file(MAPPING_BACKUP_PATH, MAPPING_PATH)
return false
_remove_if_present(MAPPING_BACKUP_PATH)
return true
func _recover_interrupted_write() -> void:
if FileAccess.file_exists(MAPPING_PATH):
_remove_if_present(MAPPING_TEMP_PATH)
_remove_if_present(MAPPING_BACKUP_PATH)
return
if FileAccess.file_exists(MAPPING_BACKUP_PATH):
_rename_file(MAPPING_BACKUP_PATH, MAPPING_PATH)
_remove_if_present(MAPPING_TEMP_PATH)
static 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
static func _remove_if_present(path: String) -> bool:
if not FileAccess.file_exists(path):
return true
return DirAccess.remove_absolute(
ProjectSettings.globalize_path(path)
) == OK

View file

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

View file

@ -69,6 +69,34 @@ func _run() -> void:
"inversion clears when a focused control is defocused", "inversion clears when a focused control is defocused",
) )
var scroll := ScrollContainer.new()
scroll.position = Vector2(0.0, 80.0)
scroll.size = Vector2(180.0, 90.0)
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
stage.add_child(scroll)
var list := VBoxContainer.new()
list.custom_minimum_size.x = 160.0
scroll.add_child(list)
var last_scroll_button: Button
for index: int in 8:
var scroll_button := Button.new()
scroll_button.text = "scroll option %d" % index
scroll_button.custom_minimum_size = Vector2(160.0, 40.0)
list.add_child(scroll_button)
last_scroll_button = scroll_button
await process_frame
last_scroll_button.grab_focus()
await process_frame
await process_frame
_expect(
scroll.scroll_vertical > 0,
"controller focus scrolls an off-screen selection into view",
)
_expect(
root.gui_get_focus_owner() == last_scroll_button,
"scroll following preserves the selected controller control",
)
stage.queue_free() stage.queue_free()
if _failures.is_empty(): if _failures.is_empty():
print("Controller focus presentation validation: PASS") print("Controller focus presentation validation: PASS")

View file

@ -94,6 +94,16 @@ func _validate_manager(manager: ControllerMappingManagerType) -> String:
ControllerMappingManagerType.ROLE_ORDER.size(), ControllerMappingManagerType.ROLE_ORDER.size(),
str(defaults.keys()), str(defaults.keys()),
] ]
if ControllerMappingManagerType.ROLE_LABELS.get(
ControllerMappingManagerType.ROLE_RIGHT_STICK_CLICK, ""
) != "sneak":
return "right-stick click is not presented as sneak"
if ControllerMappingManagerType.BUTTON_ACTION_ROLES.get(
&"sneak", &""
) != ControllerMappingManagerType.ROLE_RIGHT_STICK_CLICK:
return "right-stick click is not assigned to the sneak action"
if not _has_joy_button(&"sneak", JOY_BUTTON_RIGHT_STICK):
return "sneak does not default to right-stick click"
var trigger_button := InputEventJoypadButton.new() var trigger_button := InputEventJoypadButton.new()
trigger_button.device = manager.get_active_device_id() trigger_button.device = manager.get_active_device_id()
trigger_button.button_index = JOY_BUTTON_MISC1 trigger_button.button_index = JOY_BUTTON_MISC1
@ -226,10 +236,41 @@ func _validate_portmaster_launcher() -> String:
func _validate_auto_map(manager: ControllerMappingManagerType) -> String: func _validate_auto_map(manager: ControllerMappingManagerType) -> String:
if manager.get_role_prompt(ControllerMappingManagerType.ROLE_LT) != (
"press or squeeze LT"
):
return "auto-map does not identify the LT control"
if manager.get_role_prompt(ControllerMappingManagerType.ROLE_RT) != (
"press or squeeze RT"
):
return "auto-map does not identify the RT control"
var panel := ControllerMappingPanelType.new() var panel := ControllerMappingPanelType.new()
root.add_child(panel) root.add_child(panel)
panel.setup(manager) panel.setup(manager)
panel.open_panel() panel.open_panel()
var mapping_scrolls: Array[Node] = panel.find_children(
"", "ScrollContainer", true, false
)
if mapping_scrolls.is_empty():
return "controller mapper does not contain its mapping list"
if not (mapping_scrolls[0] as ScrollContainer).follow_focus:
return "controller mapper does not scroll to follow controller focus"
var first_binding := panel._binding_buttons.get(
ControllerMappingManagerType.ROLE_ORDER.front()
) as Button
var last_binding := panel._binding_buttons.get(
ControllerMappingManagerType.ROLE_ORDER.back()
) as Button
if first_binding.get_node(first_binding.focus_neighbor_top) != first_binding:
return "controller mapper loses focus above its first row"
if last_binding.get_node(last_binding.focus_neighbor_bottom) != (
panel._close_button
):
return "controller mapper cannot reach Done from its last row"
if panel._close_button.get_node(
panel._close_button.focus_neighbor_top
) != last_binding:
return "controller mapper cannot return from Done to its last row"
panel._begin_auto_map() panel._begin_auto_map()
for role: StringName in ControllerMappingManagerType.ROLE_ORDER: for role: StringName in ControllerMappingManagerType.ROLE_ORDER:
panel._process(ControllerMappingPanelType.CAPTURE_NEUTRAL_SECONDS) panel._process(ControllerMappingPanelType.CAPTURE_NEUTRAL_SECONDS)
@ -391,3 +432,11 @@ func _keyboard_event_count(action: StringName) -> int:
if event is InputEventKey or event is InputEventMouseButton: if event is InputEventKey or event is InputEventMouseButton:
count += 1 count += 1
return count return count
func _has_joy_button(action: StringName, button_index: JoyButton) -> bool:
for event: InputEvent in InputMap.action_get_events(action):
var button := event as InputEventJoypadButton
if button != null and button.button_index == button_index:
return true
return false

View file

@ -0,0 +1,457 @@
extends SceneTree
const JoinGamePageScene = preload(
"res://ui/network/join_game_page.tscn"
)
const SettingsPanelScene = preload("res://ui/settings_panel.tscn")
const BubbleConfirmationScene = preload(
"res://ui/components/bubble_menu/bubble_confirmation_page.tscn"
)
const TitleConfirmationScene = preload(
"res://ui/title_confirmation_bubble_page.tscn"
)
const MailPageType = preload("res://ui/mail_page.gd")
const ProfilePageType = preload("res://ui/profile_page.gd")
const DialogControllerNavigationType = preload(
"res://ui/file_dialog_controller_navigation.gd"
)
var _failures: Array[String] = []
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
root.size = Vector2i(1280, 720)
await _validate_join_game_navigation()
await _validate_data_settings_navigation()
await _validate_mail_navigation()
await _validate_profile_confirmation_focus()
await _validate_confirmation_dialog_navigation()
await _validate_bubble_confirmation_navigation()
_validate_mapping_capture_contract()
if _failures.is_empty():
print("Controller menu accessibility validation: PASS")
quit(0)
return
for failure: String in _failures:
push_error(failure)
quit(1)
func _validate_join_game_navigation() -> void:
var page := JoinGamePageScene.instantiate() as Control
root.add_child(page)
await process_frame
page.show()
var discover := page.get_node("%DiscoverButton") as Button
var direct := page.get_node("%DirectButton") as Button
var saved := page.get_node("%SavedButton") as Button
var recent := page.get_node("%RecentButton") as Button
var address := page.get_node("%Address") as LineEdit
var name_edit := page.get_node("%NameEdit") as LineEdit
var server_list := page.get_node("%ServerList") as ItemList
var refresh := page.get_node("%RefreshButton") as Button
var join := page.get_node("%JoinButton") as Button
var save := page.get_node("%SaveButton") as Button
var edit := page.get_node("%EditButton") as Button
var favorite := page.get_node("%FavoriteButton") as Button
var delete := page.get_node("%DeleteButton") as Button
var cancel := page.get_node("%CancelButton") as Button
var back := page.get_node("%BackButton") as Button
var modes: Array[Control] = [discover, direct, saved, recent]
address.hide()
name_edit.hide()
server_list.show()
_set_button_state(refresh, true)
_set_button_state(join, true)
_set_button_state(save, false)
_set_button_state(edit, false)
_set_button_state(favorite, false)
_set_button_state(delete, false)
_set_button_state(cancel, false)
_set_button_state(back, true)
page.set("_mode", 0)
page.call("_configure_controller_navigation")
await process_frame
_assert_neighbor(discover, &"focus_neighbor_bottom", server_list)
_assert_neighbor(server_list, &"focus_neighbor_top", discover)
_assert_neighbor(server_list, &"focus_neighbor_bottom", refresh)
_assert_neighbor(refresh, &"focus_neighbor_right", join)
_assert_neighbor(back, &"focus_neighbor_left", join)
var discover_controls: Array[Control] = modes.duplicate()
discover_controls.append_array([server_list, refresh, join, back])
_assert_directionally_reachable(discover, discover_controls)
address.show()
address.editable = true
server_list.hide()
_set_button_state(refresh, false)
_set_button_state(join, true)
_set_button_state(save, true)
page.set("_mode", 1)
page.call("_configure_controller_navigation")
await process_frame
_assert_neighbor(direct, &"focus_neighbor_bottom", address)
_assert_neighbor(address, &"focus_neighbor_top", direct)
_assert_neighbor(address, &"focus_neighbor_bottom", join)
var direct_controls: Array[Control] = modes.duplicate()
direct_controls.append_array([address, join, save, back])
_assert_directionally_reachable(direct, direct_controls)
name_edit.show()
name_edit.editable = true
page.set("_name_entry_active", true)
page.call("_configure_controller_navigation")
await process_frame
_assert_neighbor(address, &"focus_neighbor_bottom", name_edit)
_assert_neighbor(name_edit, &"focus_neighbor_top", address)
page.call("request_back")
_expect(
not bool(page.get("_name_entry_active")),
"Join-game Back should leave the server-name edit substate.",
)
_expect(
page.visible,
"Join-game Back should not close the browser from an edit substate.",
)
await process_frame
page.queue_free()
await process_frame
func _validate_data_settings_navigation() -> void:
var panel := SettingsPanelScene.instantiate() as Control
root.add_child(panel)
await process_frame
panel.show()
var data_page := panel.get_node("%DataPage") as SettingsBubblePage
data_page.show_page(false)
for _frame: int in 2:
await process_frame
var controls: Array[Control] = [
panel.get_node("%OpenDataFolder") as Control,
panel.get_node("%ChangeDataFolder") as Control,
panel.get_node("%CopyPlayerFingerprint") as Control,
panel.get_node("%ExportPlayerIdentity") as Control,
panel.get_node("%ImportPlayerIdentity") as Control,
panel.get_node("%ExportHostIdentity") as Control,
panel.get_node("%ImportHostIdentity") as Control,
panel.get_node("%DataBackButton") as Control,
]
for control: Control in controls:
_expect(
control.focus_mode == Control.FOCUS_ALL,
"Data & Identity control %s is not controller-focusable."
% control.name,
)
_assert_directionally_reachable(controls.front(), controls)
panel.queue_free()
await process_frame
func _validate_mail_navigation() -> void:
var page := MailPageType.new() as Control
root.add_child(page)
await process_frame
page.call("activate")
page.call("set_interactive", true)
var inbox_list := page.get("_inbox_list") as VBoxContainer
var entry := Button.new()
entry.text = "test letter"
entry.custom_minimum_size = Vector2(1008.0, 54.0)
inbox_list.add_child(entry)
await process_frame
page.call("_refresh_controller_navigation")
var archive_view := page.get("_archive_view_button") as Button
var send_mail := page.get("_send_mail_button") as Button
_assert_neighbor(
archive_view, &"focus_neighbor_right", send_mail
)
_assert_neighbor(archive_view, &"focus_neighbor_bottom", entry)
_assert_neighbor(send_mail, &"focus_neighbor_bottom", entry)
_assert_neighbor(entry, &"focus_neighbor_top", archive_view)
_assert_directionally_reachable(
archive_view, [archive_view, send_mail, entry]
)
var inbox := page.get("_inbox") as Control
var compose := page.get("_compose") as Control
var letter := page.get("_letter") as Control
inbox.hide()
compose.hide()
letter.show()
var accept := page.get("_accept") as Button
var decline := page.get("_decline") as Button
var close := page.get("_letter_close") as Button
var archive := page.get("_archive") as Button
var delete := page.get("_delete") as Button
accept.show()
decline.show()
delete.disabled = false
page.call("_refresh_controller_navigation")
_assert_neighbor(accept, &"focus_neighbor_bottom", decline)
_assert_neighbor(decline, &"focus_neighbor_bottom", delete)
_assert_neighbor(delete, &"focus_neighbor_left", archive)
_assert_neighbor(archive, &"focus_neighbor_left", close)
_assert_neighbor(close, &"focus_neighbor_top", close)
_assert_directionally_reachable(
accept, [accept, decline, close, archive, delete]
)
page.queue_free()
await process_frame
func _validate_profile_confirmation_focus() -> void:
var page := ProfilePageType.new() as Control
root.add_child(page)
await process_frame
page.call("activate")
page.call("set_interactive", true)
page.call("reset_controller_zone")
var suggestions := page.get("_suggestions") as HBoxContainer
var suggestion := Button.new()
suggestion.text = "alternate name"
suggestions.add_child(suggestion)
page.call("_apply_controller_zone_focus")
var account_controls: Array[Control] = []
for item: Variant in page.call("_account_controller_controls"):
account_controls.append(item as Control)
var preview := page.get("_preview") as Control
var reset_view := page.get("_reset_view_button") as Button
_expect(
suggestion in account_controls,
"Profile name-conflict choices are outside the account zone.",
)
_expect(
suggestion.focus_mode == Control.FOCUS_ALL,
"Profile name-conflict choices are not controller-focusable.",
)
_expect(
preview.focus_mode == Control.FOCUS_NONE,
"The profile preview must not take controller focus.",
)
_expect(
reset_view.focus_mode == Control.FOCUS_NONE,
"The profile reset-view button must not take controller focus.",
)
page.call("_show_confirmation", "defaults")
var confirmation := page.get("_discard_confirmation") as Control
var confirm := page.get("_confirmation_confirm") as Button
var keep_editing := page.get("_keep_editing_button") as Button
_expect(confirmation.visible, "The profile confirmation did not open.")
_expect(
confirm.focus_mode == Control.FOCUS_ALL,
"The profile confirmation action is not controller-focusable.",
)
_expect(
keep_editing.focus_mode == Control.FOCUS_ALL,
"The profile confirmation cancel action is not controller-focusable.",
)
for control: Control in account_controls:
_expect(
control.focus_mode == Control.FOCUS_NONE,
"Profile confirmation leaked focus to %s." % control.name,
)
var cancel_event := InputEventAction.new()
cancel_event.action = &"ui_cancel"
cancel_event.pressed = true
_expect(
bool(page.call("handle_controller_input", cancel_event)),
"Profile confirmation did not consume controller Back.",
)
_expect(
not confirmation.visible,
"Controller Back did not close the profile confirmation.",
)
for control: Control in account_controls:
_expect(
control.focus_mode == Control.FOCUS_ALL,
"Profile account focus was not restored after confirmation.",
)
page.queue_free()
await process_frame
func _validate_mapping_capture_contract() -> void:
var title_source: String = FileAccess.get_file_as_string(
"res://ui/title_screen.gd"
)
var game_ui_source: String = FileAccess.get_file_as_string(
"res://ui/game_ui.gd"
)
var chat_source: String = FileAccess.get_file_as_string(
"res://ui/chat_ui.gd"
)
var main_source: String = FileAccess.get_file_as_string(
"res://main/main.gd"
)
_expect(
title_source.contains(
"_settings_panel.is_input_mapping_capturing()"
),
"Title input does not respect every active binding capture.",
)
_expect(
game_ui_source.contains("or is_input_mapping_capturing()"),
"Controller menu scrolling does not respect every binding capture.",
)
_expect(
chat_source.contains("func _configure_controller_focus()"),
"Chat does not author controller routes for its exterior controls.",
)
_expect(
chat_source.contains(
"Control.FOCUS_ALL if _opened else Control.FOCUS_NONE"
),
"Passive chat controls can steal world controller focus.",
)
_expect(
main_source.contains("func _configure_popup_dialog("),
"Runtime popup dialogs do not receive authored controller routes.",
)
func _validate_confirmation_dialog_navigation() -> void:
var dialog := ConfirmationDialog.new()
dialog.ok_button_text = "continue"
var fields := VBoxContainer.new()
var first := LineEdit.new()
first.placeholder_text = "passphrase"
first.custom_minimum_size = Vector2(420.0, 42.0)
fields.add_child(first)
var second := LineEdit.new()
second.placeholder_text = "confirm passphrase"
second.custom_minimum_size = Vector2(420.0, 42.0)
fields.add_child(second)
dialog.add_child(fields)
root.add_child(dialog)
dialog.popup_centered(Vector2i(560, 300))
for _frame: int in 2:
await process_frame
DialogControllerNavigationType.configure_scope(dialog, first)
await process_frame
var controls: Array[Control] = (
DialogControllerNavigationType.interactive_controls(dialog)
)
_expect(
controls.has(first) and controls.has(second),
"Confirmation-dialog text fields are not controller reachable.",
)
_expect(
controls.has(dialog.get_ok_button())
and controls.has(dialog.get_cancel_button()),
"Confirmation-dialog actions are not controller reachable.",
)
_assert_directionally_reachable(first, controls)
_expect(
dialog.gui_get_focus_owner() == first,
"Confirmation dialog did not focus its requested entry control.",
)
dialog.hide()
await process_frame
root.gui_release_focus()
dialog.popup_centered(Vector2i(560, 300))
await process_frame
DialogControllerNavigationType.configure_scope(dialog, first)
await process_frame
_expect(
dialog.gui_get_focus_owner() == first,
"A reopened dialog did not restore controller focus.",
)
dialog.queue_free()
await process_frame
func _validate_bubble_confirmation_navigation() -> void:
for scene: PackedScene in [
BubbleConfirmationScene,
TitleConfirmationScene,
]:
var page := scene.instantiate() as Control
root.add_child(page)
await process_frame
page.show()
page.call("_set_interactive", true)
var confirm := page.get_node("BubbleCluster/ConfirmButton") as Button
var cancel := page.get_node("BubbleCluster/CancelButton") as Button
_assert_neighbor(confirm, &"focus_neighbor_left", cancel)
_assert_neighbor(confirm, &"focus_neighbor_right", cancel)
_assert_neighbor(confirm, &"focus_neighbor_top", confirm)
_assert_neighbor(confirm, &"focus_neighbor_bottom", confirm)
_assert_neighbor(cancel, &"focus_neighbor_left", confirm)
_assert_neighbor(cancel, &"focus_neighbor_right", confirm)
_assert_neighbor(cancel, &"focus_neighbor_top", cancel)
_assert_neighbor(cancel, &"focus_neighbor_bottom", cancel)
page.queue_free()
await process_frame
func _set_button_state(button: Button, shown: bool) -> void:
button.visible = shown
button.disabled = false
func _assert_neighbor(
origin: Control,
property: StringName,
expected: Control,
) -> void:
var path: NodePath = origin.get(property)
_expect(
not path.is_empty(),
"%s has no %s neighbor." % [origin.name, property],
)
if path.is_empty():
return
var actual := origin.get_node_or_null(path) as Control
_expect(
actual == expected,
"%s points %s to %s instead of %s."
% [
origin.name,
property,
actual.name if actual != null else "nothing",
expected.name,
],
)
func _assert_directionally_reachable(
start: Control,
controls: Array[Control],
) -> void:
var expected: Dictionary[int, bool] = {}
for control: Control in controls:
expected[control.get_instance_id()] = true
var visited: Dictionary[int, bool] = {start.get_instance_id(): true}
var pending: Array[Control] = [start]
while not pending.is_empty():
var current: Control = pending.pop_front()
for path: NodePath in [
current.focus_neighbor_left,
current.focus_neighbor_right,
current.focus_neighbor_top,
current.focus_neighbor_bottom,
]:
var neighbor := current.get_node_or_null(path) as Control
if (
neighbor == null
or not expected.has(neighbor.get_instance_id())
or visited.has(neighbor.get_instance_id())
):
continue
visited[neighbor.get_instance_id()] = true
pending.append(neighbor)
_expect(
visited.size() == expected.size(),
"Only %d of %d controls are directionally reachable from %s."
% [visited.size(), expected.size(), start.name],
)
func _expect(condition: bool, message: String) -> void:
if not condition:
_failures.append(message)

View file

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

View file

@ -14,7 +14,12 @@ func _initialize() -> void:
func _run() -> void: func _run() -> void:
_validate_spatial_navigation() _validate_spatial_navigation()
_validate_strict_directional_navigation()
_validate_disabled_control_exclusion()
_validate_traversal_cycle()
_validate_controller_hierarchy_contract() _validate_controller_hierarchy_contract()
_validate_player_page_zone_contracts()
_validate_shop_navigation_contract()
_validate_four_by_three_centering() _validate_four_by_three_centering()
_validate_low_end_profile_contract() _validate_low_end_profile_contract()
print("Controller UI navigation validation: PASS") print("Controller UI navigation validation: PASS")
@ -38,16 +43,118 @@ func _validate_spatial_navigation() -> void:
host.queue_free() host.queue_free()
func _validate_strict_directional_navigation() -> void:
var host := Control.new()
root.add_child(host)
var origin := _make_button(host, "origin", Vector2(200.0, 200.0))
var mostly_below := _make_button(
host, "mostly_below", Vector2(250.0, 400.0)
)
var controls: Array[Control] = [origin, mostly_below]
ControllerFocusNavigationType.configure_spatial_neighbors(controls)
assert(origin.get_node(origin.focus_neighbor_right) == origin)
assert(origin.get_node(origin.focus_neighbor_top) == origin)
assert(origin.get_node(origin.focus_neighbor_bottom) == mostly_below)
host.queue_free()
func _validate_disabled_control_exclusion() -> void:
var host := Control.new()
root.add_child(host)
var left := _make_button(host, "left", Vector2(50.0, 100.0))
var disabled := _make_button(host, "disabled", Vector2(200.0, 100.0))
var right := _make_button(host, "right", Vector2(350.0, 100.0))
disabled.disabled = true
disabled.focus_next = disabled.get_path_to(right)
ControllerFocusNavigationType.configure_spatial_neighbors(
[left, disabled, right]
)
assert(left.get_node(left.focus_neighbor_right) == right)
assert(right.get_node(right.focus_neighbor_left) == left)
assert(disabled.focus_next.is_empty())
host.queue_free()
func _validate_traversal_cycle() -> void:
var host := Control.new()
root.add_child(host)
var first := _make_button(host, "first", Vector2(50.0, 50.0))
var second := _make_button(host, "second", Vector2(200.0, 50.0))
var third := _make_button(host, "third", Vector2(50.0, 150.0))
var controls: Array[Control] = [third, first, second]
ControllerFocusNavigationType.configure_spatial_neighbors(controls)
assert(first.get_node(first.focus_next) == second)
assert(second.get_node(second.focus_next) == third)
assert(third.get_node(third.focus_next) == first)
assert(first.get_node(first.focus_previous) == third)
host.queue_free()
func _validate_controller_hierarchy_contract() -> void: func _validate_controller_hierarchy_contract() -> void:
var source: String = FileAccess.get_file_as_string( var source: String = FileAccess.get_file_as_string(
"res://ui/player_menu.gd" "res://ui/player_menu.gd"
) )
assert(source.contains("ROLE_POINTER_MODIFIER"))
assert(source.contains("ROLE_CAMERA_ZOOM"))
assert(source.contains("_handle_controller_secondary_switch"))
assert(source.contains("ROLE_LB")) assert(source.contains("ROLE_LB"))
assert(source.contains("ROLE_RB")) assert(source.contains("ROLE_RB"))
assert(not source.contains("_handle_controller_secondary_switch"))
assert(source.contains("ControllerOwnership.INVENTORY_TABS"))
assert(source.contains("ControllerOwnership.SORT_FILTER"))
assert(source.contains("_reset_controller_zone_for_section"))
assert(source.contains("preserve_tab_zone"))
assert(source.contains("active_tab.call_deferred(\"grab_focus\")"))
assert(source.contains("CONTROLLER_PICKUP_HOLD_SECONDS"))
assert(source.contains("_reserve_main_navigation_for_page_switching")) assert(source.contains("_reserve_main_navigation_for_page_switching"))
assert(source.contains("configure_spatial_neighbors(candidates)"))
func _validate_player_page_zone_contracts() -> void:
for path: String in [
"res://ui/logbook_page.gd",
"res://ui/the_net_page.gd",
"res://ui/mail_page.gd",
"res://ui/profile_page.gd",
"res://ui/players_page.gd",
]:
var source: String = FileAccess.get_file_as_string(path)
assert(source.contains("func reset_controller_zone()"))
assert(source.contains("func handle_controller_input(event: InputEvent)"))
var profile_source: String = FileAccess.get_file_as_string(
"res://ui/profile_page.gd"
)
assert(profile_source.contains("ControllerZone.COLOR_PICKER"))
assert(profile_source.contains("_adjust_controller_color_gamut"))
assert(profile_source.contains("_customize_button.text = \"customize\""))
assert(profile_source.contains("_enter_controller_customization"))
assert(not profile_source.contains(
"if event.is_action_pressed(\"ui_down\"):\n"
+ "\t\t\t_controller_zone = ControllerZone.CATEGORIES"
))
assert(profile_source.contains(
"_reset_view_button.focus_mode = Control.FOCUS_NONE"
))
var preview_source: String = FileAccess.get_file_as_string(
"res://ui/profile_preview.gd"
)
assert(preview_source.contains("focus_mode = Control.FOCUS_NONE"))
assert(not preview_source.contains("grab_focus()"))
var mail_source: String = FileAccess.get_file_as_string(
"res://ui/mail_page.gd"
)
assert(mail_source.contains("_configure_compose_controller_navigation"))
assert(mail_source.contains("_set_compose_neighbors"))
var net_source: String = FileAccess.get_file_as_string(
"res://ui/the_net_page.gd"
)
assert(net_source.contains("ROLE_RIGHT_STICK_Y"))
func _validate_shop_navigation_contract() -> void:
var source: String = FileAccess.get_file_as_string(
"res://ui/fishing_shop.gd"
)
assert(source.contains("ROLE_LB"))
assert(source.contains("ROLE_RB"))
assert(source.contains("_configure_controller_focus"))
func _validate_four_by_three_centering() -> void: func _validate_four_by_three_centering() -> void:

View file

@ -0,0 +1,183 @@
extends SceneTree
const FileDialogControllerNavigationType = preload(
"res://ui/file_dialog_controller_navigation.gd"
)
const InterfaceFontControllerType = preload(
"res://ui/interface_font_controller.gd"
)
const OnScreenKeyboardType = preload("res://ui/on_screen_keyboard.gd")
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = Vector2i(1280, 720)
var dialog := FileDialog.new()
dialog.file_mode = FileDialog.FILE_MODE_OPEN_DIR
dialog.access = FileDialog.ACCESS_FILESYSTEM
dialog.use_native_dialog = false
root.add_child(dialog)
dialog.current_dir = "/tmp"
dialog.popup_centered(Vector2i(900, 540))
for _frame: int in 3:
await process_frame
FileDialogControllerNavigationType.configure(dialog)
await process_frame
var root_scope: Window = (
FileDialogControllerNavigationType.active_scope(dialog)
)
assert(root_scope == dialog)
var root_controls: Array[Control] = (
FileDialogControllerNavigationType.interactive_controls(root_scope)
)
var directory_list: ItemList
var create_folder_button: Button
var path_edit: LineEdit
var has_menu_button: bool = false
var has_cancel: bool = false
var has_select: bool = false
for control: Control in root_controls:
if (
control is ItemList
and control.accessibility_name == "Directories & Files:"
):
directory_list = control as ItemList
if control is LineEdit and control.accessibility_name == "Path:":
path_edit = control as LineEdit
var button := control as Button
if button != null:
create_folder_button = (
button
if button.tooltip_text == "Create a new folder."
else create_folder_button
)
has_cancel = has_cancel or button.text == "Cancel"
has_select = has_select or button.text == "Select Current Folder"
has_menu_button = has_menu_button or control is MenuButton
assert(directory_list != null)
assert(path_edit != null)
assert(create_folder_button != null)
assert(has_menu_button)
assert(has_cancel)
assert(has_select)
assert(dialog.gui_get_focus_owner() == directory_list)
_assert_directionally_reachable(directory_list, root_controls)
create_folder_button.pressed.emit()
for _frame: int in 3:
await process_frame
FileDialogControllerNavigationType.configure(dialog)
await process_frame
var folder_scope: Window = (
FileDialogControllerNavigationType.active_scope(dialog)
)
assert(folder_scope != null and folder_scope != dialog)
var folder_controls: Array[Control] = (
FileDialogControllerNavigationType.interactive_controls(folder_scope)
)
var folder_name_edit: LineEdit
var folder_cancel: Button
var folder_ok: Button
for control: Control in folder_controls:
if control is LineEdit:
folder_name_edit = control as LineEdit
var button := control as Button
if button != null and button.text == "Cancel":
folder_cancel = button
if button != null and button.text == "OK":
folder_ok = button
assert(folder_name_edit != null)
assert(folder_cancel != null)
assert(folder_ok != null)
assert(folder_scope.gui_get_focus_owner() == folder_name_edit)
_assert_directionally_reachable(folder_name_edit, folder_controls)
var keyboard := OnScreenKeyboardType.new()
root.add_child(keyboard)
keyboard.set_enabled(true)
folder_name_edit.grab_focus()
assert(keyboard.request_for_control(folder_name_edit))
assert(keyboard.is_open())
assert(keyboard.get_parent() == folder_scope)
keyboard.call("_close_keyboard", true)
var main_source: String = FileAccess.get_file_as_string(
"res://main/main.gd"
)
assert(main_source.contains("picker_scope != _data_folder_dialog"))
assert(main_source.contains("picker_scope.hide()"))
assert(main_source.contains("is_controller_text_entry_open()"))
dialog.queue_free()
keyboard.queue_free()
await process_frame
await _validate_compact_dialog()
print("File dialog controller navigation validation: PASS")
quit()
func _validate_compact_dialog() -> void:
root.size = Vector2i(640, 480)
var font_controller := InterfaceFontControllerType.new()
root.add_child(font_controller)
await process_frame
var dialog := FileDialog.new()
dialog.file_mode = FileDialog.FILE_MODE_OPEN_DIR
dialog.access = FileDialog.ACCESS_FILESYSTEM
dialog.use_native_dialog = false
root.add_child(dialog)
dialog.current_dir = "/tmp"
font_controller.popup_file_dialog(dialog)
for _frame: int in 4:
await process_frame
assert(dialog.size.x <= 616 and dialog.size.y <= 456)
var scope: Window = FileDialogControllerNavigationType.active_scope(dialog)
var controls: Array[Control] = (
FileDialogControllerNavigationType.interactive_controls(scope)
)
var directory_list: ItemList
for control: Control in controls:
if (
control is ItemList
and control.accessibility_name == "Directories & Files:"
):
directory_list = control as ItemList
break
assert(directory_list != null)
_assert_directionally_reachable(directory_list, controls)
dialog.queue_free()
font_controller.queue_free()
await process_frame
func _assert_directionally_reachable(
start: Control,
controls: Array[Control],
) -> void:
var expected: Dictionary[int, bool] = {}
for control: Control in controls:
expected[control.get_instance_id()] = true
var visited: Dictionary[int, bool] = {start.get_instance_id(): true}
var pending: Array[Control] = [start]
while not pending.is_empty():
var current: Control = pending.pop_front()
for path: NodePath in [
current.focus_neighbor_left,
current.focus_neighbor_right,
current.focus_neighbor_top,
current.focus_neighbor_bottom,
]:
var neighbor := current.get_node_or_null(path) as Control
if (
neighbor == null
or not expected.has(neighbor.get_instance_id())
or visited.has(neighbor.get_instance_id())
):
continue
visited[neighbor.get_instance_id()] = true
pending.append(neighbor)
assert(visited.size() == expected.size())

View file

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

View file

@ -0,0 +1,160 @@
extends SceneTree
const KeyboardMouseMappingManagerType = preload(
"res://settings/keyboard_mouse_mapping_manager.gd"
)
const KeyboardMouseMappingPanelType = preload(
"res://ui/keyboard_mouse_mapping_panel.gd"
)
const SettingsPanelScene = preload("res://ui/settings_panel.tscn")
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
var manager := KeyboardMouseMappingManagerType.new()
root.add_child(manager)
await process_frame
var defaults: Dictionary = manager.get_active_bindings()
assert(
defaults.size()
== KeyboardMouseMappingManagerType.ROLE_ORDER.size()
)
assert(
str(defaults[
str(KeyboardMouseMappingManagerType.ROLE_PRIMARY_ACTION)
].get("kind", "")) == "mouse_button"
)
assert(
str(defaults[
str(KeyboardMouseMappingManagerType.ROLE_CHAT)
].get("kind", "")) == "key"
)
var joypad_events_before: int = _event_count(&"jump", true)
var rebind_key := InputEventKey.new()
rebind_key.physical_keycode = KEY_R
rebind_key.pressed = true
var jump_binding: Dictionary = manager.binding_from_event(rebind_key)
assert(manager.validate_binding(jump_binding))
assert(manager.set_binding(
KeyboardMouseMappingManagerType.ROLE_JUMP,
jump_binding,
))
assert(manager.has_custom_mapping())
assert(FileAccess.file_exists(
KeyboardMouseMappingManagerType.MAPPING_PATH
))
assert(_has_physical_key(&"jump", KEY_R))
assert(_event_count(&"jump", true) == joypad_events_before)
var mouse_event := InputEventMouseButton.new()
mouse_event.button_index = MOUSE_BUTTON_XBUTTON1
mouse_event.pressed = true
var mouse_binding: Dictionary = manager.binding_from_event(mouse_event)
assert(manager.set_binding(
KeyboardMouseMappingManagerType.ROLE_PRIMARY_ACTION,
mouse_binding,
))
assert(_has_mouse_button(&"fish_primary", MOUSE_BUTTON_XBUTTON1))
assert(not _has_mouse_button(&"fish_primary", MOUSE_BUTTON_LEFT))
var joypad_event := InputEventJoypadButton.new()
joypad_event.button_index = JOY_BUTTON_A
joypad_event.pressed = true
assert(manager.binding_from_event(joypad_event).is_empty())
var panel := KeyboardMouseMappingPanelType.new()
root.add_child(panel)
panel.setup(manager)
panel.open_panel()
await process_frame
assert(_has_label_text(panel, "keyboard binds"))
var scrolls: Array[Node] = panel.find_children(
"", "ScrollContainer", true, false
)
assert(not scrolls.is_empty())
assert((scrolls.front() as ScrollContainer).follow_focus)
var first_binding := panel._binding_buttons.get(
KeyboardMouseMappingManagerType.ROLE_ORDER.front()
) as Button
var last_binding := panel._binding_buttons.get(
KeyboardMouseMappingManagerType.ROLE_ORDER.back()
) as Button
assert(first_binding.get_node(
first_binding.focus_neighbor_top
) == first_binding)
assert(last_binding.get_node(
last_binding.focus_neighbor_bottom
) == panel._close_button)
assert(panel._close_button.get_node(
panel._close_button.focus_neighbor_top
) == last_binding)
panel._begin_capture(KeyboardMouseMappingManagerType.ROLE_INTERACT)
var interact_key := InputEventKey.new()
interact_key.physical_keycode = KEY_F
interact_key.pressed = true
panel._input(interact_key)
assert(_has_physical_key(&"interact", KEY_F))
assert(not panel.is_capturing())
var settings_panel := SettingsPanelScene.instantiate() as SettingsPanel
root.add_child(settings_panel)
await process_frame
var controller_bubble := settings_panel.get_node(
"%ControllerMapping"
) as Button
var keyboard_bubble := settings_panel.get_node("%KeyboardMapping") as Button
assert(controller_bubble.text == "controller\nbinds")
assert(keyboard_bubble.text == "keyboard\nbinds")
assert(manager.reset_mapping())
assert(not manager.has_custom_mapping())
assert(not FileAccess.file_exists(
KeyboardMouseMappingManagerType.MAPPING_PATH
))
assert(_has_physical_key(&"jump", KEY_SPACE))
assert(_has_mouse_button(&"fish_primary", MOUSE_BUTTON_LEFT))
assert(_has_physical_key(&"open_chat", KEY_T))
assert(_event_count(&"jump", true) == joypad_events_before)
settings_panel.queue_free()
panel.queue_free()
manager.queue_free()
print("Keyboard and mouse mapping validation: PASS")
quit(0)
func _event_count(action: StringName, joypad: bool) -> int:
var result: int = 0
for event: InputEvent in InputMap.action_get_events(action):
if joypad == (
event is InputEventJoypadButton or event is InputEventJoypadMotion
):
result += 1
return result
func _has_physical_key(action: StringName, key: Key) -> bool:
for event: InputEvent in InputMap.action_get_events(action):
var key_event := event as InputEventKey
if key_event != null and key_event.physical_keycode == key:
return true
return false
func _has_mouse_button(action: StringName, button: MouseButton) -> bool:
for event: InputEvent in InputMap.action_get_events(action):
var mouse_event := event as InputEventMouseButton
if mouse_event != null and mouse_event.button_index == button:
return true
return false
func _has_label_text(parent: Node, expected: String) -> bool:
for node: Node in parent.find_children("", "Label", true, false):
if (node as Label).text == expected:
return true
return false

View file

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

View file

@ -249,6 +249,36 @@ func _validate_page() -> void:
assert(_detail_text(page).contains(LogbookCatalog.facts_for(bluegill))) assert(_detail_text(page).contains(LogbookCatalog.facts_for(bluegill)))
_validate_detail_field_fonts(page) _validate_detail_field_fonts(page)
_validate_handwritten_numeric_scale(page) _validate_handwritten_numeric_scale(page)
var detail_buttons: Array = page.get("_detail_buttons") as Array
assert(detail_buttons.size() == 4)
var portrait_detail := detail_buttons[0] as Button
var facts_detail := detail_buttons[1] as Button
var quality_detail := detail_buttons[2] as Button
var stats_detail := detail_buttons[3] as Button
assert(
portrait_detail.get_node(portrait_detail.focus_neighbor_right)
== facts_detail
)
assert(
portrait_detail.get_node(portrait_detail.focus_neighbor_bottom)
== quality_detail
)
assert(
facts_detail.get_node(facts_detail.focus_neighbor_left)
== portrait_detail
)
assert(
facts_detail.get_node(facts_detail.focus_neighbor_bottom)
== quality_detail
)
assert(
quality_detail.get_node(quality_detail.focus_neighbor_bottom)
== stats_detail
)
assert(
stats_detail.get_node(stats_detail.focus_neighbor_top)
== quality_detail
)
var portrait_button := page.get("_detail_portrait_button") as Button var portrait_button := page.get("_detail_portrait_button") as Button
assert(portrait_button != null) assert(portrait_button != null)
portrait_button.pressed.emit() portrait_button.pressed.emit()
@ -266,6 +296,29 @@ func _validate_page() -> void:
(page.get("_portrait_overlay_backdrop") as Button).pressed.emit() (page.get("_portrait_overlay_backdrop") as Button).pressed.emit()
await process_frame await process_frame
assert(not portrait_overlay.visible) assert(not portrait_overlay.visible)
facts_detail.pressed.emit()
await process_frame
var overlay_text := page.get("_portrait_overlay_text") as Label
assert(overlay_text.visible)
assert(
overlay_text.get_theme_font_size("font_size")
== LogbookPage.DETAIL_OVERLAY_TEXT_FONT_SIZE
)
assert(
overlay_text.horizontal_alignment
== HORIZONTAL_ALIGNMENT_LEFT
)
assert(
portrait_overlay.size.x
- overlay_text.size.x
<= float(
(LogbookPage.DETAIL_OVERLAY_EDGE_MARGIN
+ LogbookPage.DETAIL_OVERLAY_CONTENT_MARGIN) * 2
+ 8
)
)
(page.get("_portrait_overlay_backdrop") as Button).pressed.emit()
await process_frame
inventory.remove_catch_by_id(fish_catch.catch_id) inventory.remove_catch_by_id(fish_catch.catch_id)
await process_frame await process_frame

View file

@ -293,6 +293,7 @@ func open_chat() -> void:
# prediction would let a remote host continue the last held movement. # prediction would let a remote host continue the last held movement.
_session.submit_neutral_local_movement() _session.submit_neutral_local_movement()
_entry.show() _entry.show()
_configure_controller_focus()
_entry.virtual_keyboard_enabled = false _entry.virtual_keyboard_enabled = false
_hint.hide() _hint.hide()
_refresh_input_ownership() _refresh_input_ownership()
@ -328,6 +329,7 @@ func close_chat() -> void:
_entry.virtual_keyboard_enabled = false _entry.virtual_keyboard_enabled = false
_entry.release_focus() _entry.release_focus()
_entry.hide() _entry.hide()
_configure_controller_focus()
if _input_lock_applied: if _input_lock_applied:
_player.set_local_input_suppressed(INPUT_OWNER, false) _player.set_local_input_suppressed(INPUT_OWNER, false)
_fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, false) _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, false)
@ -421,9 +423,6 @@ func _unhandled_input(event: InputEvent) -> void:
): ):
open_chat() open_chat()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
elif event.keycode == KEY_T and not _opened and _available:
open_chat()
get_viewport().set_input_as_handled()
elif ( elif (
event.unicode == 47 event.unicode == 47
and not _opened and not _opened
@ -539,7 +538,7 @@ func _build_ui() -> void:
_collapse_button.name = "ChatCollapseButton" _collapse_button.name = "ChatCollapseButton"
_collapse_button.size = HANDLE_SIZE _collapse_button.size = HANDLE_SIZE
_collapse_button.mouse_filter = Control.MOUSE_FILTER_STOP _collapse_button.mouse_filter = Control.MOUSE_FILTER_STOP
_collapse_button.focus_mode = Control.FOCUS_ALL _collapse_button.focus_mode = Control.FOCUS_NONE
_collapse_button.z_index = 3 _collapse_button.z_index = 3
_collapse_button.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST _collapse_button.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
_collapse_button.expand_icon = true _collapse_button.expand_icon = true
@ -574,7 +573,7 @@ func _build_ui() -> void:
_height_button.name = "ChatHeightButton" _height_button.name = "ChatHeightButton"
_height_button.size = HANDLE_SIZE _height_button.size = HANDLE_SIZE
_height_button.mouse_filter = Control.MOUSE_FILTER_STOP _height_button.mouse_filter = Control.MOUSE_FILTER_STOP
_height_button.focus_mode = Control.FOCUS_ALL _height_button.focus_mode = Control.FOCUS_NONE
_height_button.z_index = 3 _height_button.z_index = 3
_height_button.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST _height_button.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
_height_button.expand_icon = true _height_button.expand_icon = true
@ -1475,6 +1474,7 @@ func _layout_presentation(animate: bool) -> void:
_height_button.position = height_position _height_button.position = height_position
_unread_indicator.position = unread_position _unread_indicator.position = unread_position
_hint.position = hint_position _hint.position = hint_position
_configure_controller_focus()
return return
_height_tween = create_tween().set_parallel(true) _height_tween = create_tween().set_parallel(true)
_height_tween.tween_property( _height_tween.tween_property(
@ -1521,10 +1521,92 @@ func _layout_presentation(animate: bool) -> void:
).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
_height_tween.finished.connect(func() -> void: _height_tween.finished.connect(func() -> void:
_scroll_history_to_bottom() _scroll_history_to_bottom()
_configure_controller_focus()
) )
_hint.position = hint_position _hint.position = hint_position
func _configure_controller_focus() -> void:
if _entry == null or _collapse_button == null or _height_button == null:
return
for button: Button in [_collapse_button, _height_button]:
button.focus_mode = (
Control.FOCUS_ALL if _opened else Control.FOCUS_NONE
)
if not _opened:
return
_entry.focus_mode = Control.FOCUS_ALL
if _mobile_mode:
_set_controller_neighbors(
_entry, _entry, _entry, _entry, _height_button
)
_set_controller_neighbors(
_height_button,
_height_button,
_collapse_button,
_entry,
_height_button,
)
_set_controller_neighbors(
_collapse_button,
_height_button,
_collapse_button,
_entry,
_collapse_button,
)
return
var panel_side: Control = _entry
if _dock_right:
_set_controller_neighbors(
_entry, _collapse_button, _entry, _entry, _entry
)
_set_controller_neighbors(
_collapse_button,
_collapse_button,
panel_side,
_height_button,
_collapse_button,
)
_set_controller_neighbors(
_height_button,
_height_button,
panel_side,
_height_button,
_collapse_button,
)
else:
_set_controller_neighbors(
_entry, _entry, _collapse_button, _entry, _entry
)
_set_controller_neighbors(
_collapse_button,
panel_side,
_collapse_button,
_height_button,
_collapse_button,
)
_set_controller_neighbors(
_height_button,
panel_side,
_height_button,
_height_button,
_collapse_button,
)
func _set_controller_neighbors(
control: Control,
left: Control,
right: Control,
top: Control,
bottom: Control,
) -> void:
control.focus_neighbor_left = control.get_path_to(left)
control.focus_neighbor_right = control.get_path_to(right)
control.focus_neighbor_top = control.get_path_to(top)
control.focus_neighbor_bottom = control.get_path_to(bottom)
func _scroll_history_to_bottom() -> void: func _scroll_history_to_bottom() -> void:
if _history == null: if _history == null:
return return

View file

@ -186,12 +186,16 @@ func _configure_focus() -> void:
) )
_confirm_button.focus_neighbor_left = confirm_to_cancel _confirm_button.focus_neighbor_left = confirm_to_cancel
_confirm_button.focus_neighbor_right = confirm_to_cancel _confirm_button.focus_neighbor_right = confirm_to_cancel
_confirm_button.focus_neighbor_top = confirm_to_cancel _confirm_button.focus_neighbor_top = _confirm_button.get_path_to(
_confirm_button.focus_neighbor_bottom = confirm_to_cancel _confirm_button
)
_confirm_button.focus_neighbor_bottom = _confirm_button.focus_neighbor_top
_cancel_button.focus_neighbor_left = cancel_to_confirm _cancel_button.focus_neighbor_left = cancel_to_confirm
_cancel_button.focus_neighbor_right = cancel_to_confirm _cancel_button.focus_neighbor_right = cancel_to_confirm
_cancel_button.focus_neighbor_top = cancel_to_confirm _cancel_button.focus_neighbor_top = _cancel_button.get_path_to(
_cancel_button.focus_neighbor_bottom = cancel_to_confirm _cancel_button
)
_cancel_button.focus_neighbor_bottom = _cancel_button.focus_neighbor_top
func _set_interactive(interactive: bool) -> void: func _set_interactive(interactive: bool) -> void:
@ -211,6 +215,8 @@ func _set_interactive(interactive: bool) -> void:
if interactive if interactive
else Control.MOUSE_FILTER_IGNORE else Control.MOUSE_FILTER_IGNORE
) )
if interactive:
_configure_focus()
func _finish_transition_in( func _finish_transition_in(

View file

@ -169,10 +169,12 @@ func _on_choice_gui_input(event: InputEvent, index: int) -> void:
close_choices() close_choices()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
elif event.is_action_pressed("ui_up"): elif event.is_action_pressed("ui_up"):
_choice_buttons[posmod(index - 1, _choice_buttons.size())].grab_focus() _choice_buttons[maxi(index - 1, 0)].grab_focus()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
elif event.is_action_pressed("ui_down"): elif event.is_action_pressed("ui_down"):
_choice_buttons[(index + 1) % _choice_buttons.size()].grab_focus() _choice_buttons[mini(
index + 1, _choice_buttons.size() - 1
)].grab_focus()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()

View file

@ -29,6 +29,7 @@ var _selected: bool = false
func _ready() -> void: func _ready() -> void:
toggle_mode = true toggle_mode = true
set_meta(&"controller_focus_inversion_disabled", true)
_selected = button_pressed _selected = button_pressed
add_theme_font_override("font", UtilityPageStyle.TuffyFont) add_theme_font_override("font", UtilityPageStyle.TuffyFont)
add_theme_font_size_override("font_size", FONT_SIZE) add_theme_font_size_override("font_size", FONT_SIZE)

View file

@ -3,16 +3,22 @@ extends RefCounted
const PERPENDICULAR_WEIGHT: float = 2.5 const PERPENDICULAR_WEIGHT: float = 2.5
const MINIMUM_FORWARD_DISTANCE: float = 0.5 const MINIMUM_FORWARD_DISTANCE: float = 0.5
const MAXIMUM_DIRECTION_SLOPE: float = 1.0
static func configure_spatial_neighbors(controls: Array) -> void: static func configure_spatial_neighbors(controls: Array) -> void:
var candidates: Array[Control] = [] var candidates: Array[Control] = []
for item: Variant in controls: for item: Variant in controls:
var control := item as Control var control := item as Control
if control == null or not control.is_visible_in_tree(): if not is_focusable(control):
if control != null:
_clear_neighbors(control)
continue continue
candidates.append(control) candidates.append(control)
var traversal_order: Array[Control] = candidates.duplicate()
traversal_order.sort_custom(_sort_by_position)
for control: Control in candidates: for control: Control in candidates:
_enable_ancestor_scroll_follow(control)
control.focus_neighbor_left = _neighbor_path( control.focus_neighbor_left = _neighbor_path(
control, candidates, Vector2.LEFT control, candidates, Vector2.LEFT
) )
@ -25,6 +31,80 @@ static func configure_spatial_neighbors(controls: Array) -> void:
control.focus_neighbor_bottom = _neighbor_path( control.focus_neighbor_bottom = _neighbor_path(
control, candidates, Vector2.DOWN control, candidates, Vector2.DOWN
) )
var traversal_index: int = traversal_order.find(control)
control.focus_previous = control.get_path_to(
traversal_order[wrapi(
traversal_index - 1,
0,
traversal_order.size(),
)]
)
control.focus_next = control.get_path_to(
traversal_order[wrapi(
traversal_index + 1,
0,
traversal_order.size(),
)]
)
static func configure_traversal(controls: Array) -> void:
var candidates: Array[Control] = []
for item: Variant in controls:
var control := item as Control
if is_focusable(control):
candidates.append(control)
elif control != null:
control.focus_previous = NodePath()
control.focus_next = NodePath()
if candidates.is_empty():
return
for index: int in candidates.size():
var control: Control = candidates[index]
_enable_ancestor_scroll_follow(control)
control.focus_previous = control.get_path_to(
candidates[wrapi(index - 1, 0, candidates.size())]
)
control.focus_next = control.get_path_to(
candidates[wrapi(index + 1, 0, candidates.size())]
)
static func is_focusable(control: Control) -> bool:
if (
control == null
or not control.is_visible_in_tree()
or control.focus_mode not in [Control.FOCUS_CLICK, Control.FOCUS_ALL]
):
return false
var button := control as BaseButton
return button == null or not button.disabled
static func _clear_neighbors(control: Control) -> void:
control.focus_neighbor_left = NodePath()
control.focus_neighbor_right = NodePath()
control.focus_neighbor_top = NodePath()
control.focus_neighbor_bottom = NodePath()
control.focus_previous = NodePath()
control.focus_next = NodePath()
static func _enable_ancestor_scroll_follow(control: Control) -> void:
var ancestor: Node = control.get_parent()
while ancestor != null:
var scroll := ancestor as ScrollContainer
if scroll != null:
scroll.follow_focus = true
ancestor = ancestor.get_parent()
static func _sort_by_position(first: Control, second: Control) -> bool:
var first_center: Vector2 = first.get_global_rect().get_center()
var second_center: Vector2 = second.get_global_rect().get_center()
if not is_equal_approx(first_center.y, second_center.y):
return first_center.y < second_center.y
return first_center.x < second_center.x
static func _neighbor_path( static func _neighbor_path(
@ -36,7 +116,7 @@ static func _neighbor_path(
var best: Control = null var best: Control = null
var best_score: float = INF var best_score: float = INF
for candidate: Control in candidates: for candidate: Control in candidates:
if candidate == origin or candidate.focus_mode == Control.FOCUS_NONE: if candidate == origin or not is_focusable(candidate):
continue continue
var delta: Vector2 = ( var delta: Vector2 = (
candidate.get_global_rect().get_center() - origin_center candidate.get_global_rect().get_center() - origin_center
@ -45,6 +125,8 @@ static func _neighbor_path(
if forward_distance <= MINIMUM_FORWARD_DISTANCE: if forward_distance <= MINIMUM_FORWARD_DISTANCE:
continue continue
var perpendicular_distance: float = absf(delta.cross(direction)) var perpendicular_distance: float = absf(delta.cross(direction))
if perpendicular_distance > forward_distance * MAXIMUM_DIRECTION_SLOPE:
continue
var score: float = ( var score: float = (
forward_distance forward_distance
+ perpendicular_distance * PERPENDICULAR_WEIGHT + perpendicular_distance * PERPENDICULAR_WEIGHT
@ -53,5 +135,5 @@ static func _neighbor_path(
best = candidate best = candidate
best_score = score best_score = score
if best == null: if best == null:
return NodePath() return origin.get_path_to(origin)
return origin.get_path_to(best) return origin.get_path_to(best)

View file

@ -70,11 +70,33 @@ func _set_controller_active(active: bool) -> void:
if _controller_active == active: if _controller_active == active:
return return
_controller_active = active _controller_active = active
_apply_to_focus(get_viewport().gui_get_focus_owner()) var focus_owner: Control = get_viewport().gui_get_focus_owner()
_apply_to_focus(focus_owner)
if _controller_active:
_queue_focus_visibility_update(focus_owner)
func _on_focus_changed(control: Control) -> void: func _on_focus_changed(control: Control) -> void:
_apply_to_focus(control) _apply_to_focus(control)
if _controller_active:
_queue_focus_visibility_update(control)
func _queue_focus_visibility_update(control: Control) -> void:
if control == null:
return
_ensure_focus_visible.call_deferred(control)
func _ensure_focus_visible(control: Control) -> void:
if not is_instance_valid(control) or not control.is_visible_in_tree():
return
var ancestor: Node = control.get_parent()
while ancestor != null:
var scroll := ancestor as ScrollContainer
if scroll != null and scroll.is_visible_in_tree():
scroll.ensure_control_visible(control)
ancestor = ancestor.get_parent()
func _apply_to_focus(control: Control) -> void: func _apply_to_focus(control: Control) -> void:

View file

@ -15,6 +15,7 @@ var _binding_buttons: Dictionary = {}
var _controller_label: Label var _controller_label: Label
var _instruction_label: Label var _instruction_label: Label
var _progress_label: Label var _progress_label: Label
var _mapping_scroll: ScrollContainer
var _auto_map_button: Button var _auto_map_button: Button
var _reset_button: Button var _reset_button: Button
var _close_button: Button var _close_button: Button
@ -258,17 +259,18 @@ func _build_interface() -> void:
) )
page.add_child(_progress_label) page.add_child(_progress_label)
var scroll := ScrollContainer.new() _mapping_scroll = ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL _mapping_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED _mapping_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
page.add_child(scroll) _mapping_scroll.follow_focus = true
page.add_child(_mapping_scroll)
var role_grid := GridContainer.new() var role_grid := GridContainer.new()
role_grid.columns = 2 role_grid.columns = 2
role_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL role_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
role_grid.add_theme_constant_override("h_separation", 12) role_grid.add_theme_constant_override("h_separation", 12)
role_grid.add_theme_constant_override("v_separation", 7) role_grid.add_theme_constant_override("v_separation", 7)
scroll.add_child(role_grid) _mapping_scroll.add_child(role_grid)
for role: StringName in ControllerMappingManagerType.ROLE_ORDER: for role: StringName in ControllerMappingManagerType.ROLE_ORDER:
var role_label := Label.new() var role_label := Label.new()
@ -325,6 +327,65 @@ func _build_interface() -> void:
UtilityPageStyleType.apply_ocean_button(_close_button) UtilityPageStyleType.apply_ocean_button(_close_button)
_close_button.pressed.connect(close_panel) _close_button.pressed.connect(close_panel)
footer.add_child(_close_button) footer.add_child(_close_button)
_configure_mapping_navigation()
func _configure_mapping_navigation() -> void:
var buttons: Array[Button] = []
for role: StringName in ControllerMappingManagerType.ROLE_ORDER:
var binding_button := _binding_buttons.get(role) as Button
if binding_button != null:
buttons.append(binding_button)
if buttons.is_empty():
return
for index: int in buttons.size():
var button: Button = buttons[index]
var previous: Control = buttons[index - 1] if index > 0 else button
var next: Control = (
buttons[index + 1]
if index < buttons.size() - 1
else _close_button
)
_set_focus_neighbors(button, button, button, previous, next)
button.focus_previous = button.get_path_to(previous)
button.focus_next = button.get_path_to(next)
var last_button: Button = buttons.back()
_set_focus_neighbors(
_auto_map_button,
_auto_map_button,
_reset_button,
last_button,
_auto_map_button,
)
_set_focus_neighbors(
_reset_button,
_auto_map_button,
_close_button,
last_button,
_reset_button,
)
_set_focus_neighbors(
_close_button,
_reset_button,
_close_button,
last_button,
_close_button,
)
_close_button.focus_previous = _close_button.get_path_to(last_button)
_close_button.focus_next = _close_button.get_path_to(_auto_map_button)
func _set_focus_neighbors(
control: Control,
left: Control,
right: Control,
top: Control,
bottom: Control,
) -> void:
control.focus_neighbor_left = control.get_path_to(left)
control.focus_neighbor_right = control.get_path_to(right)
control.focus_neighbor_top = control.get_path_to(top)
control.focus_neighbor_bottom = control.get_path_to(bottom)
func _begin_auto_map() -> void: func _begin_auto_map() -> void:

View file

@ -0,0 +1,124 @@
class_name FileDialogControllerNavigation
extends RefCounted
const ControllerFocusNavigationType = preload(
"res://ui/controller_focus_navigation.gd"
)
static func configure(dialog: FileDialog) -> void:
if dialog == null or not dialog.visible or not dialog.is_inside_tree():
return
var scope: Window = active_scope(dialog)
if scope == null:
return
configure_scope(scope)
static func configure_scope(
scope: Window,
preferred_control: Control = null,
) -> void:
if scope == null or not scope.visible or not scope.is_inside_tree():
return
var controls: Array[Control] = []
_collect_interactive_controls(scope, scope, controls)
if controls.is_empty():
return
ControllerFocusNavigationType.configure_spatial_neighbors(controls)
var focus_owner: Control = scope.gui_get_focus_owner()
if controls.has(preferred_control):
if focus_owner != preferred_control:
preferred_control.grab_focus()
return
if focus_owner != null and controls.has(focus_owner):
return
var preferred: Control = _preferred_initial_control(scope, controls)
if preferred != null:
preferred.grab_focus()
static func active_scope(dialog: FileDialog) -> Window:
if dialog == null or not dialog.visible:
return null
return _deepest_exclusive_window(dialog)
static func interactive_controls(scope: Window) -> Array[Control]:
var controls: Array[Control] = []
if scope != null:
_collect_interactive_controls(scope, scope, controls)
return controls
static func _deepest_exclusive_window(window: Window) -> Window:
for child: Node in window.get_children(true):
var child_window := child as Window
if (
child_window == null
or not child_window.visible
or not child_window.exclusive
):
continue
return _deepest_exclusive_window(child_window)
return window
static func _collect_interactive_controls(
node: Node,
scope: Window,
output: Array[Control],
) -> void:
for child: Node in node.get_children(true):
var child_window := child as Window
if child_window != null and child_window != scope:
continue
var control := child as Control
if control != null and _is_controller_interactive(control):
# Godot marks some visible FileDialog toolbar and menu buttons as
# accessibility-only. Promote those genuine controls for controller
# focus while leaving labels, scrollbars, and splitters untouched.
control.focus_mode = Control.FOCUS_ALL
output.append(control)
_collect_interactive_controls(child, scope, output)
static func _is_controller_interactive(control: Control) -> bool:
if (
control == null
or not control.is_visible_in_tree()
or control.size.x < 8.0
or control.size.y < 8.0
or control is ScrollBar
):
return false
var button := control as BaseButton
if button != null:
return not button.disabled
return (
control is LineEdit
or control is TextEdit
or control is ItemList
or control is Tree
)
static func _preferred_initial_control(
scope: Window,
controls: Array[Control],
) -> Control:
if scope is FileDialog:
for control: Control in controls:
if control is ItemList and control.accessibility_name == (
"Directories & Files:"
):
return control
for control: Control in controls:
if control is LineEdit or control is TextEdit:
return control
if scope is ConfirmationDialog:
var confirmation := scope as ConfirmationDialog
var ok_button: Button = confirmation.get_ok_button()
if controls.has(ok_button):
return ok_button
return controls.front()

View file

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

View file

@ -28,6 +28,12 @@ const ShopInteractionType = preload(
const UtilityPageStyleType = preload("res://ui/utility_page_style.gd") const UtilityPageStyleType = preload("res://ui/utility_page_style.gd")
const OrganizerTabType = preload("res://ui/components/organizer_tab.gd") const OrganizerTabType = preload("res://ui/components/organizer_tab.gd")
const UIMotionType = preload("res://ui/ui_motion.gd") const UIMotionType = preload("res://ui/ui_motion.gd")
const ControllerMappingManagerType = preload(
"res://settings/controller_mapping_manager.gd"
)
const ControllerFocusNavigationType = preload(
"res://ui/controller_focus_navigation.gd"
)
const FALLBACK_SUPPLY_ICON: Texture2D = preload( const FALLBACK_SUPPLY_ICON: Texture2D = preload(
"res://ui/icons/pictograms/x_light.png" "res://ui/icons/pictograms/x_light.png"
) )
@ -168,6 +174,7 @@ var _cooler_page_active: bool = false
var _cooler_modal_open: bool = false var _cooler_modal_open: bool = false
var _shop_section: ShopSection = ShopSection.UPGRADES var _shop_section: ShopSection = ShopSection.UPGRADES
var _shop_tabs: Array[OrganizerTab] = [] var _shop_tabs: Array[OrganizerTab] = []
var _controller_mapping_manager: ControllerMappingManagerType
func _ready() -> void: func _ready() -> void:
@ -180,6 +187,53 @@ func _ready() -> void:
_cooler_purchase.pressed.connect(_purchase_cooler_capacity) _cooler_purchase.pressed.connect(_purchase_cooler_capacity)
func setup_controller_mapping(
mapping_manager: ControllerMappingManagerType,
) -> void:
_controller_mapping_manager = mapping_manager
func _input(event: InputEvent) -> void:
if (
not visible
or _closing
or _cooler_page_active
or _cooler_modal_open
):
return
var button_event := event as InputEventJoypadButton
if button_event == null:
return
var uses_left_bumper: bool = (
_controller_mapping_manager.event_uses_role(
event,
ControllerMappingManagerType.ROLE_LB,
)
if _controller_mapping_manager != null
else button_event.button_index == JOY_BUTTON_LEFT_SHOULDER
)
var uses_right_bumper: bool = (
_controller_mapping_manager.event_uses_role(
event,
ControllerMappingManagerType.ROLE_RB,
)
if _controller_mapping_manager != null
else button_event.button_index == JOY_BUTTON_RIGHT_SHOULDER
)
if not (uses_left_bumper or uses_right_bumper):
return
get_viewport().set_input_as_handled()
if not button_event.pressed or _transaction_in_progress:
return
var direction: int = -1 if uses_left_bumper else 1
var next_section: int = wrapi(
int(_shop_section) + direction,
0,
ShopSection.size(),
)
_select_shop_section(next_section, true)
func _request_shop_cooler() -> bool: func _request_shop_cooler() -> bool:
if _transaction_in_progress: if _transaction_in_progress:
_set_feedback("Finish the current transaction first.") _set_feedback("Finish the current transaction first.")
@ -193,6 +247,7 @@ func _request_shop_cooler() -> bool:
func _focus_shop_section() -> void: func _focus_shop_section() -> void:
_set_feedback("") _set_feedback("")
_configure_controller_focus()
if _shop_section == ShopSection.UPGRADES: if _shop_section == ShopSection.UPGRADES:
_reel_purchase.grab_focus() _reel_purchase.grab_focus()
return return
@ -264,6 +319,31 @@ func _select_shop_section(section_index: int, focus_content: bool) -> void:
_refresh_supplies() _refresh_supplies()
if focus_content and visible: if focus_content and visible:
_focus_shop_section() _focus_shop_section()
call_deferred("_configure_controller_focus")
func _configure_controller_focus() -> void:
if not visible or _cooler_page_active:
return
var candidates: Array[Control] = []
_collect_controller_focusables(self, candidates)
ControllerFocusNavigationType.configure_spatial_neighbors(candidates)
func _collect_controller_focusables(
root: Node,
output: Array[Control],
) -> void:
for child: Node in root.get_children():
var control := child as Control
if control != null and not control.is_visible_in_tree():
continue
if (
ControllerFocusNavigationType.is_focusable(control)
and not control is ScrollBar
):
output.append(control)
_collect_controller_focusables(child, output)
func _update_shop_tab_selection() -> void: func _update_shop_tab_selection() -> void:
@ -410,6 +490,7 @@ func open_shop() -> bool:
_shop_tab_bar.show() _shop_tab_bar.show()
_select_shop_section(ShopSection.UPGRADES, false) _select_shop_section(ShopSection.UPGRADES, false)
_refresh_all() _refresh_all()
call_deferred("_configure_controller_focus")
_reel_purchase.grab_focus() _reel_purchase.grab_focus()
menu_visibility_changed.emit(true) menu_visibility_changed.emit(true)
return true return true

View file

@ -48,6 +48,9 @@ const PlayerSettingsManagerType = preload(
const ControllerMappingManagerType = preload( const ControllerMappingManagerType = preload(
"res://settings/controller_mapping_manager.gd" "res://settings/controller_mapping_manager.gd"
) )
const KeyboardMouseMappingManagerType = preload(
"res://settings/keyboard_mouse_mapping_manager.gd"
)
const WorldTimeServiceType = preload("res://world/world_time_service.gd") const WorldTimeServiceType = preload("res://world/world_time_service.gd")
const WorldWeatherServiceType = preload( const WorldWeatherServiceType = preload(
"res://world/world_weather_service.gd" "res://world/world_weather_service.gd"
@ -198,6 +201,7 @@ var _shared_trigger_rest_by_device: Dictionary[int, float] = {}
var _controller_mapping_manager: ControllerMappingManagerType var _controller_mapping_manager: ControllerMappingManagerType
var _settings_manager: PlayerSettingsManagerType var _settings_manager: PlayerSettingsManagerType
var _controller_text_entry_request: Callable var _controller_text_entry_request: Callable
var _controller_text_entry_is_open: Callable
func _ready() -> void: func _ready() -> void:
@ -251,8 +255,28 @@ func _ready() -> void:
Input.joy_connection_changed.connect(_on_controller_connection_changed) Input.joy_connection_changed.connect(_on_controller_connection_changed)
func set_controller_text_entry_request(request: Callable) -> void: func set_controller_text_entry_request(
request: Callable,
is_open: Callable = Callable(),
) -> void:
_controller_text_entry_request = request _controller_text_entry_request = request
_controller_text_entry_is_open = is_open
func request_controller_text_entry_for(control: Control = null) -> bool:
return (
bool(_controller_text_entry_request.call(control))
if _controller_text_entry_request.is_valid()
else false
)
func is_controller_text_entry_open() -> bool:
return (
bool(_controller_text_entry_is_open.call())
if _controller_text_entry_is_open.is_valid()
else false
)
func setup( func setup(
@ -603,10 +627,7 @@ func _handle_controller_chat_controls(event: InputEvent) -> bool:
_chat_ui.refocus_gameplay() _chat_ui.refocus_gameplay()
return true return true
if accept_pressed: if accept_pressed:
if ( if request_controller_text_entry_for():
_controller_text_entry_request.is_valid()
and bool(_controller_text_entry_request.call())
):
return true return true
return _chat_ui.request_virtual_keyboard() return _chat_ui.request_virtual_keyboard()
return false return false
@ -1175,12 +1196,22 @@ func setup_controller_mapping(
_emote_radial_menu.setup_controller_mapping(_controller_mapping_manager) _emote_radial_menu.setup_controller_mapping(_controller_mapping_manager)
_quick_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) _player_menu.setup_controller_mapping(_controller_mapping_manager)
_fishing_shop.setup_controller_mapping(_controller_mapping_manager)
for panel: SettingsPanelType in [ for panel: SettingsPanelType in [
_title_settings_panel, _pause_settings_panel _title_settings_panel, _pause_settings_panel
]: ]:
panel.setup_controller_mapping(_controller_mapping_manager) panel.setup_controller_mapping(_controller_mapping_manager)
func setup_keyboard_mouse_mapping(
mapping_manager: KeyboardMouseMappingManagerType,
) -> void:
for panel: SettingsPanelType in [
_title_settings_panel, _pause_settings_panel
]:
panel.setup_keyboard_mouse_mapping(mapping_manager)
func is_controller_mapping_capturing() -> bool: func is_controller_mapping_capturing() -> bool:
return ( return (
_title_settings_panel.is_controller_mapping_capturing() _title_settings_panel.is_controller_mapping_capturing()
@ -1188,6 +1219,13 @@ func is_controller_mapping_capturing() -> bool:
) )
func is_input_mapping_capturing() -> bool:
return (
_title_settings_panel.is_input_mapping_capturing()
or _pause_settings_panel.is_input_mapping_capturing()
)
func _process(delta: float) -> void: func _process(delta: float) -> void:
_poll_virtual_mouse_controller_state() _poll_virtual_mouse_controller_state()
_update_virtual_mouse(delta) _update_virtual_mouse(delta)
@ -1199,7 +1237,7 @@ func _update_controller_menu_scroll(delta: float) -> void:
if ( if (
_controller_mapping_manager == null _controller_mapping_manager == null
or _virtual_mouse_active or _virtual_mouse_active
or is_controller_mapping_capturing() or is_input_mapping_capturing()
): ):
return return
var stick_y: float = _controller_menu_scroll_axis() var stick_y: float = _controller_menu_scroll_axis()

View file

@ -123,10 +123,10 @@ func begin_controller_placement(
var slot: BubbleHotbarSlotType = _slots[index] var slot: BubbleHotbarSlotType = _slots[index]
slot.focus_mode = Control.FOCUS_ALL slot.focus_mode = Control.FOCUS_ALL
slot.focus_neighbor_left = slot.get_path_to( slot.focus_neighbor_left = slot.get_path_to(
_slots[wrapi(index - 1, 0, slot_count)] _slots[maxi(index - 1, 0)]
) )
slot.focus_neighbor_right = slot.get_path_to( slot.focus_neighbor_right = slot.get_path_to(
_slots[wrapi(index + 1, 0, slot_count)] _slots[mini(index + 1, slot_count - 1)]
) )
slot.focus_neighbor_top = slot.get_path_to(slot) slot.focus_neighbor_top = slot.get_path_to(slot)
slot.focus_neighbor_bottom = slot.get_path_to(slot) slot.focus_neighbor_bottom = slot.get_path_to(slot)
@ -158,10 +158,10 @@ func begin_controller_management(initial_slot: int) -> void:
var slot: BubbleHotbarSlotType = _slots[index] var slot: BubbleHotbarSlotType = _slots[index]
slot.focus_mode = Control.FOCUS_ALL slot.focus_mode = Control.FOCUS_ALL
slot.focus_neighbor_left = slot.get_path_to( slot.focus_neighbor_left = slot.get_path_to(
_slots[wrapi(index - 1, 0, slot_count)] _slots[maxi(index - 1, 0)]
) )
slot.focus_neighbor_right = slot.get_path_to( slot.focus_neighbor_right = slot.get_path_to(
_slots[wrapi(index + 1, 0, slot_count)] _slots[mini(index + 1, slot_count - 1)]
) )
slot.focus_neighbor_top = slot.get_path_to(slot) slot.focus_neighbor_top = slot.get_path_to(slot)
slot.focus_neighbor_bottom = slot.get_path_to(slot) slot.focus_neighbor_bottom = slot.get_path_to(slot)

View file

@ -1,14 +1,20 @@
class_name InterfaceFontController class_name InterfaceFontController
extends Node extends Node
const FileDialogControllerNavigationType = preload(
"res://ui/file_dialog_controller_navigation.gd"
)
const STANDARD_FONT: Font = preload("res://ui/fonts/Tuffy_Bold.otf") const STANDARD_FONT: Font = preload("res://ui/fonts/Tuffy_Bold.otf")
const COMPACT_FILE_DIALOG_LIMIT := Vector2i(800, 600) const COMPACT_FILE_DIALOG_LIMIT := Vector2i(800, 600)
const COMPACT_FILE_DIALOG_MARGIN := Vector2i(12, 12) const COMPACT_FILE_DIALOG_MARGIN := Vector2i(12, 12)
const COMPACT_FILE_DIALOG_FONT_SIZE: int = 17 const COMPACT_FILE_DIALOG_FONT_SIZE: int = 17
var _controller_text_entry_request: Callable
var _controller_text_entry_is_open: Callable
var _game_theme: Theme = preload("res://ui/game_theme.tres") var _game_theme: Theme = preload("res://ui/game_theme.tres")
var _utility_theme: Theme var _utility_theme: Theme
var _compact_file_dialog_theme: Theme var _compact_file_dialog_theme: Theme
var _tracked_file_dialogs: Dictionary[int, WeakRef] = {}
func _ready() -> void: func _ready() -> void:
@ -23,6 +29,14 @@ func enforce_standard_font() -> void:
_utility_theme.default_font = STANDARD_FONT _utility_theme.default_font = STANDARD_FONT
func set_controller_text_entry_request(
request: Callable,
is_open: Callable = Callable(),
) -> void:
_controller_text_entry_request = request
_controller_text_entry_is_open = is_open
func set_readable_font_enabled(_enabled: bool) -> void: func set_readable_font_enabled(_enabled: bool) -> void:
# Compatibility seam for callers compiled against the old setting. # Compatibility seam for callers compiled against the old setting.
enforce_standard_font() enforce_standard_font()
@ -47,6 +61,7 @@ func apply_utility_theme(themed_node: Node) -> void:
func popup_file_dialog(dialog: FileDialog) -> void: func popup_file_dialog(dialog: FileDialog) -> void:
if dialog == null or not dialog.is_inside_tree(): if dialog == null or not dialog.is_inside_tree():
return return
_tracked_file_dialogs[dialog.get_instance_id()] = weakref(dialog)
var host_window: Window = dialog.get_parent().get_window() var host_window: Window = dialog.get_parent().get_window()
var host_size: Vector2i = host_window.size var host_size: Vector2i = host_window.size
var compact: bool = ( var compact: bool = (
@ -83,6 +98,55 @@ func popup_file_dialog(dialog: FileDialog) -> void:
) )
func _process(_delta: float) -> void:
if (
_controller_text_entry_is_open.is_valid()
and bool(_controller_text_entry_is_open.call())
):
return
for instance_id: int in _tracked_file_dialogs.keys():
var reference: WeakRef = _tracked_file_dialogs[instance_id]
var dialog := reference.get_ref() as FileDialog
if dialog == null or not is_instance_valid(dialog):
_tracked_file_dialogs.erase(instance_id)
continue
if not dialog.visible:
continue
FileDialogControllerNavigationType.configure(dialog)
_connect_file_dialog_text_controls(dialog)
func _connect_file_dialog_text_controls(dialog: FileDialog) -> void:
var scope: Window = FileDialogControllerNavigationType.active_scope(dialog)
for control: Control in (
FileDialogControllerNavigationType.interactive_controls(scope)
):
if not (control is LineEdit or control is TextEdit):
continue
var callback := _on_file_dialog_text_gui_input.bind(control)
if not control.gui_input.is_connected(callback):
control.gui_input.connect(callback)
func _on_file_dialog_text_gui_input(
event: InputEvent,
control: Control,
) -> void:
var button_event := event as InputEventJoypadButton
if (
button_event == null
or not button_event.pressed
or (
button_event.button_index != JOY_BUTTON_A
and not event.is_action_pressed("ui_accept")
)
or not _controller_text_entry_request.is_valid()
):
return
if bool(_controller_text_entry_request.call(control)):
control.accept_event()
func _finalize_compact_file_dialog( func _finalize_compact_file_dialog(
dialog: FileDialog, dialog: FileDialog,
host_size: Vector2i, host_size: Vector2i,

View file

@ -0,0 +1,400 @@
class_name KeyboardMouseMappingPanel
extends Control
signal closed
const KeyboardMouseMappingManagerType = preload(
"res://settings/keyboard_mouse_mapping_manager.gd"
)
const UtilityPageStyleType = preload("res://ui/utility_page_style.gd")
var _mapping_manager: KeyboardMouseMappingManagerType
var _binding_buttons: Dictionary = {}
var _progress_label: Label
var _mapping_scroll: ScrollContainer
var _reset_button: Button
var _close_button: Button
var _capturing_role: StringName = &""
func _ready() -> void:
set_process_input(true)
_build_interface()
hide()
func setup(mapping_manager: KeyboardMouseMappingManagerType) -> void:
_mapping_manager = mapping_manager
if not _mapping_manager.mapping_changed.is_connected(_refresh_bindings):
_mapping_manager.mapping_changed.connect(_refresh_bindings)
_refresh_bindings()
func open_panel() -> void:
if _mapping_manager == null:
return
_cancel_capture()
show()
_refresh_bindings()
var first_button := _binding_buttons.get(
KeyboardMouseMappingManagerType.ROLE_ORDER.front()
) as Button
if first_button != null:
first_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()
_progress_label.text = "keyboard binding cancelled"
return
close_panel()
func close_panel() -> void:
_cancel_capture()
hide()
closed.emit()
func _input(event: InputEvent) -> void:
if not visible or _mapping_manager == null or _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 = "keyboard binding cancelled"
return
var binding: Dictionary = _mapping_manager.binding_from_event(event)
if binding.is_empty():
return
get_viewport().set_input_as_handled()
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 keyboard binding"
)
_refresh_bindings()
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 = "keyboard binds"
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)
var device_label := Label.new()
device_label.text = "mouse + keyboard"
device_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
device_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
device_label.add_theme_font_size_override("font_size", 15)
device_label.add_theme_color_override(
"font_color", UtilityPageStyleType.OCEAN_TEXT_SECONDARY
)
heading_row.add_child(device_label)
var instruction_label := Label.new()
instruction_label.text = (
"select a row, then press a keyboard key or mouse button. "
+ "duplicate bindings are marked in red."
)
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)
_mapping_scroll = ScrollContainer.new()
_mapping_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_mapping_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_mapping_scroll.follow_focus = true
page.add_child(_mapping_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)
_mapping_scroll.add_child(role_grid)
for role: StringName in KeyboardMouseMappingManagerType.ROLE_ORDER:
var role_label := Label.new()
role_label.text = KeyboardMouseMappingManagerType.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 " + _mapping_manager_label(role)
)
UtilityPageStyleType.apply_compact_ocean_button(binding_button)
binding_button.pressed.connect(_begin_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)
_reset_button = Button.new()
_reset_button.text = "restore defaults"
_reset_button.tooltip_text = "restore the project keyboard defaults"
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)
_configure_mapping_navigation()
func _configure_mapping_navigation() -> void:
var buttons: Array[Button] = []
for role: StringName in KeyboardMouseMappingManagerType.ROLE_ORDER:
var binding_button := _binding_buttons.get(role) as Button
if binding_button != null:
buttons.append(binding_button)
if buttons.is_empty():
return
for index: int in buttons.size():
var button: Button = buttons[index]
var previous: Control = buttons[index - 1] if index > 0 else button
var next: Control = (
buttons[index + 1]
if index < buttons.size() - 1
else _close_button
)
_set_focus_neighbors(button, button, button, previous, next)
button.focus_previous = button.get_path_to(previous)
button.focus_next = button.get_path_to(next)
var last_button: Button = buttons.back()
_set_focus_neighbors(
_reset_button,
_reset_button,
_close_button,
last_button,
_reset_button,
)
_set_focus_neighbors(
_close_button,
_reset_button,
_close_button,
last_button,
_close_button,
)
_close_button.focus_previous = _close_button.get_path_to(last_button)
_close_button.focus_next = _close_button.get_path_to(_reset_button)
func _set_focus_neighbors(
control: Control,
left: Control,
right: Control,
top: Control,
bottom: Control,
) -> void:
control.focus_neighbor_left = control.get_path_to(left)
control.focus_neighbor_right = control.get_path_to(right)
control.focus_neighbor_top = control.get_path_to(top)
control.focus_neighbor_bottom = control.get_path_to(bottom)
func _mapping_manager_label(role: StringName) -> String:
if _mapping_manager == null:
return str(
KeyboardMouseMappingManagerType.ROLE_LABELS.get(role, str(role))
)
return _mapping_manager.get_role_label(role)
func _begin_capture(role: StringName) -> void:
if _mapping_manager == null:
return
_capturing_role = role
_set_action_buttons_disabled(true)
_progress_label.text = (
"press a key or mouse button for "
+ _mapping_manager.get_role_label(role)
+ " • escape cancels"
)
func _cancel_capture() -> void:
_capturing_role = &""
_set_action_buttons_disabled(false)
if _progress_label != null:
_progress_label.text = ""
func _set_action_buttons_disabled(disabled: bool) -> void:
if _reset_button != null:
_reset_button.disabled = disabled
if _close_button != null:
_close_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_mapping()
_progress_label.text = (
"restored the project keyboard defaults"
if reset else "could not restore keyboard defaults"
)
_refresh_bindings()
func _refresh_bindings() -> void:
if _mapping_manager == null or _reset_button == null:
return
var bindings: Dictionary = _mapping_manager.get_active_bindings()
for role: StringName in KeyboardMouseMappingManagerType.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 KeyboardMouseMappingManagerType.ROLE_ORDER:
if other_role == role:
continue
var raw_other: Variant = bindings.get(str(other_role), {})
if typeof(raw_other) != TYPE_DICTIONARY:
continue
if KeyboardMouseMappingManagerType.bindings_conflict(
binding,
raw_other as Dictionary,
):
return other_role
return &""
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)

View file

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

View file

@ -24,6 +24,10 @@ const DETAIL_FADE_DURATION: float = 0.12
const CATEGORY_FADE_DURATION: float = UIMotion.UTILITY_EXIT_DURATION const CATEGORY_FADE_DURATION: float = UIMotion.UTILITY_EXIT_DURATION
const HANDWRITTEN_NUMERIC_SCALE: float = 0.8 const HANDWRITTEN_NUMERIC_SCALE: float = 0.8
const PORTRAIT_VIEW_MAX_SIZE := Vector2(860.0, 480.0) const PORTRAIT_VIEW_MAX_SIZE := Vector2(860.0, 480.0)
const DETAIL_OVERLAY_EDGE_MARGIN: int = 22
const DETAIL_OVERLAY_CONTENT_MARGIN: int = 34
const DETAIL_OVERLAY_TITLE_FONT_SIZE: int = 40
const DETAIL_OVERLAY_TEXT_FONT_SIZE: int = 36
const INK := Color("251b10") const INK := Color("251b10")
const MUTED_INK := Color("6d5b45") const MUTED_INK := Color("6d5b45")
const LOGBOOK_ARTWORK: Texture2D = preload( const LOGBOOK_ARTWORK: Texture2D = preload(
@ -50,6 +54,13 @@ const CATALOG_SNAP_DELAY: float = 0.12
const DETAIL_PORTRAIT_SIZE := Vector2(160.0, 88.0) const DETAIL_PORTRAIT_SIZE := Vector2(160.0, 88.0)
const DETAIL_BOTTOM_INSET: float = 35.0 const DETAIL_BOTTOM_INSET: float = 35.0
enum ControllerZone {
TABS,
ENTRIES,
DETAILS,
OVERLAY,
}
var _collection_log: CollectionLogType var _collection_log: CollectionLogType
var _inventory: FishInventoryType var _inventory: FishInventoryType
var _catalog: FishPoolType var _catalog: FishPoolType
@ -78,9 +89,14 @@ var _catalog_scroll_down_indicator: TextureRect
var _empty_state: Label var _empty_state: Label
var _detail_body: VBoxContainer var _detail_body: VBoxContainer
var _detail_portrait_button: Button var _detail_portrait_button: Button
var _detail_buttons: Array[Button] = []
var _portrait_overlay: Control var _portrait_overlay: Control
var _portrait_overlay_backdrop: Button var _portrait_overlay_backdrop: Button
var _portrait_overlay_artwork: LogbookPortraitType var _portrait_overlay_artwork: LogbookPortraitType
var _portrait_overlay_title: Label
var _portrait_overlay_text: Label
var _overlay_return_focus: Control
var _controller_zone: ControllerZone = ControllerZone.TABS
func _ready() -> void: func _ready() -> void:
@ -136,7 +152,9 @@ func set_interactive(value: bool) -> void:
) )
for tab: Button in _category_tabs: for tab: Button in _category_tabs:
tab.focus_mode = ( tab.focus_mode = (
Control.FOCUS_ALL if _interactive else Control.FOCUS_NONE Control.FOCUS_ALL
if _interactive and _controller_zone == ControllerZone.TABS
else Control.FOCUS_NONE
) )
tab.mouse_filter = ( tab.mouse_filter = (
Control.MOUSE_FILTER_STOP Control.MOUSE_FILTER_STOP
@ -145,31 +163,109 @@ func set_interactive(value: bool) -> void:
) )
for entry: Button in _entry_buttons.values(): for entry: Button in _entry_buttons.values():
entry.focus_mode = ( entry.focus_mode = (
Control.FOCUS_ALL if _interactive else Control.FOCUS_NONE Control.FOCUS_ALL
if _interactive and _controller_zone == ControllerZone.ENTRIES
else Control.FOCUS_NONE
) )
entry.mouse_filter = ( entry.mouse_filter = (
Control.MOUSE_FILTER_STOP Control.MOUSE_FILTER_STOP
if _interactive if _interactive
else Control.MOUSE_FILTER_IGNORE else Control.MOUSE_FILTER_IGNORE
) )
for detail_button: Button in _detail_buttons:
if not is_instance_valid(detail_button):
continue
detail_button.focus_mode = (
Control.FOCUS_ALL
if _interactive and _controller_zone == ControllerZone.DETAILS
else Control.FOCUS_NONE
)
if _portrait_overlay_backdrop != null:
_portrait_overlay_backdrop.focus_mode = (
Control.FOCUS_ALL
if _interactive and _controller_zone == ControllerZone.OVERLAY
else Control.FOCUS_NONE
)
func focus_initial() -> void: func focus_initial() -> void:
if not _active or not _interactive: if not _active or not _interactive:
return return
if _portrait_overlay != null and _portrait_overlay.visible: if _controller_zone == ControllerZone.OVERLAY:
_portrait_overlay_backdrop.grab_focus() _portrait_overlay_backdrop.grab_focus()
return return
var selected := _entry_buttons.get(_selected_id) as Button if _controller_zone == ControllerZone.TABS:
var category_tab_index: int = _category_tab_categories.find(_category)
if category_tab_index >= 0:
_category_tabs[category_tab_index].grab_focus()
return
if _controller_zone == ControllerZone.DETAILS:
if not _detail_buttons.is_empty():
_detail_buttons.front().grab_focus()
return
var selected := _entry_buttons.get(_selected_entry_key) as Button
if selected != null: if selected != null:
selected.grab_focus() selected.grab_focus()
elif not _entry_buttons.is_empty(): elif not _entry_buttons.is_empty():
var first := _entry_buttons.values().front() as Button var first := _entry_buttons.values().front() as Button
first.grab_focus() first.grab_focus()
else:
var category_tab_index: int = _category_tab_categories.find(_category)
if category_tab_index >= 0: func reset_controller_zone() -> void:
_category_tabs[category_tab_index].grab_focus() _controller_zone = ControllerZone.TABS
set_interactive(_interactive)
call_deferred("focus_initial")
func handle_controller_input(event: InputEvent) -> bool:
if not _active or not _interactive:
return false
var accept_pressed: bool = event.is_action_pressed("ui_accept")
var cancel_pressed: bool = event.is_action_pressed("ui_cancel")
if cancel_pressed:
match _controller_zone:
ControllerZone.OVERLAY:
_hide_portrait_overlay()
ControllerZone.DETAILS:
_set_controller_zone(ControllerZone.ENTRIES)
ControllerZone.ENTRIES:
_set_controller_zone(ControllerZone.TABS)
ControllerZone.TABS:
return false
return true
if _controller_zone == ControllerZone.TABS:
if event.is_action_pressed("ui_left"):
_select_adjacent_controller_category(-1)
return true
if event.is_action_pressed("ui_right"):
_select_adjacent_controller_category(1)
return true
if accept_pressed:
if not _entry_buttons.is_empty():
_set_controller_zone(ControllerZone.ENTRIES)
return true
if _controller_zone == ControllerZone.ENTRIES and accept_pressed:
if not _selected_entry_key.is_empty() and not _detail_buttons.is_empty():
_set_controller_zone(ControllerZone.DETAILS)
return true
return false
func _set_controller_zone(zone: ControllerZone) -> void:
_controller_zone = zone
set_interactive(_interactive)
call_deferred("focus_initial")
func _select_adjacent_controller_category(direction: int) -> void:
var current_index: int = _category_tab_categories.find(_category)
var target_index: int = clampi(
current_index + direction,
0,
_category_tab_categories.size() - 1,
)
if target_index != current_index:
_select_category(_category_tab_categories[target_index])
func _build_interface() -> void: func _build_interface() -> void:
@ -715,6 +811,7 @@ func _build_known_details(fish: FishDataType) -> void:
_detail_portrait_button.pressed.connect( _detail_portrait_button.pressed.connect(
_show_portrait_overlay.bind(fish.display_texture) _show_portrait_overlay.bind(fish.display_texture)
) )
_detail_buttons.append(_detail_portrait_button)
portrait_column.add_child(_detail_portrait_button) portrait_column.add_child(_detail_portrait_button)
var artwork_center := CenterContainer.new() var artwork_center := CenterContainer.new()
artwork_center.mouse_filter = Control.MOUSE_FILTER_IGNORE artwork_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
@ -729,11 +826,20 @@ func _build_known_details(fish: FishDataType) -> void:
) )
artwork_center.add_child(artwork) artwork_center.add_child(artwork)
var facts_text: String = LogbookCatalog.facts_for(fish)
var facts_button := _make_detail_section_button(
"fish facts",
facts_text,
)
facts_button.custom_minimum_size = Vector2(190.0, 132.0)
facts_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
facts_button.size_flags_stretch_ratio = 1.0
summary_columns.add_child(facts_button)
var facts_column := VBoxContainer.new() var facts_column := VBoxContainer.new()
facts_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL facts_column.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
facts_column.size_flags_stretch_ratio = 1.0 facts_column.mouse_filter = Control.MOUSE_FILTER_IGNORE
facts_column.add_theme_constant_override("separation", 6) facts_column.add_theme_constant_override("separation", 6)
summary_columns.add_child(facts_column) facts_button.add_child(facts_column)
var facts_heading := _field_label( var facts_heading := _field_label(
"shellfish facts" "shellfish facts"
if fish.logbook_section == FishDataType.LogbookSection.SHELLFISH if fish.logbook_section == FishDataType.LogbookSection.SHELLFISH
@ -741,17 +847,37 @@ func _build_known_details(fish: FishDataType) -> void:
16, 16,
) )
facts_column.add_child(facts_heading) facts_column.add_child(facts_heading)
var facts := _label(LogbookCatalog.facts_for(fish), 16) var facts := _label(facts_text, 16)
facts.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART facts.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
facts.size_flags_horizontal = Control.SIZE_EXPAND_FILL facts.size_flags_horizontal = Control.SIZE_EXPAND_FILL
facts_column.add_child(facts) facts_column.add_child(facts)
_detail_body.add_child(_build_quality_progress(fish.id)) var quality_button := _make_detail_section_button(
"quality collection",
_quality_overlay_text(fish.id),
)
quality_button.custom_minimum_size.y = 132.0
quality_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_detail_body.add_child(quality_button)
var quality_progress := _build_quality_progress(fish.id)
quality_progress.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
quality_progress.mouse_filter = Control.MOUSE_FILTER_IGNORE
quality_button.add_child(quality_progress)
var stats_button := _make_detail_section_button(
"fish stats",
_stats_overlay_text(fish, catalog_number),
)
stats_button.custom_minimum_size.y = 190.0
stats_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
stats_button.size_flags_vertical = Control.SIZE_EXPAND_FILL
_detail_body.add_child(stats_button)
var stats_anchor := VBoxContainer.new() var stats_anchor := VBoxContainer.new()
stats_anchor.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
stats_anchor.mouse_filter = Control.MOUSE_FILTER_IGNORE
stats_anchor.size_flags_horizontal = Control.SIZE_EXPAND_FILL stats_anchor.size_flags_horizontal = Control.SIZE_EXPAND_FILL
stats_anchor.size_flags_vertical = Control.SIZE_EXPAND_FILL stats_anchor.size_flags_vertical = Control.SIZE_EXPAND_FILL
stats_anchor.alignment = BoxContainer.ALIGNMENT_END stats_anchor.alignment = BoxContainer.ALIGNMENT_END
_detail_body.add_child(stats_anchor) stats_button.add_child(stats_anchor)
var stats_columns := HBoxContainer.new() var stats_columns := HBoxContainer.new()
stats_columns.size_flags_horizontal = Control.SIZE_EXPAND_FILL stats_columns.size_flags_horizontal = Control.SIZE_EXPAND_FILL
stats_columns.add_theme_constant_override("separation", 28) stats_columns.add_theme_constant_override("separation", 28)
@ -806,6 +932,47 @@ func _build_known_details(fish: FishDataType) -> void:
bottom_inset.custom_minimum_size.y = DETAIL_BOTTOM_INSET bottom_inset.custom_minimum_size.y = DETAIL_BOTTOM_INSET
bottom_inset.mouse_filter = Control.MOUSE_FILTER_IGNORE bottom_inset.mouse_filter = Control.MOUSE_FILTER_IGNORE
stats_anchor.add_child(bottom_inset) stats_anchor.add_child(bottom_inset)
_configure_detail_focus(
_detail_portrait_button,
facts_button,
quality_button,
stats_button,
)
set_interactive(_interactive)
func _configure_detail_focus(
portrait: Button,
facts: Button,
quality: Button,
stats: Button,
) -> void:
var top_left: Button = portrait if portrait != null else facts
if portrait != null:
portrait.focus_neighbor_left = portrait.get_path_to(portrait)
portrait.focus_neighbor_right = portrait.get_path_to(facts)
portrait.focus_neighbor_top = portrait.get_path_to(portrait)
portrait.focus_neighbor_bottom = portrait.get_path_to(quality)
portrait.focus_previous = portrait.get_path_to(stats)
portrait.focus_next = portrait.get_path_to(facts)
facts.focus_neighbor_left = facts.get_path_to(top_left)
facts.focus_neighbor_right = facts.get_path_to(facts)
facts.focus_neighbor_top = facts.get_path_to(facts)
facts.focus_neighbor_bottom = facts.get_path_to(quality)
facts.focus_previous = facts.get_path_to(top_left)
facts.focus_next = facts.get_path_to(quality)
quality.focus_neighbor_left = quality.get_path_to(quality)
quality.focus_neighbor_right = quality.get_path_to(quality)
quality.focus_neighbor_top = quality.get_path_to(facts)
quality.focus_neighbor_bottom = quality.get_path_to(stats)
quality.focus_previous = quality.get_path_to(facts)
quality.focus_next = quality.get_path_to(stats)
stats.focus_neighbor_left = stats.get_path_to(stats)
stats.focus_neighbor_right = stats.get_path_to(stats)
stats.focus_neighbor_top = stats.get_path_to(quality)
stats.focus_neighbor_bottom = stats.get_path_to(stats)
stats.focus_previous = stats.get_path_to(quality)
stats.focus_next = stats.get_path_to(top_left)
func _build_quality_progress(fish_id: StringName) -> VBoxContainer: func _build_quality_progress(fish_id: StringName) -> VBoxContainer:
@ -866,6 +1033,72 @@ func _build_quality_progress(fish_id: StringName) -> VBoxContainer:
return quality_section return quality_section
func _make_detail_section_button(
section_title: String,
section_text: String,
) -> Button:
var button := Button.new()
button.text = ""
button.tooltip_text = "View %s larger" % section_title
button.accessibility_name = "View %s larger" % section_title
button.add_theme_stylebox_override("normal", _portrait_button_style(false))
button.add_theme_stylebox_override("hover", _portrait_button_style(true))
button.add_theme_stylebox_override("focus", _portrait_button_style(true))
button.add_theme_stylebox_override("pressed", _portrait_button_style(true))
button.pressed.connect(
_show_detail_text_overlay.bind(section_title, section_text, button)
)
_detail_buttons.append(button)
return button
func _quality_overlay_text(fish_id: StringName) -> String:
var lines: Array[String] = []
for quality: int in FishQualityType.TIER_COUNT:
var collected: bool = _collection_log.has_discovered_quality(
fish_id,
quality,
)
lines.append("%s %s" % [
"collected" if collected else "not collected",
FishQualityType.display_name(quality),
])
return "\n".join(lines)
func _stats_overlay_text(fish: FishDataType, catalog_number: int) -> String:
var habitat_label: String = (
"habitat"
if fish.logbook_section == FishDataType.LogbookSection.SHELLFISH
else "body of water"
)
return "\n".join([
"catalog number: %s" % (
"#%03d" % catalog_number if catalog_number > 0 else "unknown"
),
"%s: %s" % [habitat_label, fish.get_habitat_label()],
"weight range: %.2f%.2f lb" % [
fish.get_minimum_weight(),
fish.get_maximum_weight(),
],
"rarity: %s" % fish.get_rarity_name().to_lower(),
"time of day: %s" % _availability_text(fish),
"value range: %d%d" % [
FishQualityType.apply_sale_value(
fish.sell_value_min,
FishQualityType.Tier.BORING,
),
FishQualityType.apply_sale_value(
fish.sell_value_max,
FishQualityType.Tier.SHINY,
),
],
"number owned: %d" % (
_inventory.get_count(fish.id) if _inventory != null else 0
),
])
func _show_no_selection() -> void: func _show_no_selection() -> void:
_clear_details() _clear_details()
var instruction := _label("Select an entry to read its catch record.", 21) var instruction := _label("Select an entry to read its catch record.", 21)
@ -992,6 +1225,7 @@ func _clear_entries() -> void:
func _clear_details() -> void: func _clear_details() -> void:
_detail_portrait_button = null _detail_portrait_button = null
_detail_buttons.clear()
for child: Node in _detail_body.get_children(): for child: Node in _detail_body.get_children():
_detail_body.remove_child(child) _detail_body.remove_child(child)
child.queue_free() child.queue_free()
@ -1110,19 +1344,51 @@ func _build_portrait_overlay() -> void:
) )
_portrait_overlay.add_child(_portrait_overlay_backdrop) _portrait_overlay.add_child(_portrait_overlay_backdrop)
var center := CenterContainer.new() var overlay_margin := MarginContainer.new()
center.mouse_filter = Control.MOUSE_FILTER_IGNORE overlay_margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) overlay_margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_portrait_overlay.add_child(center) _set_margins(
overlay_margin,
DETAIL_OVERLAY_EDGE_MARGIN,
DETAIL_OVERLAY_EDGE_MARGIN,
DETAIL_OVERLAY_EDGE_MARGIN,
DETAIL_OVERLAY_EDGE_MARGIN,
)
_portrait_overlay.add_child(overlay_margin)
var card := PanelContainer.new() var card := PanelContainer.new()
card.mouse_filter = Control.MOUSE_FILTER_STOP card.mouse_filter = Control.MOUSE_FILTER_STOP
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
card.size_flags_vertical = Control.SIZE_EXPAND_FILL
card.add_theme_stylebox_override("panel", _portrait_view_style()) card.add_theme_stylebox_override("panel", _portrait_view_style())
center.add_child(card) overlay_margin.add_child(card)
var margin := MarginContainer.new() var margin := MarginContainer.new()
_set_margins(margin, 22, 22, 22, 22) _set_margins(
margin,
DETAIL_OVERLAY_CONTENT_MARGIN,
DETAIL_OVERLAY_CONTENT_MARGIN,
DETAIL_OVERLAY_CONTENT_MARGIN,
DETAIL_OVERLAY_CONTENT_MARGIN,
)
card.add_child(margin) card.add_child(margin)
var overlay_stack := VBoxContainer.new()
overlay_stack.alignment = BoxContainer.ALIGNMENT_CENTER
overlay_stack.add_theme_constant_override("separation", 18)
margin.add_child(overlay_stack)
_portrait_overlay_title = _field_label(
"", DETAIL_OVERLAY_TITLE_FONT_SIZE
)
_portrait_overlay_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
overlay_stack.add_child(_portrait_overlay_title)
_portrait_overlay_artwork = LogbookPortraitType.new() _portrait_overlay_artwork = LogbookPortraitType.new()
margin.add_child(_portrait_overlay_artwork) _portrait_overlay_artwork.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
overlay_stack.add_child(_portrait_overlay_artwork)
_portrait_overlay_text = _label("", DETAIL_OVERLAY_TEXT_FONT_SIZE)
_portrait_overlay_text.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_portrait_overlay_text.size_flags_vertical = Control.SIZE_EXPAND_FILL
_portrait_overlay_text.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
_portrait_overlay_text.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_portrait_overlay_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
overlay_stack.add_child(_portrait_overlay_text)
func _show_portrait_overlay(portrait_texture: Texture2D) -> void: func _show_portrait_overlay(portrait_texture: Texture2D) -> void:
@ -1132,6 +1398,31 @@ func _show_portrait_overlay(portrait_texture: Texture2D) -> void:
portrait_texture, portrait_texture,
PORTRAIT_VIEW_MAX_SIZE, PORTRAIT_VIEW_MAX_SIZE,
) )
_portrait_overlay_title.text = "artwork"
_portrait_overlay_artwork.visible = true
_portrait_overlay_text.visible = false
_overlay_return_focus = _detail_portrait_button
_controller_zone = ControllerZone.OVERLAY
set_interactive(_interactive)
_portrait_overlay.visible = true
_portrait_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
_portrait_overlay_backdrop.grab_focus()
func _show_detail_text_overlay(
section_title: String,
section_text: String,
return_focus: Control,
) -> void:
if _portrait_overlay == null:
return
_portrait_overlay_title.text = section_title
_portrait_overlay_artwork.visible = false
_portrait_overlay_text.text = section_text
_portrait_overlay_text.visible = true
_overlay_return_focus = return_focus
_controller_zone = ControllerZone.OVERLAY
set_interactive(_interactive)
_portrait_overlay.visible = true _portrait_overlay.visible = true
_portrait_overlay.mouse_filter = Control.MOUSE_FILTER_STOP _portrait_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
_portrait_overlay_backdrop.grab_focus() _portrait_overlay_backdrop.grab_focus()
@ -1142,14 +1433,16 @@ func _hide_portrait_overlay(restore_focus: bool = true) -> void:
return return
_portrait_overlay.visible = false _portrait_overlay.visible = false
_portrait_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE _portrait_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
_controller_zone = ControllerZone.DETAILS
set_interactive(_interactive)
if ( if (
restore_focus restore_focus
and _detail_portrait_button != null and _overlay_return_focus != null
and is_instance_valid(_detail_portrait_button) and is_instance_valid(_overlay_return_focus)
and _active and _active
and _interactive and _interactive
): ):
_detail_portrait_button.grab_focus() _overlay_return_focus.grab_focus()
func _on_portrait_overlay_backdrop_input(event: InputEvent) -> void: func _on_portrait_overlay_backdrop_input(event: InputEvent) -> void:

View file

@ -31,6 +31,8 @@ var _compose: Control
var _letter: Control var _letter: Control
var _inbox_list: VBoxContainer var _inbox_list: VBoxContainer
var _empty_label: Label var _empty_label: Label
var _send_mail_button: Button
var _archive_view_button: Button
var _recipient: OptionButton var _recipient: OptionButton
var _greeting: OptionButton var _greeting: OptionButton
var _body: TextEdit var _body: TextEdit
@ -45,16 +47,20 @@ var _attachment_amount_currency_icon: TextureRect
var _amount_minus: Button var _amount_minus: Button
var _amount_plus: Button var _amount_plus: Button
var _attachment_summary: RichTextLabel var _attachment_summary: RichTextLabel
var _compose_cancel: Button
var _send_button: Button var _send_button: Button
var _status: Label var _status: Label
var _letter_text: Label var _letter_text: Label
var _letter_gift: RichTextLabel var _letter_gift: RichTextLabel
var _letter_close: Button
var _accept: Button var _accept: Button
var _decline: Button var _decline: Button
var _archive: Button var _archive: Button
var _delete: Button var _delete: Button
var _current_mail_id := "" var _current_mail_id := ""
var _showing_archive := false var _showing_archive := false
var _active: bool = false
var _interactive: bool = false
func _ready() -> void: func _ready() -> void:
@ -85,13 +91,16 @@ func setup(
func activate() -> void: func activate() -> void:
_active = true
_show_inbox() _show_inbox()
call_deferred("_focus_first") call_deferred("_refresh_controller_navigation")
func deactivate() -> void: func deactivate() -> void:
_active = false
_current_mail_id = "" _current_mail_id = ""
_show_inbox() _show_inbox()
set_interactive(false)
func consume_escape() -> bool: func consume_escape() -> bool:
@ -106,12 +115,263 @@ func is_composing_letter() -> bool:
func set_interactive(value: bool) -> void: func set_interactive(value: bool) -> void:
mouse_filter = Control.MOUSE_FILTER_PASS if value else Control.MOUSE_FILTER_IGNORE _interactive = value and _active
for node: Node in find_children("*", "BaseButton", true, false): mouse_filter = (
(node as BaseButton).focus_mode = ( Control.MOUSE_FILTER_PASS
Control.FOCUS_ALL if value and node.is_visible_in_tree() if _interactive else Control.MOUSE_FILTER_IGNORE
)
_refresh_controller_navigation()
func reset_controller_zone() -> void:
_show_inbox()
call_deferred("_refresh_controller_navigation")
func handle_controller_input(event: InputEvent) -> bool:
if not _active or not _interactive:
return false
if event.is_action_pressed("ui_cancel"):
return consume_escape()
return false
func _refresh_controller_navigation() -> void:
var controls: Array[Control] = []
_collect_visible_controller_controls(self, controls)
for control: Control in controls:
var button := control as BaseButton
control.focus_mode = (
Control.FOCUS_ALL
if _interactive and (button == null or not button.disabled)
else Control.FOCUS_NONE else Control.FOCUS_NONE
) )
ControllerFocusNavigation.configure_spatial_neighbors(controls)
if _compose != null and _compose.visible:
_configure_compose_controller_navigation()
elif _inbox != null and _inbox.visible:
_configure_inbox_controller_navigation()
elif _letter != null and _letter.visible:
_configure_letter_controller_navigation()
if not _interactive or controls.is_empty():
return
var focus_owner: Control = get_viewport().gui_get_focus_owner()
if focus_owner == null or not is_ancestor_of(focus_owner):
controls.sort_custom(func(first: Control, second: Control) -> bool:
if not is_equal_approx(first.global_position.y, second.global_position.y):
return first.global_position.y < second.global_position.y
return first.global_position.x < second.global_position.x
)
controls.front().grab_focus()
func _configure_inbox_controller_navigation() -> void:
if not _interactive:
return
var entries: Array[Control] = []
for child: Node in _inbox_list.get_children():
var button := child as Button
if button != null and _controller_focus_eligible(button):
entries.append(button)
var first_entry: Control = (
entries.front() if not entries.is_empty() else _archive_view_button
)
_set_compose_neighbors(
_archive_view_button,
_archive_view_button,
_send_mail_button,
_archive_view_button,
first_entry,
)
_set_compose_neighbors(
_send_mail_button,
_archive_view_button,
_send_mail_button,
_send_mail_button,
entries.front() if not entries.is_empty() else _send_mail_button,
)
for index: int in entries.size():
var entry: Control = entries[index]
_set_compose_neighbors(
entry,
entry,
entry,
entries[index - 1] if index > 0 else _archive_view_button,
entries[index + 1] if index < entries.size() - 1 else entry,
)
var traversal: Array[Control] = [
_archive_view_button,
_send_mail_button,
]
traversal.append_array(entries)
ControllerFocusNavigation.configure_traversal(traversal)
func _configure_letter_controller_navigation() -> void:
if not _interactive:
return
var pending_gift: bool = (
_accept != null
and _accept.is_visible_in_tree()
and _decline != null
and _decline.is_visible_in_tree()
)
if pending_gift:
_set_compose_neighbors(
_accept, _accept, _accept, _accept, _decline
)
_set_compose_neighbors(
_decline, _decline, _decline, _accept, _delete
)
_set_compose_neighbors(
_letter_close,
_letter_close,
_archive,
_letter_close,
_letter_close,
)
_set_compose_neighbors(
_archive,
_letter_close,
_delete,
_archive,
_archive,
)
_set_compose_neighbors(
_delete,
_archive,
_delete,
_decline if pending_gift else _delete,
_delete,
)
var traversal: Array[Control] = [_letter_close, _archive, _delete]
if pending_gift:
traversal = [_accept, _decline, _letter_close, _archive, _delete]
ControllerFocusNavigation.configure_traversal(traversal)
func _configure_compose_controller_navigation() -> void:
if not _interactive:
return
var amount_visible: bool = (
_attachment_amount != null
and _attachment_amount.is_visible_in_tree()
)
var amount_left: Control = _amount_minus if amount_visible else _compose_cancel
var amount_middle: Control = (
_attachment_amount if amount_visible else _compose_cancel
)
var amount_right: Control = _amount_plus if amount_visible else _send_button
_set_compose_neighbors(
_greeting, _greeting, _recipient, _greeting, _body
)
_set_compose_neighbors(
_recipient, _greeting, _attachment_kind, _recipient, _body
)
_set_compose_neighbors(
_body, _body, _attachment_choice, _recipient, _salutation
)
_set_compose_neighbors(
_salutation, _salutation, _compose_cancel, _body, _salutation
)
_set_compose_neighbors(
_attachment_kind,
_recipient,
_attachment_kind,
_attachment_kind,
_attachment_choice,
)
_set_compose_neighbors(
_attachment_choice,
_body,
_attachment_choice,
_attachment_kind,
amount_middle,
)
if amount_visible:
_set_compose_neighbors(
_amount_minus,
_amount_minus,
_attachment_amount,
_attachment_choice,
_compose_cancel,
)
_set_compose_neighbors(
_attachment_amount,
_amount_minus,
_amount_plus,
_attachment_choice,
_compose_cancel,
)
_set_compose_neighbors(
_amount_plus,
_attachment_amount,
_amount_plus,
_attachment_choice,
_send_button,
)
_set_compose_neighbors(
_compose_cancel,
_salutation,
_send_button,
amount_left,
_compose_cancel,
)
_set_compose_neighbors(
_send_button,
_compose_cancel,
_send_button,
amount_right,
_send_button,
)
func _set_compose_neighbors(
control: Control,
left: Control,
right: Control,
top: Control,
bottom: Control,
) -> void:
if control == null or not control.is_visible_in_tree():
return
var safe_left: Control = left if _controller_focus_eligible(left) else control
var safe_right: Control = right if _controller_focus_eligible(right) else control
var safe_top: Control = top if _controller_focus_eligible(top) else control
var safe_bottom: Control = (
bottom if _controller_focus_eligible(bottom) else control
)
control.focus_neighbor_left = control.get_path_to(safe_left)
control.focus_neighbor_right = control.get_path_to(safe_right)
control.focus_neighbor_top = control.get_path_to(safe_top)
control.focus_neighbor_bottom = control.get_path_to(safe_bottom)
func _controller_focus_eligible(control: Control) -> bool:
if (
control == null
or not control.is_visible_in_tree()
or control.focus_mode == Control.FOCUS_NONE
):
return false
var button := control as BaseButton
return button == null or not button.disabled
func _collect_visible_controller_controls(
root: Node,
output: Array[Control],
) -> void:
for child: Node in root.get_children():
var control := child as Control
if control != null and not control.is_visible_in_tree():
continue
if (
control != null
and (control is BaseButton or control is LineEdit or control is TextEdit)
):
output.append(control)
_collect_visible_controller_controls(child, output)
func _build_ui() -> void: func _build_ui() -> void:
@ -144,22 +404,22 @@ func _build_inbox() -> Control:
title.size = Vector2(500, 42) title.size = Vector2(500, 42)
title.add_theme_font_size_override("font_size", 30) title.add_theme_font_size_override("font_size", 30)
page.add_child(title) page.add_child(title)
var send := Button.new() _send_mail_button = Button.new()
send.text = "send mail" _send_mail_button.text = "send mail"
send.position = Vector2(820, 0) _send_mail_button.position = Vector2(820, 0)
send.size = Vector2(150, 48) _send_mail_button.size = Vector2(150, 48)
send.pressed.connect(_show_compose) _send_mail_button.pressed.connect(_show_compose)
page.add_child(send) page.add_child(_send_mail_button)
var archive_view := Button.new() _archive_view_button = Button.new()
archive_view.text = "archive" _archive_view_button.text = "archive"
archive_view.position = Vector2(654, 0) _archive_view_button.position = Vector2(654, 0)
archive_view.size = Vector2(154, 48) _archive_view_button.size = Vector2(154, 48)
archive_view.pressed.connect(func() -> void: _archive_view_button.pressed.connect(func() -> void:
_showing_archive = not _showing_archive _showing_archive = not _showing_archive
archive_view.text = "inbox" if _showing_archive else "archive" _archive_view_button.text = "inbox" if _showing_archive else "archive"
_refresh_inbox() _refresh_inbox()
) )
page.add_child(archive_view) page.add_child(_archive_view_button)
var scroll := ScrollContainer.new() var scroll := ScrollContainer.new()
scroll.position = Vector2(12, 62) scroll.position = Vector2(12, 62)
scroll.size = Vector2(1036, 334) scroll.size = Vector2(1036, 334)
@ -281,12 +541,12 @@ func _build_compose() -> Control:
_attachment_summary.scroll_active = false _attachment_summary.scroll_active = false
_attachment_summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _attachment_summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
page.add_child(_attachment_summary) page.add_child(_attachment_summary)
var cancel := Button.new() _compose_cancel = Button.new()
cancel.text = "cancel" _compose_cancel.text = "cancel"
cancel.position = Vector2(688, 342) _compose_cancel.position = Vector2(688, 342)
cancel.size = Vector2(126, 48) _compose_cancel.size = Vector2(126, 48)
cancel.pressed.connect(_show_inbox) _compose_cancel.pressed.connect(_show_inbox)
page.add_child(cancel) page.add_child(_compose_cancel)
_send_button = Button.new() _send_button = Button.new()
_send_button.text = "send letter" _send_button.text = "send letter"
_send_button.position = Vector2(826, 342) _send_button.position = Vector2(826, 342)
@ -333,12 +593,12 @@ func _build_letter() -> Control:
_service.decline_gift(_current_mail_id) _service.decline_gift(_current_mail_id)
) )
page.add_child(_decline) page.add_child(_decline)
var close := Button.new() _letter_close = Button.new()
close.text = "close" _letter_close.text = "close"
close.position = Vector2(26, 370) _letter_close.position = Vector2(26, 370)
close.size = Vector2(150, 48) _letter_close.size = Vector2(150, 48)
close.pressed.connect(_show_inbox) _letter_close.pressed.connect(_show_inbox)
page.add_child(close) page.add_child(_letter_close)
_archive = Button.new() _archive = Button.new()
_archive.text = "archive" _archive.text = "archive"
_archive.position = Vector2(550, 370) _archive.position = Vector2(550, 370)
@ -360,6 +620,7 @@ func _show_inbox() -> void:
_letter.hide() _letter.hide()
_current_mail_id = "" _current_mail_id = ""
_refresh_inbox() _refresh_inbox()
call_deferred("_refresh_controller_navigation")
func _show_compose() -> void: func _show_compose() -> void:
@ -370,7 +631,7 @@ func _show_compose() -> void:
_signature.text = _service.get_local_display_name() _signature.text = _service.get_local_display_name()
_refresh_recipients() _refresh_recipients()
_update_send_state() _update_send_state()
_greeting.grab_focus() call_deferred("_refresh_controller_navigation")
func _reset_compose() -> void: func _reset_compose() -> void:
@ -427,7 +688,8 @@ func _open_letter(mail_id: String) -> void:
"Resolve this gift before deleting the letter." if pending else "" "Resolve this gift before deleting the letter." if pending else ""
) )
if pending: if pending:
_accept.grab_focus() _accept.call_deferred("grab_focus")
call_deferred("_refresh_controller_navigation")
func _refresh_inbox() -> void: func _refresh_inbox() -> void:
@ -444,6 +706,7 @@ func _refresh_inbox() -> void:
empty.custom_minimum_size = Vector2(1008, 80) empty.custom_minimum_size = Vector2(1008, 80)
empty.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER empty.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_inbox_list.add_child(empty) _inbox_list.add_child(empty)
call_deferred("_refresh_controller_navigation")
return return
for letter: Dictionary in letters: for letter: Dictionary in letters:
var button := Button.new() var button := Button.new()
@ -473,6 +736,7 @@ func _refresh_inbox() -> void:
button.pressed.connect(_open_letter.bind(str(letter["mail_id"]))) button.pressed.connect(_open_letter.bind(str(letter["mail_id"])))
UtilityPageStyle.apply_ocean_button(button) UtilityPageStyle.apply_ocean_button(button)
_inbox_list.add_child(button) _inbox_list.add_child(button)
call_deferred("_refresh_controller_navigation")
func _refresh_recipients() -> void: func _refresh_recipients() -> void:
@ -556,6 +820,7 @@ func _refresh_attachment_choices(_index: int) -> void:
) )
_update_attachment_amount_limit() _update_attachment_amount_limit()
_update_attachment_summary() _update_attachment_summary()
call_deferred("_refresh_controller_navigation")
func _update_attachment_amount_limit() -> void: func _update_attachment_amount_limit() -> void:
@ -616,6 +881,7 @@ func _update_send_state() -> void:
or not _reservations.validate_attachment(attachment) or not _reservations.validate_attachment(attachment)
or not _attachment_is_available(attachment) or not _attachment_is_available(attachment)
) )
call_deferred("_refresh_controller_navigation")
func _attachment_is_available(attachment: Dictionary) -> bool: func _attachment_is_available(attachment: Dictionary) -> bool:

View file

@ -13,6 +13,9 @@ enum Mode {
} }
const DISCOVERY_REFRESH_SECONDS: float = 8.0 const DISCOVERY_REFRESH_SECONDS: float = 8.0
const ControllerFocusNavigationType = preload(
"res://ui/controller_focus_navigation.gd"
)
const ADDRESS_FORMAT_HELP: String = ( const ADDRESS_FORMAT_HELP: String = (
"Hostname, IPv4, or [IPv6]. Port 7777 is used when omitted; " "Hostname, IPv4, or [IPv6]. Port 7777 is used when omitted; "
@ -189,6 +192,8 @@ func open_page(preserved_endpoint: String = "") -> void:
if _mode == Mode.DIRECT: if _mode == Mode.DIRECT:
_address.grab_focus() _address.grab_focus()
_address.select_all() _address.select_all()
else:
_server_list.grab_focus()
func close_page() -> void: func close_page() -> void:
@ -196,6 +201,10 @@ func close_page() -> void:
hide_for_join_confirmation() hide_for_join_confirmation()
func request_back() -> void:
_on_back_pressed()
func hide_for_join_confirmation() -> void: func hide_for_join_confirmation() -> void:
_clear_edit_state() _clear_edit_state()
_discovery_refresh_timer.stop() _discovery_refresh_timer.stop()
@ -649,6 +658,136 @@ func _refresh() -> void:
) )
if not warning.is_empty() and _status.text.is_empty(): if not warning.is_empty() and _status.text.is_empty():
_status.text = warning _status.text = warning
_configure_controller_navigation()
func _configure_controller_navigation() -> void:
var mode_buttons: Array[Control] = [
_discover_button,
_direct_button,
_saved_button,
_recent_button,
]
var content_controls: Array[Control] = []
for control: Control in [_address, _name_edit, _server_list]:
if _controller_focus_eligible(control):
content_controls.append(control)
var action_controls: Array[Control] = []
for control: Control in [
_refresh_button,
_join_button,
_save_button,
_edit_button,
_favorite_button,
_delete_button,
_cancel_button,
_back_button,
]:
if _controller_focus_eligible(control):
action_controls.append(control)
var all_controls: Array[Control] = []
all_controls.append_array(mode_buttons)
all_controls.append_array(content_controls)
all_controls.append_array(action_controls)
for control: Control in all_controls:
control.focus_mode = Control.FOCUS_ALL
for control: Control in [
_address,
_name_edit,
_server_list,
_refresh_button,
_join_button,
_save_button,
_edit_button,
_favorite_button,
_delete_button,
_cancel_button,
_back_button,
]:
if control not in all_controls:
control.focus_mode = Control.FOCUS_NONE
var primary_content: Control = (
content_controls.front()
if not content_controls.is_empty()
else action_controls.front()
if not action_controls.is_empty()
else _back_button
)
for index: int in mode_buttons.size():
var mode_button: Control = mode_buttons[index]
_set_controller_neighbors(
mode_button,
mode_buttons[maxi(index - 1, 0)],
mode_buttons[mini(index + 1, mode_buttons.size() - 1)],
mode_button,
primary_content,
)
for index: int in content_controls.size():
var content: Control = content_controls[index]
var above: Control = (
content_controls[index - 1]
if index > 0
else mode_buttons[int(_mode)]
)
var below: Control = (
content_controls[index + 1]
if index < content_controls.size() - 1
else action_controls.front()
if not action_controls.is_empty()
else content
)
_set_controller_neighbors(content, content, content, above, below)
for index: int in action_controls.size():
var action: Control = action_controls[index]
_set_controller_neighbors(
action,
action_controls[maxi(index - 1, 0)],
action_controls[mini(index + 1, action_controls.size() - 1)],
content_controls.back()
if not content_controls.is_empty()
else mode_buttons[int(_mode)],
action,
)
ControllerFocusNavigationType.configure_traversal(all_controls)
_recover_controller_focus(all_controls, primary_content)
func _controller_focus_eligible(control: Control) -> bool:
if control == null or not control.is_visible_in_tree():
return false
var button := control as BaseButton
if button != null and button.disabled:
return false
var line_edit := control as LineEdit
return line_edit == null or line_edit.editable
func _set_controller_neighbors(
control: Control,
left: Control,
right: Control,
top: Control,
bottom: Control,
) -> void:
control.focus_neighbor_left = control.get_path_to(left)
control.focus_neighbor_right = control.get_path_to(right)
control.focus_neighbor_top = control.get_path_to(top)
control.focus_neighbor_bottom = control.get_path_to(bottom)
func _recover_controller_focus(
controls: Array[Control],
fallback: Control,
) -> void:
if not is_visible_in_tree():
return
var owner: Control = get_viewport().gui_get_focus_owner()
if owner != null and owner in controls:
return
if owner != null and _delete_confirmation.is_ancestor_of(owner):
return
if fallback != null:
fallback.call_deferred("grab_focus")
func _format_entry_details(entry: SavedServerEntry) -> String: func _format_entry_details(entry: SavedServerEntry) -> String:
@ -832,9 +971,14 @@ func _on_cancel_pressed() -> void:
func _on_back_pressed() -> void: func _on_back_pressed() -> void:
if _name_entry_active or _delete_armed: if _delete_armed or _delete_confirmation.visible:
_cancel_delete()
return
if _name_entry_active:
_clear_edit_state() _clear_edit_state()
_refresh() _refresh()
var content: Control = _address if _mode == Mode.DIRECT else _server_list
content.call_deferred("grab_focus")
return return
if ( if (
_network_session != null _network_session != null

View file

@ -9,6 +9,7 @@ const CHECK_ICON: Texture2D = preload(
) )
const CHARACTER_KEY_SIZE: Vector2 = Vector2(96.0, 72.0) const CHARACTER_KEY_SIZE: Vector2 = Vector2(96.0, 72.0)
const CHARACTER_FONT_SIZE: int = 34 const CHARACTER_FONT_SIZE: int = 34
const CANONICAL_WINDOW_SIZE: Vector2 = Vector2(1280.0, 720.0)
const TRIGGER_PRESS_THRESHOLD: float = 0.55 const TRIGGER_PRESS_THRESHOLD: float = 0.55
const TRIGGER_RELEASE_THRESHOLD: float = 0.25 const TRIGGER_RELEASE_THRESHOLD: float = 0.25
@ -36,6 +37,11 @@ var _space_button: Button
var _check_button: Button var _check_button: Button
var _left_trigger_pressed: bool = false var _left_trigger_pressed: bool = false
var _right_trigger_pressed: bool = false var _right_trigger_pressed: bool = false
var _portable_host_parent: Node
var _portable_host_index: int = -1
var _portable_target_window: Window
var _portable_target_window_size: Vector2i
var _portable_target_window_position: Vector2i
func _ready() -> void: func _ready() -> void:
@ -63,12 +69,17 @@ func is_open() -> bool:
func request_for_focused_control() -> bool: func request_for_focused_control() -> bool:
if not _is_available_for_controller() or visible: return request_for_control(get_viewport().gui_get_focus_owner())
func request_for_control(control: Control = null) -> bool:
if (
not _is_available_for_controller()
or visible
or not _can_edit(control)
):
return false return false
var focus_owner: Control = get_viewport().gui_get_focus_owner() _open_for(control)
if not _can_edit(focus_owner):
return false
_open_for(focus_owner)
return true return true
@ -145,6 +156,7 @@ func _open_for(control: Control) -> void:
_target.set("virtual_keyboard_enabled", false) _target.set("virtual_keyboard_enabled", false)
_buffer = str(_target.get("text")) _buffer = str(_target.get("text"))
_page = Page.LOWER _page = Page.LOWER
_attach_to_target_window(control)
show() show()
_refresh_preview() _refresh_preview()
_rebuild_keys() _rebuild_keys()
@ -159,11 +171,62 @@ func _close_keyboard(restore_focus: bool) -> void:
) )
hide() hide()
get_viewport().gui_release_focus() get_viewport().gui_release_focus()
_restore_portable_host()
_target = null _target = null
if restore_focus and is_instance_valid(prior_target): if restore_focus and is_instance_valid(prior_target):
prior_target.grab_focus() prior_target.grab_focus()
func _attach_to_target_window(control: Control) -> void:
var target_window: Window = control.get_window()
if target_window == null or target_window == get_window():
return
_portable_host_parent = get_parent()
_portable_host_index = get_index()
_portable_target_window = target_window
_portable_target_window_size = target_window.size
_portable_target_window_position = target_window.position
var parent_window := target_window.get_parent() as Window
if parent_window != null:
target_window.size = parent_window.size
target_window.position = parent_window.position
reparent(target_window, false)
anchor_left = 0.0
anchor_top = 0.0
anchor_right = 0.0
anchor_bottom = 0.0
size = CANONICAL_WINDOW_SIZE
var available: Vector2 = Vector2(target_window.size)
var fit_scale: float = minf(
available.x / CANONICAL_WINDOW_SIZE.x,
available.y / CANONICAL_WINDOW_SIZE.y,
)
scale = Vector2.ONE * maxf(fit_scale, 0.01)
position = (available - CANONICAL_WINDOW_SIZE * fit_scale) * 0.5
func _restore_portable_host() -> void:
if _portable_host_parent == null:
return
var original_parent: Node = _portable_host_parent
var original_index: int = _portable_host_index
var target_window: Window = _portable_target_window
_portable_host_parent = null
_portable_host_index = -1
_portable_target_window = null
reparent(original_parent, false)
if original_index >= 0:
original_parent.move_child(
self,
mini(original_index, original_parent.get_child_count() - 1),
)
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
scale = Vector2.ONE
if target_window != null and is_instance_valid(target_window):
target_window.size = _portable_target_window_size
target_window.position = _portable_target_window_position
func _submit() -> void: func _submit() -> void:
var submitted_target: Control = _target var submitted_target: Control = _target
var submitted_text: String = _buffer var submitted_text: String = _buffer

View file

@ -183,7 +183,7 @@ func handle_escape() -> bool:
if _confirmation_page.visible: if _confirmation_page.visible:
_close_confirmation() _close_confirmation()
elif _join_game_page.visible: elif _join_game_page.visible:
_close_join_game() _join_game_page.request_back()
elif _settings_panel.visible: elif _settings_panel.visible:
_settings_panel.handle_back() _settings_panel.handle_back()
else: else:

View file

@ -36,6 +36,9 @@ const PlayerExperienceType = preload(
const ControllerMappingManagerType = preload( const ControllerMappingManagerType = preload(
"res://settings/controller_mapping_manager.gd" "res://settings/controller_mapping_manager.gd"
) )
const ControllerFocusNavigationType = preload(
"res://ui/controller_focus_navigation.gd"
)
const FishBatchSelectionType = preload( const FishBatchSelectionType = preload(
"res://ui/fish_batch_selection.gd" "res://ui/fish_batch_selection.gd"
) )
@ -131,10 +134,13 @@ enum CloseReason {
} }
enum ControllerOwnership { enum ControllerOwnership {
INVENTORY_TABS,
ITEM_LIST, ITEM_LIST,
NOTEPAD_ACTIONS, NOTEPAD_ACTIONS,
SORT_FILTER,
HOTBAR_MANAGEMENT, HOTBAR_MANAGEMENT,
HOTBAR_PLACEMENT, HOTBAR_PLACEMENT,
PAGE_CONTENT,
} }
const INVENTORY_MAIN_POSITION := Vector2(54.0, 166.0) const INVENTORY_MAIN_POSITION := Vector2(54.0, 166.0)
@ -149,6 +155,7 @@ const INVENTORY_MAIN_CORNER_RADIUS: int = 58
const INVENTORY_INNER_CORNER_RADIUS: int = 45 const INVENTORY_INNER_CORNER_RADIUS: int = 45
const TACKLE_GRID_COLUMNS: int = 3 const TACKLE_GRID_COLUMNS: int = 3
const SALE_CONFIRMATION_SIZE := Vector2(520.0, 190.0) const SALE_CONFIRMATION_SIZE := Vector2(520.0, 190.0)
const CONTROLLER_PICKUP_HOLD_SECONDS: float = 0.42
@onready var _navigation_cluster: BubbleClusterType = %NavigationCluster @onready var _navigation_cluster: BubbleClusterType = %NavigationCluster
@onready var _presentation_scale_root: Control = %PlayerMenuPresentationScaleRoot @onready var _presentation_scale_root: Control = %PlayerMenuPresentationScaleRoot
@ -308,7 +315,9 @@ var _last_inventory_section: Section = Section.COOLER
var _bag_view: BagView = BagView.EQUIPMENT var _bag_view: BagView = BagView.EQUIPMENT
var _selected_tackle_item_id: StringName var _selected_tackle_item_id: StringName
var _tackle_item_buttons: Dictionary[StringName, Button] = {} var _tackle_item_buttons: Dictionary[StringName, Button] = {}
var _controller_ownership: ControllerOwnership = ControllerOwnership.ITEM_LIST var _controller_ownership: ControllerOwnership = (
ControllerOwnership.INVENTORY_TABS
)
var _controller_source_section: Section = Section.COOLER var _controller_source_section: Section = Section.COOLER
var _controller_source_identity: StringName var _controller_source_identity: StringName
var _controller_notepad_actions: Array[BaseButton] = [] var _controller_notepad_actions: Array[BaseButton] = []
@ -317,6 +326,8 @@ var _controller_hotbar_assignment_kind: PlayerHotbarType.AssignmentKind = (
) )
var _controller_hotbar_identity: StringName var _controller_hotbar_identity: StringName
var _controller_previous_hotbar_slot: int = 0 var _controller_previous_hotbar_slot: int = 0
var _controller_accept_held: bool = false
var _controller_accept_hold_elapsed: float = 0.0
var _sort_mode: SortMode = SortMode.CATCH_ORDER var _sort_mode: SortMode = SortMode.CATCH_ORDER
var _sort_descending: bool = true var _sort_descending: bool = true
var _fish_selection := FishBatchSelectionType.new() var _fish_selection := FishBatchSelectionType.new()
@ -352,8 +363,10 @@ var _page_incoming_root: Control
var _page_outgoing_content_root: Control var _page_outgoing_content_root: Control
var _inventory_transition_group: Control var _inventory_transition_group: Control
var _fish_nodes: Dictionary[StringName, CoolerFishSpriteType] = {} var _fish_nodes: Dictionary[StringName, CoolerFishSpriteType] = {}
var _cooler_slot_nodes: Array[Panel] = []
var _sorted_catches: Array[FishCatchType] = [] var _sorted_catches: Array[FishCatchType] = []
var _bag_item_nodes: Dictionary[StringName, BagItemSpriteType] = {} var _bag_item_nodes: Dictionary[StringName, BagItemSpriteType] = {}
var _bag_slot_nodes: Array[Panel] = []
var _sorted_bag_items: Array[OwnedItemType] = [] var _sorted_bag_items: Array[OwnedItemType] = []
var _bag_drag_active: bool = false var _bag_drag_active: bool = false
var _motion_elapsed: float = 0.0 var _motion_elapsed: float = 0.0
@ -594,16 +607,13 @@ func setup_controller_mapping(
) -> void: ) -> void:
_controller_mapping_manager = mapping_manager _controller_mapping_manager = mapping_manager
_profile_page.setup_controller_mapping(_controller_mapping_manager) _profile_page.setup_controller_mapping(_controller_mapping_manager)
_the_net_page.setup_controller_mapping(_controller_mapping_manager)
func set_profile_preview_world_pixel_size(pixel_size: int) -> void: func set_profile_preview_world_pixel_size(pixel_size: int) -> void:
_profile_page.set_world_pixel_size(pixel_size) _profile_page.set_world_pixel_size(pixel_size)
var _left_page_trigger_held: bool = false
var _right_page_trigger_held: bool = false
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if event is InputEventKey and event.echo: if event is InputEventKey and event.echo:
return return
@ -616,9 +626,6 @@ func _input(event: InputEvent) -> void:
if _handle_controller_page_switch(event): if _handle_controller_page_switch(event):
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
return return
if _handle_controller_secondary_switch(event):
get_viewport().set_input_as_handled()
return
if _handle_direct_page_shortcut(event): if _handle_direct_page_shortcut(event):
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
return return
@ -640,16 +647,21 @@ func _input(event: InputEvent) -> void:
func _handle_controller_ownership_input(event: InputEvent) -> bool: func _handle_controller_ownership_input(event: InputEvent) -> bool:
if not visible: if not visible:
return false return false
if not _is_inventory_section(_current_section):
return _handle_active_page_controller_input(event)
var button_event := event as InputEventJoypadButton var button_event := event as InputEventJoypadButton
var accept_pressed: bool = ( var accept_event: bool = (
button_event != null button_event != null
and button_event.pressed
and _event_matches_controller_role( and _event_matches_controller_role(
event, event,
ControllerMappingManagerType.ROLE_A, ControllerMappingManagerType.ROLE_A,
JOY_BUTTON_A, JOY_BUTTON_A,
) )
) )
var accept_pressed: bool = (
accept_event
and button_event.pressed
)
var cancel_pressed: bool = ( var cancel_pressed: bool = (
button_event != null button_event != null
and button_event.pressed and button_event.pressed
@ -659,23 +671,24 @@ func _handle_controller_ownership_input(event: InputEvent) -> bool:
JOY_BUTTON_B, JOY_BUTTON_B,
) )
) )
var alternate_pressed: bool = ( if _controller_ownership == ControllerOwnership.INVENTORY_TABS:
button_event != null if cancel_pressed:
and button_event.pressed close_menu()
and _event_matches_controller_role( return true
event, if accept_pressed:
ControllerMappingManagerType.ROLE_Y, _enter_inventory_content_zone()
JOY_BUTTON_Y, return true
) if event.is_action_pressed("ui_left"):
) _switch_inventory_tab_direction(-1)
return true
if event.is_action_pressed("ui_right"):
_switch_inventory_tab_direction(1)
return true
return false
if _controller_ownership == ControllerOwnership.HOTBAR_MANAGEMENT: if _controller_ownership == ControllerOwnership.HOTBAR_MANAGEMENT:
if cancel_pressed: if cancel_pressed:
_release_controller_ownership(true, false) _release_controller_ownership(true, false)
return true return true
if alternate_pressed:
if _hotbar != null:
_hotbar.clear_slot(_hotbar.get_selected_slot())
return true
if accept_pressed: if accept_pressed:
return true return true
return false return false
@ -686,34 +699,90 @@ func _handle_controller_ownership_input(event: InputEvent) -> bool:
if cancel_pressed: if cancel_pressed:
_release_controller_ownership(true, true) _release_controller_ownership(true, true)
return true return true
if alternate_pressed:
return true
return false return false
if _controller_ownership == ControllerOwnership.NOTEPAD_ACTIONS: if _controller_ownership == ControllerOwnership.NOTEPAD_ACTIONS:
if cancel_pressed: if cancel_pressed:
_release_controller_ownership(true, false) _release_controller_ownership(true, false)
return true return true
if alternate_pressed: return false
return true if _controller_ownership == ControllerOwnership.SORT_FILTER:
var direction: int = 0 if cancel_pressed:
if event.is_action_pressed("ui_left") or event.is_action_pressed("ui_up"): _enter_inventory_content_zone()
direction = -1
elif (
event.is_action_pressed("ui_right")
or event.is_action_pressed("ui_down")
):
direction = 1
if direction != 0:
_focus_next_notepad_action(direction)
return true return true
return false return false
if alternate_pressed: if _controller_ownership == ControllerOwnership.ITEM_LIST:
return _try_begin_controller_hotbar_placement() if cancel_pressed:
if accept_pressed: _cancel_controller_accept_hold()
return _try_enter_notepad_controller_ownership() _enter_inventory_tabs_zone()
return true
if _event_matches_controller_role(
event,
ControllerMappingManagerType.ROLE_SELECT,
JOY_BUTTON_BACK,
):
if button_event != null and button_event.pressed:
if _current_section == Section.COOLER:
_enter_inventory_sort_zone()
return true
if accept_event:
if button_event.pressed:
_controller_accept_held = true
_controller_accept_hold_elapsed = 0.0
else:
var was_pending: bool = _controller_accept_held
_cancel_controller_accept_hold()
if was_pending:
_activate_inventory_selection()
return true
return false return false
func _handle_active_page_controller_input(event: InputEvent) -> bool:
var handled: bool = false
match _current_section:
Section.LOGBOOK:
handled = _catalog_logbook.handle_controller_input(event)
Section.NET:
handled = _the_net_page.handle_controller_input(event)
Section.MAIL:
handled = _mail_page.handle_controller_input(event)
Section.PROFILE:
handled = _profile_page.handle_controller_input(event)
Section.PLAYERS:
handled = _players_page.handle_controller_input(event)
if handled:
return true
var button_event := event as InputEventJoypadButton
if (
button_event != null
and button_event.pressed
and _event_matches_controller_role(
event,
ControllerMappingManagerType.ROLE_B,
JOY_BUTTON_B,
)
):
close_menu()
return true
return false
func _activate_inventory_selection() -> void:
if _try_enter_notepad_controller_ownership():
return
if _current_section != Section.BAG:
return
var focus_owner: Control = get_viewport().gui_get_focus_owner()
var item_node := focus_owner as BagItemSpriteType
if item_node != null and not item_node.item_id.is_empty():
_select_bag_item(item_node.item_id)
func _cancel_controller_accept_hold() -> void:
_controller_accept_held = false
_controller_accept_hold_elapsed = 0.0
func _event_matches_controller_role( func _event_matches_controller_role(
event: InputEvent, event: InputEvent,
role: StringName, role: StringName,
@ -761,35 +830,11 @@ func _try_enter_notepad_controller_ownership() -> bool:
_controller_source_identity = source_identity _controller_source_identity = source_identity
_controller_notepad_actions = actions _controller_notepad_actions = actions
_controller_ownership = ControllerOwnership.NOTEPAD_ACTIONS _controller_ownership = ControllerOwnership.NOTEPAD_ACTIONS
ControllerFocusNavigationType.configure_spatial_neighbors(actions)
actions.front().call_deferred("grab_focus") actions.front().call_deferred("grab_focus")
return true return true
func _focus_next_notepad_action(direction: int) -> void:
var available: Array[BaseButton] = []
for action: BaseButton in _controller_notepad_actions:
if (
is_instance_valid(action)
and action.visible
and not action.disabled
and action.focus_mode != Control.FOCUS_NONE
):
available.append(action)
if available.is_empty():
return
var focused: Control = get_viewport().gui_get_focus_owner()
var current_index: int = available.find(focused)
if current_index < 0:
current_index = 0 if direction > 0 else available.size() - 1
else:
current_index = wrapi(
current_index + direction,
0,
available.size(),
)
available[current_index].grab_focus()
func _try_begin_controller_hotbar_placement() -> bool: func _try_begin_controller_hotbar_placement() -> bool:
if _hotbar == null: if _hotbar == null:
return false return false
@ -946,88 +991,6 @@ func _restore_controller_item_focus(
func _handle_controller_page_switch(event: InputEvent) -> bool: func _handle_controller_page_switch(event: InputEvent) -> bool:
var button_event: InputEventJoypadButton = event as InputEventJoypadButton
var motion_event: InputEventJoypadMotion = event as InputEventJoypadMotion
var use_mapping: bool = (
_controller_mapping_manager != null
)
var uses_left_trigger: bool = (
_controller_mapping_manager.event_uses_role(
event, ControllerMappingManagerType.ROLE_POINTER_MODIFIER
)
if use_mapping
else (
motion_event != null
and motion_event.axis == JOY_AXIS_TRIGGER_LEFT
)
)
var uses_right_trigger: bool = (
_controller_mapping_manager.event_uses_role(
event, ControllerMappingManagerType.ROLE_CAMERA_ZOOM
)
if use_mapping
else (
motion_event != null
and motion_event.axis == JOY_AXIS_TRIGGER_RIGHT
)
)
if (
not (uses_left_trigger or uses_right_trigger)
or not visible
):
return false
var is_pressed: bool = (
button_event.pressed
if button_event != null
else motion_event.axis_value > 0.5
)
if not is_pressed:
if uses_left_trigger:
_left_page_trigger_held = false
if uses_right_trigger:
_right_page_trigger_held = false
return true
if (
(uses_left_trigger and _left_page_trigger_held)
or (uses_right_trigger and _right_page_trigger_held)
):
return true
if uses_left_trigger:
_left_page_trigger_held = true
if uses_right_trigger:
_right_page_trigger_held = true
# Trigger input belongs to the Player Menu while it is visible, even when a
# transition or modal temporarily prevents changing pages.
if (
_transitioning
or _page_transitioning
or _sale_confirmation.visible
or get_viewport().gui_is_dragging()
or _controller_ownership != ControllerOwnership.ITEM_LIST
):
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_trigger else 1
)
var next_index: int = wrapi(current_index + direction, 0, sections.size())
_show_section(sections[next_index])
return true
func _handle_controller_secondary_switch(event: InputEvent) -> bool:
var button_event := event as InputEventJoypadButton var button_event := event as InputEventJoypadButton
if button_event == null or not visible: if button_event == null or not visible:
return false return false
@ -1050,59 +1013,40 @@ func _handle_controller_secondary_switch(event: InputEvent) -> bool:
return false return false
if not button_event.pressed: if not button_event.pressed:
return true return true
var direction: int = -1 if uses_left_bumper else 1
if _controller_ownership == ControllerOwnership.HOTBAR_MANAGEMENT:
_cycle_from_controller_hotbar_management(direction)
return true
if ( if (
_transitioning _transitioning
or _page_transitioning or _page_transitioning
or _sale_confirmation.visible or _sale_confirmation.visible
or get_viewport().gui_is_dragging() or get_viewport().gui_is_dragging()
or _controller_ownership != ControllerOwnership.ITEM_LIST
): ):
return true return true
if _is_inventory_section(_current_section): var sections: Array[Section] = [
var inventory_index: int = _get_inventory_tab_index() _last_inventory_section,
if ( Section.LOGBOOK,
(direction > 0 and inventory_index == 3) Section.NET,
or (direction < 0 and inventory_index == 0) Section.MAIL,
): Section.PROFILE,
_begin_controller_hotbar_management() Section.PLAYERS,
return true ]
_show_inventory_tab(inventory_index + direction) 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 = clampi(
current_index + direction,
0,
sections.size() - 1,
)
if next_index == current_index:
return true return true
_cycle_visible_secondary_tabs(direction) _cancel_controller_accept_hold()
_release_controller_ownership(false, true)
_show_section(sections[next_index])
return true return true
func _begin_controller_hotbar_management() -> void:
if _hotbar == null:
return
_controller_source_section = _current_section
_controller_source_identity = StringName()
var focus_owner: Control = get_viewport().gui_get_focus_owner()
if _current_section == Section.COOLER:
var fish_node := focus_owner as CoolerFishSpriteType
if fish_node != null:
_controller_source_identity = fish_node.catch_id
elif _current_section == Section.BAG:
var item_node := focus_owner as BagItemSpriteType
if item_node != null:
_controller_source_identity = item_node.item_id
_controller_ownership = ControllerOwnership.HOTBAR_MANAGEMENT
controller_hotbar_management_requested.emit(_hotbar.get_selected_slot())
func _cycle_from_controller_hotbar_management(direction: int) -> void:
_release_controller_ownership(false, false)
var target_index: int = 0 if direction > 0 else 3
if target_index == _get_inventory_tab_index():
call_deferred("_focus_current_section")
else:
_show_inventory_tab(target_index)
func _get_inventory_tab_index() -> int: func _get_inventory_tab_index() -> int:
if _current_section == Section.COOLER: if _current_section == Section.COOLER:
return 0 return 0
@ -1123,41 +1067,69 @@ func _show_inventory_tab(index: int) -> void:
_show_section(Section.TACKLE_BOX) _show_section(Section.TACKLE_BOX)
func _cycle_visible_secondary_tabs(direction: int) -> bool: func _switch_inventory_tab_direction(direction: int) -> void:
var navigation_cluster := get_node_or_null("%NavigationCluster") as Control var current_index: int = _get_inventory_tab_index()
var grouped_buttons: Dictionary = {} var target_index: int = clampi(current_index + direction, 0, 3)
_collect_visible_toggle_buttons(self, navigation_cluster, grouped_buttons) if target_index == current_index:
var selected_group: Variant = null return
var selected_group_y: float = INF _show_inventory_tab(target_index)
for group_value: Variant in grouped_buttons.keys():
var buttons := grouped_buttons[group_value] as Array
if buttons.size() < 2: func _enter_inventory_tabs_zone() -> void:
continue if not _is_inventory_section(_current_section):
var group_y: float = INF return
for item: Variant in buttons: _cancel_controller_accept_hold()
var button := item as BaseButton _controller_ownership = ControllerOwnership.INVENTORY_TABS
group_y = minf(group_y, button.global_position.y) var active_tab: Button = _inventory_tab_for_section(_current_section)
if group_y < selected_group_y: if (
selected_group = group_value active_tab != null
selected_group_y = group_y and active_tab.is_visible_in_tree()
if selected_group == null: and active_tab.focus_mode != Control.FOCUS_NONE
return false ):
var tabs := grouped_buttons[selected_group] as Array active_tab.call_deferred("grab_focus")
tabs.sort_custom(func(first: BaseButton, second: BaseButton) -> bool:
return first.global_position.x < second.global_position.x
func _enter_inventory_content_zone() -> void:
if not _is_inventory_section(_current_section):
return
_cancel_controller_accept_hold()
_controller_ownership = ControllerOwnership.ITEM_LIST
call_deferred("_focus_current_section")
func _enter_inventory_sort_zone() -> void:
if _current_section != Section.COOLER:
return
_cancel_controller_accept_hold()
_controller_ownership = ControllerOwnership.SORT_FILTER
_controller_source_section = _current_section
var sort_controls: Array[Control] = [
_cooler_sort_option,
_cooler_sort_direction,
]
ControllerFocusNavigationType.configure_spatial_neighbors(
sort_controls
) )
var current_index: int = 0 _cooler_sort_option.call_deferred("grab_focus")
for index: int in tabs.size():
var tab := tabs[index] as BaseButton
if tab.button_pressed: func _reset_controller_zone_for_section() -> void:
current_index = index _cancel_controller_accept_hold()
break if _is_inventory_section(_current_section):
var target := tabs[wrapi( _enter_inventory_tabs_zone()
current_index + direction, 0, tabs.size() return
)] as BaseButton _controller_ownership = ControllerOwnership.PAGE_CONTENT
target.set_pressed_no_signal(true) match _current_section:
target.pressed.emit() Section.LOGBOOK:
return true _catalog_logbook.reset_controller_zone()
Section.NET:
_the_net_page.reset_controller_zone()
Section.MAIL:
_mail_page.reset_controller_zone()
Section.PROFILE:
_profile_page.reset_controller_zone()
Section.PLAYERS:
_players_page.reset_controller_zone()
func _collect_visible_toggle_buttons( func _collect_visible_toggle_buttons(
@ -1659,6 +1631,7 @@ func _focus_current_section() -> void:
_collect_focusable_content(self, navigation_cluster, candidates) _collect_focusable_content(self, navigation_cluster, candidates)
if candidates.is_empty(): if candidates.is_empty():
return return
ControllerFocusNavigationType.configure_spatial_neighbors(candidates)
candidates.sort_custom(func(first: Control, second: Control) -> bool: candidates.sort_custom(func(first: Control, second: Control) -> bool:
if not is_equal_approx(first.global_position.y, second.global_position.y): if not is_equal_approx(first.global_position.y, second.global_position.y):
return first.global_position.y < second.global_position.y return first.global_position.y < second.global_position.y
@ -1679,8 +1652,7 @@ func _collect_focusable_content(
if control != null and not control.is_visible_in_tree(): if control != null and not control.is_visible_in_tree():
continue continue
if ( if (
control != null ControllerFocusNavigationType.is_focusable(control)
and control.focus_mode != Control.FOCUS_NONE
and not control is ScrollBar and not control is ScrollBar
): ):
output.append(control) output.append(control)
@ -1689,6 +1661,19 @@ func _collect_focusable_content(
func _process(delta: float) -> void: func _process(delta: float) -> void:
if visible or _shop_cooler_context_active: if visible or _shop_cooler_context_active:
if (
visible
and _controller_accept_held
and _controller_ownership == ControllerOwnership.ITEM_LIST
and _current_section in [Section.COOLER, Section.BAG]
):
_controller_accept_hold_elapsed += delta
if (
_controller_accept_hold_elapsed
>= CONTROLLER_PICKUP_HOLD_SECONDS
):
_cancel_controller_accept_hold()
_try_begin_controller_hotbar_placement()
_motion_elapsed += delta _motion_elapsed += delta
if visible: if visible:
_navigation_cluster.advance_motion(delta) _navigation_cluster.advance_motion(delta)
@ -1815,10 +1800,10 @@ func _configure_sale_confirmation_focus() -> void:
_confirm_sale_button.focus_neighbor_left _confirm_sale_button.focus_neighbor_left
) )
_confirm_sale_button.focus_neighbor_top = ( _confirm_sale_button.focus_neighbor_top = (
_confirm_sale_button.focus_neighbor_left _confirm_sale_button.get_path_to(_confirm_sale_button)
) )
_confirm_sale_button.focus_neighbor_bottom = ( _confirm_sale_button.focus_neighbor_bottom = (
_confirm_sale_button.focus_neighbor_left _confirm_sale_button.focus_neighbor_top
) )
_cancel_sale_button.focus_neighbor_left = ( _cancel_sale_button.focus_neighbor_left = (
_cancel_sale_button.get_path_to(_confirm_sale_button) _cancel_sale_button.get_path_to(_confirm_sale_button)
@ -1827,10 +1812,10 @@ func _configure_sale_confirmation_focus() -> void:
_cancel_sale_button.focus_neighbor_left _cancel_sale_button.focus_neighbor_left
) )
_cancel_sale_button.focus_neighbor_top = ( _cancel_sale_button.focus_neighbor_top = (
_cancel_sale_button.focus_neighbor_left _cancel_sale_button.get_path_to(_cancel_sale_button)
) )
_cancel_sale_button.focus_neighbor_bottom = ( _cancel_sale_button.focus_neighbor_bottom = (
_cancel_sale_button.focus_neighbor_left _cancel_sale_button.focus_neighbor_top
) )
if not _sale_confirmation.visible: if not _sale_confirmation.visible:
for button: Button in [_confirm_sale_button, _cancel_sale_button]: for button: Button in [_confirm_sale_button, _cancel_sale_button]:
@ -1906,6 +1891,9 @@ func _show_bag_view(view: BagView) -> void:
return return
if _bag_view == view: if _bag_view == view:
return return
var preserve_tab_zone: bool = (
_controller_ownership == ControllerOwnership.INVENTORY_TABS
)
_release_controller_ownership(false, true) _release_controller_ownership(false, true)
_bag_view = view _bag_view = view
_selected_bag_item_id = StringName() _selected_bag_item_id = StringName()
@ -1915,7 +1903,10 @@ func _show_bag_view(view: BagView) -> void:
_refresh_inventory_organizer_tabs() _refresh_inventory_organizer_tabs()
_reserve_visible_secondary_navigation() _reserve_visible_secondary_navigation()
_set_content_interactive(true) _set_content_interactive(true)
call_deferred("_focus_current_section") if preserve_tab_zone:
_enter_inventory_tabs_zone()
else:
call_deferred("_focus_current_section")
func _refresh_tackle_box() -> void: func _refresh_tackle_box() -> void:
@ -2058,7 +2049,7 @@ func _configure_tackle_column_focus(
button.focus_neighbor_left = button.get_path_to(left_target) button.focus_neighbor_left = button.get_path_to(left_target)
button.focus_neighbor_right = button.get_path_to(right_target) button.focus_neighbor_right = button.get_path_to(right_target)
button.focus_neighbor_top = ( button.focus_neighbor_top = (
button.get_path_to(_tackle_sub_tab) button.get_path_to(button)
if index < TACKLE_GRID_COLUMNS if index < TACKLE_GRID_COLUMNS
else button.get_path_to(buttons[index - TACKLE_GRID_COLUMNS]) else button.get_path_to(buttons[index - TACKLE_GRID_COLUMNS])
) )
@ -2756,7 +2747,7 @@ func _finish_menu_entry(generation: int) -> void:
_presentation_tween = null _presentation_tween = null
_transitioning = false _transitioning = false
_set_shell_interactive(true) _set_shell_interactive(true)
_focus_current_section() _reset_controller_zone_for_section()
func _begin_menu_exit(reason: CloseReason, restore_controls: bool) -> void: func _begin_menu_exit(reason: CloseReason, restore_controls: bool) -> void:
@ -2972,7 +2963,7 @@ func _finish_page_transition(generation: int) -> void:
_inventory_sub_tabs.modulate.a = 1.0 _inventory_sub_tabs.modulate.a = 1.0
_reset_page_transition_visuals() _reset_page_transition_visuals()
_set_content_interactive(true) _set_content_interactive(true)
_focus_current_section() _reset_controller_zone_for_section()
func _set_shell_interactive(interactive: bool) -> void: func _set_shell_interactive(interactive: bool) -> void:
@ -3049,6 +3040,9 @@ func _set_content_interactive(interactive: bool) -> void:
_profile_page.set_interactive( _profile_page.set_interactive(
interactive and _current_section == Section.PROFILE interactive and _current_section == Section.PROFILE
) )
_players_page.set_interactive(
interactive and _current_section == Section.PLAYERS
)
var cooler_interactive: bool = ( var cooler_interactive: bool = (
interactive and _current_section == Section.COOLER interactive and _current_section == Section.COOLER
) )
@ -3303,7 +3297,7 @@ func _sync_bag_item_nodes(owned_items: Array[OwnedItemType]) -> void:
func _layout_bag_items() -> void: func _layout_bag_items() -> void:
if not is_node_ready() or _sorted_bag_items.is_empty(): if not is_node_ready():
return return
_bag_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED _bag_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
var columns: int = 3 var columns: int = 3
@ -3327,6 +3321,16 @@ func _layout_bag_items() -> void:
if _compact_layout if _compact_layout
else Vector2(820.0, 240.0) else Vector2(820.0, 240.0)
) )
_sync_inventory_slot_visuals(
_bag_item_field,
_bag_slot_nodes,
maxi(9, _sorted_bag_items.size()),
columns,
cell_size,
item_size,
origin,
18.0,
)
for index: int in _sorted_bag_items.size(): for index: int in _sorted_bag_items.size():
var owned: OwnedItemType = _sorted_bag_items[index] var owned: OwnedItemType = _sorted_bag_items[index]
var item_node := _bag_item_nodes.get( var item_node := _bag_item_nodes.get(
@ -3354,6 +3358,44 @@ func _layout_bag_items() -> void:
) )
func _sync_inventory_slot_visuals(
parent: Control,
slots: Array[Panel],
slot_count: int,
columns: int,
cell_size: Vector2,
slot_size: Vector2,
origin: Vector2,
alternate_lane_offset: float,
) -> void:
for slot: Panel in slots:
if is_instance_valid(slot):
slot.queue_free()
slots.clear()
var slot_color: Color = UtilityPageStyle.OCEAN_PANEL_MID
slot_color.a = 0.42
for index: int in slot_count:
var row: int = floori(float(index) / float(columns))
var column: int = index % columns
var slot := Panel.new()
slot.name = "InventorySlot%d" % index
slot.mouse_filter = Control.MOUSE_FILTER_IGNORE
slot.z_index = -1
slot.size = slot_size
slot.position = origin + Vector2(
float(column) * cell_size.x
+ (alternate_lane_offset if row % 2 == 1 else 0.0),
float(row) * cell_size.y,
)
slot.add_theme_stylebox_override(
"panel",
UtilityPageStyle.rounded_style(slot_color, 14),
)
parent.add_child(slot)
parent.move_child(slot, 0)
slots.append(slot)
func _configure_bag_item_focus() -> void: func _configure_bag_item_focus() -> void:
if _current_section != Section.BAG: if _current_section != Section.BAG:
return return
@ -3374,9 +3416,12 @@ func _configure_bag_item_focus() -> void:
for index: int in controls.size(): for index: int in controls.size():
var control: BagItemSpriteType = controls[index] var control: BagItemSpriteType = controls[index]
var column: int = index % 3 var column: int = index % 3
var row: int = floori(float(index) / 3.0) var left_index: int = index - 1 if column > 0 else index
var left_index: int = row * 3 + maxi(column - 1, 0) var right_index: int = (
var right_index: int = mini(index + 1, controls.size() - 1) index + 1
if column < 2 and index + 1 < controls.size()
else index
)
control.focus_neighbor_left = control.get_path_to( control.focus_neighbor_left = control.get_path_to(
controls[left_index] controls[left_index]
) )
@ -3384,12 +3429,14 @@ func _configure_bag_item_focus() -> void:
controls[right_index] controls[right_index]
) )
control.focus_neighbor_top = ( control.focus_neighbor_top = (
control.get_path_to(active_tab) control.get_path_to(control)
if index < 3 if index < 3
else control.get_path_to(controls[index - 3]) else control.get_path_to(controls[index - 3])
) )
control.focus_neighbor_bottom = control.get_path_to( control.focus_neighbor_bottom = control.get_path_to(
controls[mini(index + 3, controls.size() - 1)] controls[index + 3]
if index + 3 < controls.size()
else control
) )
@ -3654,9 +3701,13 @@ func _layout_cooler_fish(animate: bool = true) -> void:
) )
var side_margin: float = 2.0 if _compact_layout else 8.0 var side_margin: float = 2.0 if _compact_layout else 8.0
var top_margin: float = 4.0 if _compact_layout else 6.0 var top_margin: float = 4.0 if _compact_layout else 6.0
var slot_count: int = maxi(
_sorted_catches.size(),
_cooler_capacity.get_capacity() if _cooler_capacity != null else 0,
)
var required_rows: int = ceili( var required_rows: int = ceili(
float(_sorted_catches.size()) / float(columns) float(slot_count) / float(columns)
) if not _sorted_catches.is_empty() else 0 ) if slot_count > 0 else 0
var required_height: float = ( var required_height: float = (
top_margin * 2.0 + float(required_rows) * cell_size.y top_margin * 2.0 + float(required_rows) * cell_size.y
) )
@ -3667,6 +3718,16 @@ func _layout_cooler_fish(animate: bool = true) -> void:
_cooler_host.custom_minimum_size = content_size _cooler_host.custom_minimum_size = content_size
_fish_field.custom_minimum_size = content_size _fish_field.custom_minimum_size = content_size
_cooler_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO _cooler_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
_sync_inventory_slot_visuals(
_fish_field,
_cooler_slot_nodes,
slot_count,
columns,
cell_size,
fish_size,
Vector2(side_margin, top_margin),
5.0,
)
for index: int in _sorted_catches.size(): for index: int in _sorted_catches.size():
var fish_catch: FishCatchType = _sorted_catches[index] var fish_catch: FishCatchType = _sorted_catches[index]
var fish_node := _fish_nodes.get( var fish_node := _fish_nodes.get(
@ -3724,31 +3785,38 @@ func _configure_cooler_fish_focus() -> void:
) )
for index: int in controls.size(): for index: int in controls.size():
var control: CoolerFishSpriteType = controls[index] var control: CoolerFishSpriteType = controls[index]
var column: int = index % columns
control.focus_neighbor_left = control.get_path_to( control.focus_neighbor_left = control.get_path_to(
controls[maxi(index - 1, 0)] controls[index - 1] if column > 0 else control
) )
control.focus_neighbor_right = control.get_path_to( control.focus_neighbor_right = control.get_path_to(
controls[mini(index + 1, controls.size() - 1)] controls[index + 1]
if column < columns - 1 and index + 1 < controls.size()
else control
) )
control.focus_neighbor_top = ( control.focus_neighbor_top = (
control.get_path_to(top_control) control.get_path_to(top_control)
if _shop_cooler_context_active and index < columns
else control.get_path_to(control)
if index < columns if index < columns
else control.get_path_to(controls[index - columns]) else control.get_path_to(controls[index - columns])
) )
control.focus_neighbor_bottom = ( control.focus_neighbor_bottom = (
control.get_path_to(_favorite_bubble) control.get_path_to(_favorite_bubble)
if ( if (
not _favorite_bubble.disabled _shop_cooler_context_active
and not _favorite_bubble.disabled
and index + columns >= controls.size() and index + columns >= controls.size()
) )
else control.get_path_to(_sell_all_bubble) else control.get_path_to(_sell_all_bubble)
if ( if (
not _sell_all_bubble.disabled _shop_cooler_context_active
and not _sell_all_bubble.disabled
and index + columns >= controls.size() and index + columns >= controls.size()
) )
else control.get_path_to( else control.get_path_to(controls[index + columns])
controls[mini(index + columns, controls.size() - 1)] if index + columns < controls.size()
) else control.get_path_to(control)
) )
var focused_node := _fish_nodes.get( var focused_node := _fish_nodes.get(
_fish_selection.get_focused_id() _fish_selection.get_focused_id()

View file

@ -2,6 +2,14 @@ class_name PlayersPage
extends Control extends Control
const TOGGLE_STATE_COLOR := Color("c3dfe6") const TOGGLE_STATE_COLOR := Color("c3dfe6")
const DialogControllerNavigationType = preload(
"res://ui/file_dialog_controller_navigation.gd"
)
enum ControllerZone {
TABS,
BODY,
}
var _service: NetworkPlayerListService var _service: NetworkPlayerListService
var _discovery: DiscoveryClient var _discovery: DiscoveryClient
@ -17,6 +25,9 @@ var _discoverable_toggle: Button
var _discoverable_state_label: Label var _discoverable_state_label: Label
var _host_discovery_status: Label var _host_discovery_status: Label
var _current_tab := 0 var _current_tab := 0
var _active: bool = false
var _interactive: bool = false
var _controller_zone: ControllerZone = ControllerZone.TABS
func _ready() -> void: func _ready() -> void:
@ -45,12 +56,107 @@ func setup(
func activate() -> void: func activate() -> void:
_active = true
_refresh() _refresh()
_focus_first() reset_controller_zone()
func deactivate() -> void: func deactivate() -> void:
_active = false
_status.text = "" _status.text = ""
set_interactive(false)
func set_interactive(interactive: bool) -> void:
_interactive = interactive and _active
var body_controls: Array[Control] = _body_controls()
for index: int in _tabs.get_child_count():
var tab := _tabs.get_child(index) as Button
if tab != null:
tab.focus_mode = (
Control.FOCUS_ALL
if _interactive
and _controller_zone == ControllerZone.TABS
and tab.visible
else Control.FOCUS_NONE
)
for control: Control in body_controls:
var button := control as BaseButton
control.focus_mode = (
Control.FOCUS_ALL
if _interactive
and _controller_zone == ControllerZone.BODY
and (button == null or not button.disabled)
else Control.FOCUS_NONE
)
ControllerFocusNavigation.configure_spatial_neighbors(body_controls)
func reset_controller_zone() -> void:
_controller_zone = ControllerZone.TABS
set_interactive(_interactive)
call_deferred("_focus_controller_zone")
func handle_controller_input(event: InputEvent) -> bool:
if not _active or not _interactive:
return false
if event.is_action_pressed("ui_cancel"):
if _controller_zone == ControllerZone.BODY:
_controller_zone = ControllerZone.TABS
set_interactive(_interactive)
call_deferred("_focus_controller_zone")
return true
return false
if _controller_zone == ControllerZone.TABS:
var direction: int = 0
if event.is_action_pressed("ui_left"):
direction = -1
elif event.is_action_pressed("ui_right"):
direction = 1
if direction != 0:
_select_adjacent_controller_tab(direction)
return true
if event.is_action_pressed("ui_accept"):
if not _body_controls().is_empty():
_controller_zone = ControllerZone.BODY
set_interactive(_interactive)
call_deferred("_focus_controller_zone")
return true
return false
func _select_adjacent_controller_tab(direction: int) -> void:
var available: Array[int] = []
for index: int in _tabs.get_child_count():
var tab := _tabs.get_child(index) as Button
if tab != null and tab.visible:
available.append(index)
var current_position: int = available.find(_current_tab)
var target_position: int = clampi(
current_position + direction,
0,
available.size() - 1,
)
if target_position != current_position:
_select_tab(available[target_position])
func _focus_controller_zone() -> void:
if not _interactive:
return
if _controller_zone == ControllerZone.TABS:
var tab := _tabs.get_child(_current_tab) as Button
if tab != null and tab.visible:
tab.grab_focus()
return
_focus_first()
func _body_controls() -> Array[Control]:
var controls: Array[Control] = []
_collect_focusable_player_controls(self, controls)
return controls
func _build() -> void: func _build() -> void:
@ -196,7 +302,7 @@ func _select_tab(index: int) -> void:
return return
_current_tab = index _current_tab = index
_refresh() _refresh()
_focus_first() call_deferred("_focus_controller_zone")
func _refresh() -> void: func _refresh() -> void:
@ -221,6 +327,9 @@ func _refresh() -> void:
_build_relationship_rows() _build_relationship_rows()
2: 2:
_build_ban_rows() _build_ban_rows()
if _controller_zone == ControllerZone.BODY and _body_controls().is_empty():
_controller_zone = ControllerZone.TABS
set_interactive(_interactive)
func _refresh_host_settings() -> void: func _refresh_host_settings() -> void:
@ -582,11 +691,22 @@ func _confirm(text: String, action: Callable) -> void:
) )
add_child(dialog) add_child(dialog)
dialog.popup_centered(Vector2i(520, 220)) dialog.popup_centered(Vector2i(520, 220))
_configure_confirmation_dialog.call_deferred(dialog)
func _configure_confirmation_dialog(dialog: ConfirmationDialog) -> void:
if dialog != null and is_instance_valid(dialog) and dialog.visible:
DialogControllerNavigationType.configure_scope(
dialog, dialog.get_cancel_button()
)
func _focus_first() -> void: func _focus_first() -> void:
var candidates: Array[Control] = [] var candidates: Array[Control] = []
_collect_focusable_player_controls(self, candidates) _collect_focusable_player_controls(self, candidates)
candidates = candidates.filter(func(control: Control) -> bool:
return control.focus_mode != Control.FOCUS_NONE
)
if candidates.is_empty(): if candidates.is_empty():
return return
candidates.sort_custom(func(first: Control, second: Control) -> bool: candidates.sort_custom(func(first: Control, second: Control) -> bool:
@ -609,8 +729,7 @@ func _collect_focusable_player_controls(
continue continue
if ( if (
control != null control != null
and control.focus_mode != Control.FOCUS_NONE and (control is BaseButton or control is LineEdit)
and not control is ScrollBar
): ):
output.append(control) output.append(control)
_collect_focusable_player_controls(child, output) _collect_focusable_player_controls(child, output)

View file

@ -197,6 +197,22 @@ func _set_interactive(interactive: bool) -> void:
if interactive if interactive
else Control.MOUSE_FILTER_IGNORE else Control.MOUSE_FILTER_IGNORE
) )
# Most settings pages are configured while their parent panel is hidden.
# Rebuild the graph after a page becomes visible so hidden-at-ready controls
# do not retain the empty neighbors produced by the initial layout pass.
if interactive:
call_deferred("_refresh_focus_navigation")
else:
ControllerFocusNavigationType.configure_spatial_neighbors(
_focus_controls
)
func _refresh_focus_navigation() -> void:
if visible and not _is_transitioning:
ControllerFocusNavigationType.configure_spatial_neighbors(
_focus_controls
)
func _finish_transition_out(generation: int, completed: Callable) -> void: func _finish_transition_out(generation: int, completed: Callable) -> void:

View file

@ -25,6 +25,15 @@ const ControllerMappingManagerType = preload(
const ControllerMappingPanelType = preload( const ControllerMappingPanelType = preload(
"res://ui/controller_mapping_panel.gd" "res://ui/controller_mapping_panel.gd"
) )
const KeyboardMouseMappingManagerType = preload(
"res://settings/keyboard_mouse_mapping_manager.gd"
)
const KeyboardMouseMappingPanelType = preload(
"res://ui/keyboard_mouse_mapping_panel.gd"
)
const DialogControllerNavigationType = preload(
"res://ui/file_dialog_controller_navigation.gd"
)
signal applied signal applied
signal closed signal closed
@ -110,6 +119,8 @@ var _host_identity: HostIdentityStore
var _interface_fonts: InterfaceFontController var _interface_fonts: InterfaceFontController
var _controller_mapping_manager: ControllerMappingManagerType var _controller_mapping_manager: ControllerMappingManagerType
var _controller_mapping_panel: ControllerMappingPanelType var _controller_mapping_panel: ControllerMappingPanelType
var _keyboard_mouse_mapping_manager: KeyboardMouseMappingManagerType
var _keyboard_mouse_mapping_panel: KeyboardMouseMappingPanelType
var _data_folder_dialog: FileDialog var _data_folder_dialog: FileDialog
var _backup_file_dialog: FileDialog var _backup_file_dialog: FileDialog
var _export_file_dialog: FileDialog var _export_file_dialog: FileDialog
@ -144,6 +155,7 @@ func _ready() -> void:
%SoundBackButton.pressed.connect(handle_back) %SoundBackButton.pressed.connect(handle_back)
%ControlsBackButton.pressed.connect(handle_back) %ControlsBackButton.pressed.connect(handle_back)
%ControllerMapping.pressed.connect(_open_controller_mapping) %ControllerMapping.pressed.connect(_open_controller_mapping)
%KeyboardMapping.pressed.connect(_open_keyboard_mouse_mapping)
%AccessibilityBackButton.pressed.connect(handle_back) %AccessibilityBackButton.pressed.connect(handle_back)
%DataBackButton.pressed.connect(handle_back) %DataBackButton.pressed.connect(handle_back)
%RootBackButton.gui_input.connect(_on_back_bubble_gui_input) %RootBackButton.gui_input.connect(_on_back_bubble_gui_input)
@ -230,6 +242,11 @@ func _ready() -> void:
_controller_mapping_panel.closed.connect( _controller_mapping_panel.closed.connect(
_on_controller_mapping_panel_closed _on_controller_mapping_panel_closed
) )
_keyboard_mouse_mapping_panel = KeyboardMouseMappingPanelType.new()
add_child(_keyboard_mouse_mapping_panel)
_keyboard_mouse_mapping_panel.closed.connect(
_on_keyboard_mouse_mapping_panel_closed
)
_style_data_page() _style_data_page()
_style_sound_page() _style_sound_page()
call_deferred("_refresh_panel_size") call_deferred("_refresh_panel_size")
@ -250,6 +267,13 @@ func setup_controller_mapping(
_controller_mapping_panel.setup(_controller_mapping_manager) _controller_mapping_panel.setup(_controller_mapping_manager)
func setup_keyboard_mouse_mapping(
mapping_manager: KeyboardMouseMappingManagerType,
) -> void:
_keyboard_mouse_mapping_manager = mapping_manager
_keyboard_mouse_mapping_panel.setup(_keyboard_mouse_mapping_manager)
func setup_data_and_identity( func setup_data_and_identity(
data_root: PlayerDataRoot, data_root: PlayerDataRoot,
identity_backups: IdentityBackupService, identity_backups: IdentityBackupService,
@ -328,6 +352,11 @@ func _finish_panel_close(applied_result: bool) -> void:
and _controller_mapping_panel.is_open() and _controller_mapping_panel.is_open()
): ):
_controller_mapping_panel.close_panel() _controller_mapping_panel.close_panel()
if (
_keyboard_mouse_mapping_panel != null
and _keyboard_mouse_mapping_panel.is_open()
):
_keyboard_mouse_mapping_panel.close_panel()
for page: SettingsBubblePage in _pages.values(): for page: SettingsBubblePage in _pages.values():
page.hide_page() page.hide_page()
_page_stack.clear() _page_stack.clear()
@ -341,6 +370,12 @@ func _finish_panel_close(applied_result: bool) -> void:
func handle_back() -> void: func handle_back() -> void:
if (
_keyboard_mouse_mapping_panel != null
and _keyboard_mouse_mapping_panel.is_open()
):
_keyboard_mouse_mapping_panel.request_back()
return
if ( if (
_controller_mapping_panel != null _controller_mapping_panel != null
and _controller_mapping_panel.is_open() and _controller_mapping_panel.is_open()
@ -366,6 +401,17 @@ func _on_controller_mapping_panel_closed() -> void:
%ControllerMapping.grab_focus() %ControllerMapping.grab_focus()
func _open_keyboard_mouse_mapping() -> void:
if _keyboard_mouse_mapping_manager == null:
_feedback.text = "no keyboard binding service is available"
return
_keyboard_mouse_mapping_panel.open_panel()
func _on_keyboard_mouse_mapping_panel_closed() -> void:
%KeyboardMapping.grab_focus()
func get_active_page_id() -> StringName: func get_active_page_id() -> StringName:
return _page_stack.back() if not _page_stack.is_empty() else StringName() return _page_stack.back() if not _page_stack.is_empty() else StringName()
@ -377,6 +423,16 @@ func is_controller_mapping_capturing() -> bool:
) )
func is_input_mapping_capturing() -> bool:
return (
is_controller_mapping_capturing()
or (
_keyboard_mouse_mapping_panel != null
and _keyboard_mouse_mapping_panel.is_capturing()
)
)
func _push_page(page_id: StringName) -> void: func _push_page(page_id: StringName) -> void:
if ( if (
_page_transition_active _page_transition_active
@ -579,6 +635,9 @@ func _show_existing_data_folder_choice(path: String) -> void:
_interface_fonts.apply_utility_theme(dialog) _interface_fonts.apply_utility_theme(dialog)
add_child(dialog) add_child(dialog)
dialog.popup_centered(Vector2i(620, 340)) dialog.popup_centered(Vector2i(620, 340))
_configure_confirmation_dialog.call_deferred(
dialog, dialog.get_cancel_button()
)
func _choose_identity_export(identity_type: String) -> void: func _choose_identity_export(identity_type: String) -> void:
@ -662,7 +721,9 @@ func _show_passphrase_dialog(exporting: bool) -> void:
if exporting else "Enter the backup passphrase." if exporting else "Enter the backup passphrase."
) )
_passphrase_dialog.popup_centered(Vector2i(560, 300)) _passphrase_dialog.popup_centered(Vector2i(560, 300))
_passphrase_entry.grab_focus() _configure_confirmation_dialog.call_deferred(
_passphrase_dialog, _passphrase_entry
)
func _submit_identity_passphrase() -> void: func _submit_identity_passphrase() -> void:
@ -707,6 +768,9 @@ func _show_identity_replacement_confirmation() -> void:
_interface_fonts.apply_utility_theme(dialog) _interface_fonts.apply_utility_theme(dialog)
add_child(dialog) add_child(dialog)
dialog.popup_centered(Vector2i(620, 360)) dialog.popup_centered(Vector2i(620, 360))
_configure_confirmation_dialog.call_deferred(
dialog, dialog.get_cancel_button()
)
func _advance_identity_replacement(dialog: ConfirmationDialog) -> void: func _advance_identity_replacement(dialog: ConfirmationDialog) -> void:
@ -717,6 +781,9 @@ func _advance_identity_replacement(dialog: ConfirmationDialog) -> void:
"Replace the active identity and archive the current key locally?" "Replace the active identity and archive the current key locally?"
) )
dialog.call_deferred("popup_centered", Vector2i(560, 280)) dialog.call_deferred("popup_centered", Vector2i(560, 280))
_configure_confirmation_dialog.call_deferred(
dialog, dialog.get_cancel_button()
)
return return
_confirm_identity_replacement(dialog) _confirm_identity_replacement(dialog)
@ -732,6 +799,16 @@ func _confirm_identity_replacement(dialog: ConfirmationDialog) -> void:
dialog.queue_free() dialog.queue_free()
func _configure_confirmation_dialog(
dialog: ConfirmationDialog,
preferred_control: Control,
) -> void:
if dialog != null and is_instance_valid(dialog) and dialog.visible:
DialogControllerNavigationType.configure_scope(
dialog, preferred_control
)
func _identity_operation_allowed() -> bool: func _identity_operation_allowed() -> bool:
if _identity_backups == null: if _identity_backups == null:
_feedback.text = "Identity backup is unavailable." _feedback.text = "Identity backup is unavailable."

View file

@ -600,8 +600,8 @@ grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
script = ExtResource("3_page") script = ExtResource("3_page")
page_id = &"controls" 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/OnScreenKeyboardToggle"), NodePath("BubbleCluster/ControllerMapping"), 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/OnScreenKeyboardToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/KeyboardMapping"), 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/OnScreenKeyboardToggle"), 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/OnScreenKeyboardToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/KeyboardMapping"), NodePath("BubbleCluster/ControlsBackButton")])
initial_focus_path = NodePath("BubbleCluster/MouseValue") initial_focus_path = NodePath("BubbleCluster/MouseValue")
back_focus_path = NodePath("BubbleCluster/ControlsBackButton") back_focus_path = NodePath("BubbleCluster/ControlsBackButton")
@ -713,15 +713,27 @@ motion_phase = 4.7
[node name="ControllerMapping" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")] [node name="ControllerMapping" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")]
unique_name_in_owner = true unique_name_in_owner = true
custom_minimum_size = Vector2(0, 0) custom_minimum_size = Vector2(0, 0)
text = "map\ncontroller" text = "controller\nbinds"
neutral_size = Vector2(160, 150) neutral_size = Vector2(130, 108)
desktop_anchor = Vector2(525, 390) desktop_anchor = Vector2(470, 390)
compact_anchor = Vector2(485, 355) compact_anchor = Vector2(425, 350)
compact_minimum_size = Vector2(148, 138) compact_minimum_size = Vector2(116, 98)
minimum_font_size = 14 minimum_font_size = 14
maximum_font_size = 23 maximum_font_size = 20
motion_phase = 4.9 motion_phase = 4.9
[node name="KeyboardMapping" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 0)
text = "keyboard\nbinds"
neutral_size = Vector2(130, 108)
desktop_anchor = Vector2(610, 380)
compact_anchor = Vector2(545, 345)
compact_minimum_size = Vector2(116, 98)
minimum_font_size = 14
maximum_font_size = 20
motion_phase = 5.2
[node name="ControlsBackButton" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")] [node name="ControlsBackButton" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")]
unique_name_in_owner = true unique_name_in_owner = true
custom_minimum_size = Vector2(0, 0) custom_minimum_size = Vector2(0, 0)
@ -845,8 +857,8 @@ grow_vertical = 2
script = ExtResource("3_page") script = ExtResource("3_page")
page_id = &"data" page_id = &"data"
bubble_paths = Array[NodePath]([NodePath("BubbleCluster/DataBackButton")]) bubble_paths = Array[NodePath]([NodePath("BubbleCluster/DataBackButton")])
focus_paths = Array[NodePath]([NodePath("BubbleCluster/DataBackButton")]) focus_paths = Array[NodePath]([NodePath("Paper/Content/OpenDataFolder"), NodePath("Paper/Content/ChangeDataFolder"), NodePath("Paper/Content/CopyPlayerFingerprint"), NodePath("Paper/Content/IdentityGrid/ExportPlayerIdentity"), NodePath("Paper/Content/IdentityGrid/ImportPlayerIdentity"), NodePath("Paper/Content/IdentityGrid/ExportHostIdentity"), NodePath("Paper/Content/IdentityGrid/ImportHostIdentity"), NodePath("BubbleCluster/DataBackButton")])
initial_focus_path = NodePath("BubbleCluster/DataBackButton") initial_focus_path = NodePath("Paper/Content/OpenDataFolder")
back_focus_path = NodePath("BubbleCluster/DataBackButton") back_focus_path = NodePath("BubbleCluster/DataBackButton")
[node name="BubbleCluster" type="Control" parent="DataPage"] [node name="BubbleCluster" type="Control" parent="DataPage"]

View file

@ -11,6 +11,11 @@ enum View {
PAYMENTS, PAYMENTS,
} }
enum ControllerZone {
TABS,
CLAIMS,
}
var _jobs: PlayerJobService var _jobs: PlayerJobService
var _world_time: WorldTimeService var _world_time: WorldTimeService
var _header: Label var _header: Label
@ -18,11 +23,15 @@ var _refresh_label: Label
var _forecast_list: HBoxContainer var _forecast_list: HBoxContainer
var _tabs: HBoxContainer var _tabs: HBoxContainer
var _list: VBoxContainer var _list: VBoxContainer
var _jobs_scroll: ScrollContainer
var _status: Label var _status: Label
var _current_view: View = View.DAILY var _current_view: View = View.DAILY
var _forecast_start_index: int = -1 var _forecast_start_index: int = -1
var _active: bool = false var _active: bool = false
var _interactive: bool = false var _interactive: bool = false
var _controller_zone: ControllerZone = ControllerZone.TABS
var _claim_buttons: Array[Button] = []
var _controller_mapping_manager: ControllerMappingManager
func _ready() -> void: func _ready() -> void:
@ -40,6 +49,10 @@ func setup(jobs: PlayerJobService, world_time: WorldTimeService) -> void:
_refresh() _refresh()
func setup_controller_mapping(mapping_manager: ControllerMappingManager) -> void:
_controller_mapping_manager = mapping_manager
func activate() -> void: func activate() -> void:
_active = true _active = true
set_process(true) set_process(true)
@ -68,6 +81,7 @@ func set_interactive(interactive: bool) -> void:
button.focus_mode = ( button.focus_mode = (
Control.FOCUS_ALL Control.FOCUS_ALL
if interactive if interactive
and _controller_zone == ControllerZone.TABS
else Control.FOCUS_NONE else Control.FOCUS_NONE
) )
button.mouse_filter = ( button.mouse_filter = (
@ -75,20 +89,108 @@ func set_interactive(interactive: bool) -> void:
if interactive if interactive
else Control.MOUSE_FILTER_IGNORE else Control.MOUSE_FILTER_IGNORE
) )
_refresh() for claim_button: Button in _claim_buttons:
if not is_instance_valid(claim_button):
continue
claim_button.disabled = not interactive
claim_button.focus_mode = (
Control.FOCUS_ALL
if interactive
and _controller_zone == ControllerZone.CLAIMS
and not claim_button.disabled
else Control.FOCUS_NONE
)
claim_button.mouse_filter = (
Control.MOUSE_FILTER_STOP
if interactive else Control.MOUSE_FILTER_IGNORE
)
func focus_initial() -> void: func focus_initial() -> void:
if _interactive and _tabs != null and _tabs.get_child_count() > 0: if not _interactive:
return
if _controller_zone == ControllerZone.CLAIMS:
if not _claim_buttons.is_empty():
_claim_buttons.front().grab_focus()
return
if _tabs != null and _tabs.get_child_count() > 0:
var button := _tabs.get_child(int(_current_view)) as Button var button := _tabs.get_child(int(_current_view)) as Button
if button != null: if button != null:
button.grab_focus() button.grab_focus()
func _process(_delta: float) -> void: func reset_controller_zone() -> void:
_controller_zone = ControllerZone.TABS
set_interactive(_interactive)
call_deferred("focus_initial")
func handle_controller_input(event: InputEvent) -> bool:
if not _active or not _interactive:
return false
if _handle_controller_scroll(event):
return true
if event.is_action_pressed("ui_cancel"):
if _controller_zone == ControllerZone.CLAIMS:
_controller_zone = ControllerZone.TABS
set_interactive(_interactive)
call_deferred("focus_initial")
return true
return false
if _controller_zone == ControllerZone.TABS:
var direction: int = 0
if event.is_action_pressed("ui_left"):
direction = -1
elif event.is_action_pressed("ui_right"):
direction = 1
if direction != 0:
var target_index: int = clampi(
int(_current_view) + direction,
0,
View.size() - 1,
)
if target_index != int(_current_view):
_select_view(target_index as View)
return true
if event.is_action_pressed("ui_accept"):
if not _claim_buttons.is_empty():
_controller_zone = ControllerZone.CLAIMS
set_interactive(_interactive)
call_deferred("focus_initial")
return true
return false
func _handle_controller_scroll(event: InputEvent) -> bool:
var motion := event as InputEventJoypadMotion
if motion == null or _jobs_scroll == null:
return false
var uses_right_y: bool = (
_controller_mapping_manager.event_uses_role(
event,
ControllerMappingManager.ROLE_RIGHT_STICK_Y,
)
if _controller_mapping_manager != null
else motion.axis == JOY_AXIS_RIGHT_Y
)
if not uses_right_y:
return false
return true
func _process(delta: float) -> void:
if _active and _jobs != null: if _active and _jobs != null:
_refresh_label.text = _daily_refresh_text() _refresh_label.text = _daily_refresh_text()
_refresh_forecast(false) _refresh_forecast(false)
var right_y: float = (
_controller_mapping_manager.get_role_axis(
ControllerMappingManager.ROLE_RIGHT_STICK_Y
)
if _controller_mapping_manager != null
else Input.get_joy_axis(0, JOY_AXIS_RIGHT_Y)
)
if _jobs_scroll != null and absf(right_y) >= 0.18:
_jobs_scroll.scroll_vertical += roundi(right_y * 320.0 * delta)
func _build_laptop() -> void: func _build_laptop() -> void:
@ -177,15 +279,15 @@ func _build_laptop() -> void:
jobs_column.add_theme_constant_override("separation", 8) jobs_column.add_theme_constant_override("separation", 8)
layout.add_child(jobs_column) layout.add_child(jobs_column)
var scroll := ScrollContainer.new() _jobs_scroll = ScrollContainer.new()
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL _jobs_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL _jobs_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED _jobs_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
jobs_column.add_child(scroll) jobs_column.add_child(_jobs_scroll)
_list = VBoxContainer.new() _list = VBoxContainer.new()
_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL _list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_list.add_theme_constant_override("separation", 7) _list.add_theme_constant_override("separation", 7)
scroll.add_child(_list) _jobs_scroll.add_child(_list)
_status = Label.new() _status = Label.new()
_status.add_theme_font_size_override("font_size", 16) _status.add_theme_font_size_override("font_size", 16)
@ -212,6 +314,7 @@ func _refresh() -> void:
for child: Node in _list.get_children(): for child: Node in _list.get_children():
_list.remove_child(child) _list.remove_child(child)
child.queue_free() child.queue_free()
_claim_buttons.clear()
match _current_view: match _current_view:
View.DAILY: View.DAILY:
_build_job_rows(_jobs.get_daily_jobs(), "no daily jobs available") _build_job_rows(_jobs.get_daily_jobs(), "no daily jobs available")
@ -221,6 +324,10 @@ func _refresh() -> void:
) )
View.PAYMENTS: View.PAYMENTS:
_build_payment_rows() _build_payment_rows()
if _controller_zone == ControllerZone.CLAIMS and _claim_buttons.is_empty():
_controller_zone = ControllerZone.TABS
set_interactive(_interactive)
ControllerFocusNavigation.configure_spatial_neighbors(_claim_buttons)
func _build_job_rows(jobs: Array[Dictionary], empty_text: String) -> void: func _build_job_rows(jobs: Array[Dictionary], empty_text: String) -> void:
@ -311,6 +418,8 @@ func _build_job_rows(jobs: Array[Dictionary], empty_text: String) -> void:
) )
claim.pressed.connect(_claim.bind(claim_id)) claim.pressed.connect(_claim.bind(claim_id))
UtilityPageStyle.apply_compact_ocean_button(claim) UtilityPageStyle.apply_compact_ocean_button(claim)
if claimable:
_claim_buttons.append(claim)
content.add_child(claim) content.add_child(claim)
_list.add_child(row) _list.add_child(row)
@ -357,6 +466,7 @@ func _build_payment_rows() -> void:
_claim.bind(str(reward.get("claim_id", ""))) _claim.bind(str(reward.get("claim_id", "")))
) )
UtilityPageStyle.apply_ocean_button(claim) UtilityPageStyle.apply_ocean_button(claim)
_claim_buttons.append(claim)
content.add_child(claim) content.add_child(claim)
_list.add_child(row) _list.add_child(row)

View file

@ -197,14 +197,18 @@ func _configure_focus() -> void:
_cancel_button _cancel_button
) )
_confirm_button.focus_neighbor_right = _confirm_button.focus_neighbor_left _confirm_button.focus_neighbor_right = _confirm_button.focus_neighbor_left
_confirm_button.focus_neighbor_top = _confirm_button.focus_neighbor_left _confirm_button.focus_neighbor_top = _confirm_button.get_path_to(
_confirm_button.focus_neighbor_bottom = _confirm_button.focus_neighbor_left _confirm_button
)
_confirm_button.focus_neighbor_bottom = _confirm_button.focus_neighbor_top
_cancel_button.focus_neighbor_left = _cancel_button.get_path_to( _cancel_button.focus_neighbor_left = _cancel_button.get_path_to(
_confirm_button _confirm_button
) )
_cancel_button.focus_neighbor_right = _cancel_button.focus_neighbor_left _cancel_button.focus_neighbor_right = _cancel_button.focus_neighbor_left
_cancel_button.focus_neighbor_top = _cancel_button.focus_neighbor_left _cancel_button.focus_neighbor_top = _cancel_button.get_path_to(
_cancel_button.focus_neighbor_bottom = _cancel_button.focus_neighbor_left _cancel_button
)
_cancel_button.focus_neighbor_bottom = _cancel_button.focus_neighbor_top
func _set_interactive(interactive: bool) -> void: func _set_interactive(interactive: bool) -> void:
@ -224,6 +228,8 @@ func _set_interactive(interactive: bool) -> void:
if interactive if interactive
else Control.MOUSE_FILTER_IGNORE else Control.MOUSE_FILTER_IGNORE
) )
if interactive:
_configure_focus()
func _finish_transition_in( func _finish_transition_in(

View file

@ -556,7 +556,7 @@ func _input(event: InputEvent) -> void:
return return
if ( if (
_settings_panel.visible _settings_panel.visible
and _settings_panel.is_controller_mapping_capturing() and _settings_panel.is_input_mapping_capturing()
): ):
return return
if _credits_page.visible: if _credits_page.visible:
@ -565,7 +565,7 @@ func _input(event: InputEvent) -> void:
return return
if _join_game_page.visible: if _join_game_page.visible:
if event.is_action_pressed("ui_cancel"): if event.is_action_pressed("ui_cancel"):
_close_join_game() _join_game_page.request_back()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
return return
if ( if (
@ -624,23 +624,18 @@ func _handle_primary_menu_focus_input(event: InputEvent) -> bool:
return false return false
if event is InputEventKey and (event as InputEventKey).echo: if event is InputEventKey and (event as InputEventKey).echo:
return false return false
var moves_forward: bool = ( var moves_directionally: bool = (
event.is_action_pressed("ui_down") event.is_action_pressed("ui_down")
or event.is_action_pressed("ui_right") or event.is_action_pressed("ui_right")
) or event.is_action_pressed("ui_up")
var moves_backward: bool = (
event.is_action_pressed("ui_up")
or event.is_action_pressed("ui_left") or event.is_action_pressed("ui_left")
) )
if not moves_forward and not moves_backward: if not moves_directionally:
return false return false
_navigation_focus_active = true _navigation_focus_active = true
if _primary_menu_has_focus(): if _primary_menu_has_focus():
return false return false
if moves_forward: _get_first_available_menu_button().grab_focus()
_get_first_available_menu_button().grab_focus()
else:
_quit_button.grab_focus()
return true return true

View file

@ -44,7 +44,8 @@ func _ready() -> void:
_on_screen_keyboard = OnScreenKeyboardType.new() _on_screen_keyboard = OnScreenKeyboardType.new()
_ui_root.add_child(_on_screen_keyboard) _ui_root.add_child(_on_screen_keyboard)
_game_ui.set_controller_text_entry_request( _game_ui.set_controller_text_entry_request(
Callable(_on_screen_keyboard, "request_for_focused_control") Callable(_on_screen_keyboard, "request_for_control"),
Callable(_on_screen_keyboard, "is_open"),
) )
var controller_focus_recovery := ControllerFocusRecoveryType.new() var controller_focus_recovery := ControllerFocusRecoveryType.new()
_ui_root.add_child(controller_focus_recovery) _ui_root.add_child(controller_focus_recovery)