feat: complete controller-first menu navigation

This commit is contained in:
Alexander Sellite 2026-08-08 14:23:28 -04:00
parent 26c9308fcf
commit 4ac7d5edb8
35 changed files with 2013 additions and 85 deletions

View file

@ -568,6 +568,21 @@ func _refresh_input_ownership() -> void:
)
_panel.mouse_filter = filter
_history.mouse_filter = filter
_entry.focus_mode = (
Control.FOCUS_ALL if _opened else Control.FOCUS_NONE
)
_collapse_button.focus_mode = (
Control.FOCUS_ALL if _opened else Control.FOCUS_NONE
)
_height_button.focus_mode = (
Control.FOCUS_ALL
if _opened and not _mobile_mode
else Control.FOCUS_NONE
)
if not _opened:
var focus_owner: Control = get_viewport().gui_get_focus_owner()
if focus_owner in [_entry, _collapse_button, _height_button]:
get_viewport().gui_release_focus()
func _clock_panel_style() -> StyleBoxFlat:
@ -886,9 +901,14 @@ func _refresh_visibility() -> void:
_collapse_button.show()
var collapsed := _presentation_state == PresentationState.COLLAPSED
_panel.show()
_collapse_button.focus_mode = (
Control.FOCUS_ALL if _opened else Control.FOCUS_NONE
)
_height_button.visible = not _mobile_mode
_height_button.focus_mode = (
Control.FOCUS_NONE if _mobile_mode else Control.FOCUS_ALL
Control.FOCUS_ALL
if _opened and not _mobile_mode
else Control.FOCUS_NONE
)
_unread_indicator.visible = collapsed and _collapsed_has_unread
_hint.visible = not _opened

View file

@ -56,6 +56,8 @@ var _base_position: Vector2 = Vector2.ZERO
var _presented_size: Vector2 = Vector2.ZERO
var _compact: bool = false
var _presentation_initialized: bool = false
var _controller_preview_active: bool = false
var _controller_preview_texture: Texture2D
func _ready() -> void:
@ -146,6 +148,15 @@ func set_drag_enabled(enabled: bool) -> void:
_hovered = false
func set_controller_placement_preview(
active: bool,
texture: Texture2D,
) -> void:
_controller_preview_active = active
_controller_preview_texture = texture
refresh()
func refresh() -> void:
if _hotbar == null:
return
@ -161,11 +172,16 @@ func refresh() -> void:
if _fish_inventory != null and not catch_id.is_empty()
else null
)
_item_icon.texture = (
var assigned_texture: Texture2D = (
fish_catch.fish.display_texture
if fish_catch != null
else item.icon if item != null else null
)
_item_icon.texture = (
_controller_preview_texture
if _controller_preview_active
else assigned_texture
)
var quantity: int = (
_bag.get_quantity(item_id)
if _bag != null and not item_id.is_empty()
@ -177,7 +193,9 @@ func refresh() -> void:
else ""
)
_quantity_label.text = quantity_text
_quantity_label.visible = not quantity_text.is_empty()
_quantity_label.visible = (
not _controller_preview_active and not quantity_text.is_empty()
)
tooltip_text = (
"%s · %.1f lb" % [
FishQualityType.qualified_name(
@ -192,7 +210,11 @@ func refresh() -> void:
var was_selected: bool = _selected
var was_empty: bool = _empty
_selected = slot_index == _hotbar.get_selected_slot()
_empty = item == null and fish_catch == null
_empty = (
not _controller_preview_active
and item == null
and fish_catch == null
)
if was_selected != _selected or was_empty != _empty:
_apply_style()
@ -220,7 +242,9 @@ func _apply_style() -> void:
var normal_fill: Color = profile.normal_fill
normal_fill.a = 1.0
var selected_fill: Color = normal_fill
if _selected:
if _controller_preview_active:
selected_fill = normal_fill.lightened(0.22)
elif _selected:
selected_fill = normal_fill.lightened(0.12)
add_theme_stylebox_override(
"normal",

View file

@ -42,6 +42,53 @@ func _ready() -> void:
_apply_icon_presentation(neutral_size)
func _gui_input(event: InputEvent) -> void:
if _adjustment_direction(self) == 0:
return
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:
return
var adjustment_button: BaseButton = _find_adjustment_button(direction)
if adjustment_button == null:
return
adjustment_button.pressed.emit()
accept_event()
func _find_adjustment_button(direction: int) -> BaseButton:
var parent_node: Node = get_parent()
if parent_node == null:
return null
for child: Node in parent_node.get_children():
var button := child as BaseButton
if (
button != null
and button.visible
and not button.disabled
and _adjustment_direction(button) == direction
):
return button
return null
func _adjustment_direction(button: BaseButton) -> int:
var descriptions: Array[String] = [
button.text.strip_edges().to_lower(),
button.tooltip_text.strip_edges().to_lower(),
button.accessibility_name.strip_edges().to_lower(),
]
for description: String in descriptions:
if description in ["-", "", "minus", "decrease"]:
return -1
if description in ["+", "plus", "increase"]:
return 1
return 0
func apply_layout(
center: Vector2,
bubble_size: Vector2,

View file

@ -1,6 +1,10 @@
class_name BubbleCluster
extends Control
const ControllerFocusNavigationType = preload(
"res://ui/controller_focus_navigation.gd"
)
@export var profile: BubbleMenuProfile
@export var desktop_reference_size: Vector2 = Vector2(396.0, 318.0)
@export var compact_reference_size: Vector2 = Vector2(294.0, 200.0)
@ -18,14 +22,6 @@ func configure(bubbles: Array[BubbleButton]) -> void:
if bubble.profile == null:
bubble.profile = profile
bubble.apply_profile()
if index > 0:
bubble.focus_neighbor_top = bubble.get_path_to(
_bubbles[index - 1]
)
if index + 1 < _bubbles.size():
bubble.focus_neighbor_bottom = bubble.get_path_to(
_bubbles[index + 1]
)
func apply_layout(field_size: Vector2, compact: bool) -> void:
@ -52,6 +48,7 @@ func apply_layout(field_size: Vector2, compact: bool) -> void:
bubble_size,
profile.font_size_ratio
)
ControllerFocusNavigationType.configure_spatial_neighbors(_bubbles)
func advance_motion(delta: float) -> void:

View file

@ -19,6 +19,7 @@ var _quality_color := Color.WHITE
func _ready() -> void:
set_meta(&"controller_focus_inversion_disabled", true)
resized.connect(_update_visual_pivot)
focus_entered.connect(_refresh_style)
focus_exited.connect(_refresh_style)

View file

@ -0,0 +1,57 @@
class_name ControllerFocusNavigation
extends RefCounted
const PERPENDICULAR_WEIGHT: float = 2.5
const MINIMUM_FORWARD_DISTANCE: float = 0.5
static func configure_spatial_neighbors(controls: Array) -> void:
var candidates: Array[Control] = []
for item: Variant in controls:
var control := item as Control
if control == null or not control.is_visible_in_tree():
continue
candidates.append(control)
for control: Control in candidates:
control.focus_neighbor_left = _neighbor_path(
control, candidates, Vector2.LEFT
)
control.focus_neighbor_right = _neighbor_path(
control, candidates, Vector2.RIGHT
)
control.focus_neighbor_top = _neighbor_path(
control, candidates, Vector2.UP
)
control.focus_neighbor_bottom = _neighbor_path(
control, candidates, Vector2.DOWN
)
static func _neighbor_path(
origin: Control,
candidates: Array[Control],
direction: Vector2,
) -> NodePath:
var origin_center: Vector2 = origin.get_global_rect().get_center()
var best: Control = null
var best_score: float = INF
for candidate: Control in candidates:
if candidate == origin or candidate.focus_mode == Control.FOCUS_NONE:
continue
var delta: Vector2 = (
candidate.get_global_rect().get_center() - origin_center
)
var forward_distance: float = delta.dot(direction)
if forward_distance <= MINIMUM_FORWARD_DISTANCE:
continue
var perpendicular_distance: float = absf(delta.cross(direction))
var score: float = (
forward_distance
+ perpendicular_distance * PERPENDICULAR_WEIGHT
)
if score < best_score:
best = candidate
best_score = score
if best == null:
return NodePath()
return origin.get_path_to(best)

View file

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

View file

@ -0,0 +1,97 @@
class_name ControllerFocusPresentation
extends Node
const CONTROLLER_MOTION_THRESHOLD: float = 0.35
const INVERSION_DISABLED_META: StringName = &"controller_focus_inversion_disabled"
var _controller_active: bool = false
var _focused_item: CanvasItem
var _original_material: Material
var _inversion_material: ShaderMaterial
func _ready() -> void:
var inversion_shader := Shader.new()
inversion_shader.code = """
shader_type canvas_item;
render_mode unshaded;
void fragment() {
vec4 source = texture(TEXTURE, UV) * COLOR;
COLOR = vec4(vec3(1.0) - source.rgb, source.a);
}
"""
_inversion_material = ShaderMaterial.new()
_inversion_material.shader = inversion_shader
get_viewport().gui_focus_changed.connect(_on_focus_changed)
set_process_input(true)
set_process(true)
func _exit_tree() -> void:
_restore_focused_item()
func _input(event: InputEvent) -> void:
if event is InputEventJoypadButton:
if (event as InputEventJoypadButton).pressed:
_set_controller_active(true)
elif event is InputEventJoypadMotion:
if absf((event as InputEventJoypadMotion).axis_value) >= (
CONTROLLER_MOTION_THRESHOLD
):
_set_controller_active(true)
elif event is InputEventMouseButton:
if (event as InputEventMouseButton).pressed:
_set_controller_active(false)
elif event is InputEventKey:
if (event as InputEventKey).pressed:
_set_controller_active(false)
func _process(_delta: float) -> void:
if not is_instance_valid(_focused_item):
_focused_item = null
_original_material = null
return
var focus_owner: Control = get_viewport().gui_get_focus_owner()
if (
focus_owner != _focused_item
or not focus_owner.is_visible_in_tree()
or focus_owner.focus_mode == Control.FOCUS_NONE
or bool(focus_owner.get_meta(INVERSION_DISABLED_META, false))
):
_apply_to_focus(focus_owner)
func _set_controller_active(active: bool) -> void:
if _controller_active == active:
return
_controller_active = active
_apply_to_focus(get_viewport().gui_get_focus_owner())
func _on_focus_changed(control: Control) -> void:
_apply_to_focus(control)
func _apply_to_focus(control: Control) -> void:
_restore_focused_item()
if (
not _controller_active
or control == null
or not control.is_visible_in_tree()
or control.focus_mode == Control.FOCUS_NONE
or bool(control.get_meta(INVERSION_DISABLED_META, false))
):
return
_focused_item = control
_original_material = _focused_item.material
_focused_item.material = _inversion_material
func _restore_focused_item() -> void:
if is_instance_valid(_focused_item):
_focused_item.material = _original_material
_focused_item = null
_original_material = null

View file

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

View file

@ -0,0 +1,142 @@
class_name ControllerFocusRecovery
extends Node
const CONTROLLER_AXIS_THRESHOLD: float = 0.35
const SEMANTIC_MATCH_BONUS: float = 1000000.0
var _controller_active: bool = false
var _last_focus_center: Vector2 = Vector2.ZERO
var _last_focus_key: String = ""
var _scope_chain: Array[WeakRef] = []
var _recovery_generation: int = 0
func _ready() -> void:
set_process_input(true)
set_process(true)
get_viewport().gui_focus_changed.connect(_on_gui_focus_changed)
func _exit_tree() -> void:
var viewport := get_viewport()
if viewport.gui_focus_changed.is_connected(_on_gui_focus_changed):
viewport.gui_focus_changed.disconnect(_on_gui_focus_changed)
func _input(event: InputEvent) -> void:
if event is InputEventJoypadButton:
var button_event := event as InputEventJoypadButton
if button_event.pressed:
_controller_active = true
if button_event.button_index == JOY_BUTTON_LEFT_SHOULDER:
_recovery_generation += 1
_scope_chain.clear()
return
if event is InputEventJoypadMotion:
if absf((event as InputEventJoypadMotion).axis_value) >= (
CONTROLLER_AXIS_THRESHOLD
):
_controller_active = true
return
if event is InputEventMouseButton:
if (event as InputEventMouseButton).pressed:
_controller_active = false
return
if event is InputEventKey and (event as InputEventKey).pressed:
_controller_active = false
func _process(_delta: float) -> void:
if (
_controller_active
and not _scope_chain.is_empty()
and get_viewport().gui_get_focus_owner() == null
):
_recover_focus(_recovery_generation)
func _on_gui_focus_changed(control: Control) -> void:
_recovery_generation += 1
if control != null:
if _controller_active and control is BaseButton:
_remember_focus(control)
return
if not _controller_active or _scope_chain.is_empty():
return
var generation: int = _recovery_generation
_recover_focus.call_deferred(generation)
func _remember_focus(control: Control) -> void:
_last_focus_center = control.get_global_rect().get_center()
_last_focus_key = _semantic_key(control)
_scope_chain.clear()
var recovery_root: Node = get_parent()
var ancestor: Node = control.get_parent()
while ancestor != null:
if ancestor is Control:
_scope_chain.append(weakref(ancestor))
if ancestor == recovery_root:
break
ancestor = ancestor.get_parent()
func _recover_focus(generation: int) -> void:
if generation != _recovery_generation:
return
if not _controller_active or get_viewport().gui_get_focus_owner() != null:
return
for scope_reference: WeakRef in _scope_chain:
var scope := scope_reference.get_ref() as Control
if (
scope == null
or not is_instance_valid(scope)
or not scope.is_inside_tree()
or not scope.is_visible_in_tree()
):
continue
var replacement := _best_replacement_in(scope)
if replacement != null:
replacement.grab_focus()
return
func _best_replacement_in(scope: Control) -> Control:
var best: Control = null
var best_score: float = INF
for node: Node in scope.find_children("*", "Control", true, false):
var candidate := node as Control
if not _is_focusable(candidate):
continue
var distance: float = candidate.get_global_rect().get_center().distance_squared_to(
_last_focus_center
)
if not _last_focus_key.is_empty() and _semantic_key(candidate) == _last_focus_key:
distance -= SEMANTIC_MATCH_BONUS
if distance < best_score:
best = candidate
best_score = distance
return best
func _is_focusable(control: Control) -> bool:
if (
control == null
or not control.is_inside_tree()
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 _semantic_key(control: Control) -> String:
if control.has_meta(&"controller_focus_key"):
return str(control.get_meta(&"controller_focus_key"))
var button := control as Button
var label: String = button.text if button != null else ""
var tooltip: String = control.tooltip_text
tooltip = tooltip.trim_suffix(" (show variants)")
tooltip = tooltip.trim_suffix(" (hide variants)")
return "%s|%s|%s" % [control.get_class(), label, tooltip]

View file

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

View file

@ -133,6 +133,8 @@ const SHOP_SPEECH_CHARACTERS_PER_SECOND: float = 28.0
@onready var _shop_prompt_key: Label = %ShopPromptKey
@onready var _shop_prompt_pointer: Polygon2D = %ShopPromptPointer
var _shop_animalese_voice: AnimaleseVoiceType
var _shop_npc_player_in_range: bool = false
var _shop_npc_spoken_for_current_visit: bool = false
@onready var _effect_status: Label = %EffectStatus
@onready var _chat_ui: ChatUIType = %ChatUI
@onready var _emote_radial_menu: EmoteRadialMenuType = %EmoteRadialMenu
@ -207,6 +209,12 @@ func _ready() -> void:
_hotbar_ui.presentation_transition_finished.connect(
_on_hotbar_presentation_transition_finished
)
_player_menu.controller_hotbar_placement_requested.connect(
_hotbar_ui.begin_controller_placement
)
_player_menu.controller_hotbar_placement_ended.connect(
_hotbar_ui.end_controller_placement
)
_title_settings_panel.panel_visibility_changed.connect(
_on_settings_visibility_changed
)
@ -503,7 +511,7 @@ func _handle_controller_chat_controls(event: InputEvent) -> bool:
_chat_ui.toggle_chat()
return true
if focus_pressed:
_chat_ui.toggle_focus()
_chat_ui.refocus_gameplay()
return true
if accept_pressed:
return _chat_ui.request_virtual_keyboard()
@ -1302,6 +1310,8 @@ func _on_hud_bait_inventory_changed() -> void:
func set_gameplay_ui_enabled(enabled: bool) -> void:
_gameplay_ui_enabled = enabled
if enabled:
_try_start_shop_npc_speech()
_gameplay_transient_hud.visible = enabled and not _player_menu_open
_experience_presentation.visible = enabled
_refresh_chat_availability()
@ -1362,7 +1372,6 @@ func set_shop_prompt_visible(
world_anchor: Vector3 = Vector3(0.0, INF, 0.0),
) -> void:
_apply_shop_prompt_style()
var was_visible := _shop_prompt.visible
_shop_prompt.visible = (
is_visible
and _gameplay_ui_enabled
@ -1371,23 +1380,42 @@ func set_shop_prompt_visible(
and not _shop_open
)
if _shop_prompt.visible:
if not was_visible:
_ensure_shop_animalese_voice()
TypewriterRevealType.start(
_shop_prompt_message,
SHOP_SPEECH_CHARACTERS_PER_SECOND,
)
_shop_animalese_voice.speak_text(
_shop_prompt_message,
_shop_prompt_message.text,
"shopkeeper",
SHOP_ANIMALESE_VOICE_ID,
SHOP_SPEECH_CHARACTERS_PER_SECOND,
)
if world_anchor.is_finite():
_position_shop_prompt(world_anchor)
func set_shop_npc_player_in_range(in_range: bool) -> void:
if _shop_npc_player_in_range == in_range:
return
_shop_npc_player_in_range = in_range
if not in_range:
_shop_npc_spoken_for_current_visit = false
return
_try_start_shop_npc_speech()
func _try_start_shop_npc_speech() -> void:
if (
not _shop_npc_player_in_range
or _shop_npc_spoken_for_current_visit
or not _gameplay_ui_enabled
):
return
_shop_npc_spoken_for_current_visit = true
_ensure_shop_animalese_voice()
TypewriterRevealType.start(
_shop_prompt_message,
SHOP_SPEECH_CHARACTERS_PER_SECOND,
)
_shop_animalese_voice.speak_text(
_shop_prompt_message,
_shop_prompt_message.text,
"shopkeeper",
SHOP_ANIMALESE_VOICE_ID,
SHOP_SPEECH_CHARACTERS_PER_SECOND,
)
func _ensure_shop_animalese_voice() -> void:
if _shop_animalese_voice != null:
return

View file

@ -41,6 +41,12 @@ var _item_name_suppressed: bool = false
var _motion_elapsed: float = 0.0
var _compact_layout: bool = false
var _player_menu_context: bool = false
var _controller_placement_active: bool = false
var _controller_placement_kind: PlayerHotbarType.AssignmentKind = (
PlayerHotbarType.AssignmentKind.EMPTY
)
var _controller_placement_identity: StringName
var _controller_placement_texture: Texture2D
var _visibility_tween: Tween
var _visibility_generation: int = 0
@ -99,6 +105,79 @@ func set_drag_enabled(enabled: bool) -> void:
_hide_item_name()
func begin_controller_placement(
assignment_kind: PlayerHotbarType.AssignmentKind,
identity: StringName,
initial_slot: int,
) -> void:
if _hotbar == null or identity.is_empty():
return
_controller_placement_active = true
_controller_placement_kind = assignment_kind
_controller_placement_identity = identity
_controller_placement_texture = _resolve_controller_placement_texture()
var slot_count: int = _slots.size()
for index: int in slot_count:
var slot: BubbleHotbarSlotType = _slots[index]
slot.focus_mode = Control.FOCUS_ALL
slot.focus_neighbor_left = slot.get_path_to(
_slots[wrapi(index - 1, 0, slot_count)]
)
slot.focus_neighbor_right = slot.get_path_to(
_slots[wrapi(index + 1, 0, slot_count)]
)
slot.focus_neighbor_top = slot.get_path_to(slot)
slot.focus_neighbor_bottom = slot.get_path_to(slot)
var target_index: int = clampi(initial_slot, 0, slot_count - 1)
_hotbar.select_slot(target_index)
_refresh_controller_placement_preview()
_slots[target_index].call_deferred("grab_focus")
func end_controller_placement() -> void:
if not _controller_placement_active:
return
_controller_placement_active = false
_controller_placement_kind = PlayerHotbarType.AssignmentKind.EMPTY
_controller_placement_identity = StringName()
_controller_placement_texture = null
for slot: BubbleHotbarSlotType in _slots:
slot.focus_mode = Control.FOCUS_NONE
slot.set_controller_placement_preview(false, null)
_show_selected_item_briefly()
func _resolve_controller_placement_texture() -> Texture2D:
if (
_controller_placement_kind == PlayerHotbarType.AssignmentKind.FISH
and _fish_inventory != null
):
var fish_catch: FishCatchType = _fish_inventory.get_catch_by_id(
_controller_placement_identity
)
return fish_catch.fish.display_texture if fish_catch != null else null
if (
_controller_placement_kind == PlayerHotbarType.AssignmentKind.ITEM
and _catalog != null
):
var item: ItemDataType = _catalog.get_item_by_id(
_controller_placement_identity
)
return item.icon if item != null else null
return null
func _refresh_controller_placement_preview() -> void:
if not _controller_placement_active or _hotbar == null:
return
var target_index: int = _hotbar.get_selected_slot()
for slot: BubbleHotbarSlotType in _slots:
slot.set_controller_placement_preview(
slot.slot_index == target_index,
_controller_placement_texture,
)
func set_player_menu_context(enabled: bool) -> void:
if _player_menu_context == enabled:
return
@ -230,6 +309,9 @@ func _collect_slots() -> void:
slot.item_hover_ended.connect(_on_slot_item_hover_ended)
slot.item_drag_started.connect(_on_slot_drag_started)
slot.item_drag_finished.connect(_on_slot_drag_finished)
slot.focus_entered.connect(
_on_controller_slot_focused.bind(slot.slot_index)
)
_slots.append(slot)
_slots.sort_custom(
func(
@ -278,10 +360,20 @@ func _on_selected_slot_changed(
_item_id: StringName,
) -> void:
_refresh()
if _controller_placement_active:
_refresh_controller_placement_preview()
return
if _hovered_slot_index < 0:
_show_selected_item_briefly()
func _on_controller_slot_focused(slot_index: int) -> void:
if not _controller_placement_active or _hotbar == null:
return
_hotbar.select_slot(slot_index)
_refresh_controller_placement_preview()
func _on_slot_item_hovered(
slot_index: int,
item_id: StringName,

View file

@ -453,6 +453,7 @@ func _refresh_catalog() -> void:
func _make_entry(fish: FishDataType, discovered: bool) -> Button:
var entry := Button.new()
entry.set_meta(&"controller_focus_inversion_disabled", true)
entry.custom_minimum_size = CATALOG_ENTRY_SIZE
entry.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
entry.size_flags_vertical = Control.SIZE_SHRINK_CENTER

456
ui/on_screen_keyboard.gd Normal file
View file

@ -0,0 +1,456 @@
class_name OnScreenKeyboard
extends Control
const ControllerFocusNavigationType = preload(
"res://ui/controller_focus_navigation.gd"
)
const CHECK_ICON: Texture2D = preload(
"res://ui/icons/pictograms/check_mark_dark.png"
)
const CHARACTER_KEY_SIZE: Vector2 = Vector2(96.0, 72.0)
const CHARACTER_FONT_SIZE: int = 34
const TRIGGER_PRESS_THRESHOLD: float = 0.55
const TRIGGER_RELEASE_THRESHOLD: float = 0.25
signal text_submitted(value: String)
enum Page {
LOWER,
UPPER,
SYMBOLS,
}
var _enabled: bool = false
var _page: Page = Page.LOWER
var _target: Control
var _target_virtual_keyboard_enabled: bool = true
var _buffer: String = ""
var _preview: Label
var _page_buttons: Array[Button] = []
var _keys_host: VBoxContainer
var _key_buttons: Array[Button] = []
var _caret_left_button: Button
var _caret_right_button: Button
var _backspace_button: Button
var _space_button: Button
var _check_button: Button
var _left_trigger_pressed: bool = false
var _right_trigger_pressed: bool = false
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
z_index = 1000
mouse_filter = Control.MOUSE_FILTER_STOP
_build_interface()
hide()
set_process_input(true)
func set_enabled(enabled: bool) -> void:
_enabled = enabled
if not _enabled and visible:
_close_keyboard(false)
func is_enabled() -> bool:
return _enabled
func is_open() -> bool:
return visible
func _input(event: InputEvent) -> void:
if not _enabled:
return
if visible:
var joy_motion := event as InputEventJoypadMotion
if joy_motion != null and _handle_trigger_shortcut(joy_motion):
get_viewport().set_input_as_handled()
return
var joy_button := event as InputEventJoypadButton
if joy_button != null and joy_button.pressed:
if joy_button.button_index == JOY_BUTTON_X:
_backspace()
get_viewport().set_input_as_handled()
return
if joy_button.button_index == JOY_BUTTON_LEFT_SHOULDER:
_close_keyboard(false)
get_viewport().set_input_as_handled()
return
if joy_button.button_index == JOY_BUTTON_RIGHT_SHOULDER:
_set_page(wrapi(int(_page) + 1, 0, Page.size()))
get_viewport().set_input_as_handled()
return
if event.is_action_pressed(&"ui_cancel"):
_close_keyboard(true)
get_viewport().set_input_as_handled()
return
var joy_event := event as InputEventJoypadButton
if (
joy_event == null
or not joy_event.pressed
or (
joy_event.button_index != JOY_BUTTON_A
and not event.is_action_pressed(&"ui_accept")
)
):
return
var focus_owner: Control = get_viewport().gui_get_focus_owner()
if _can_edit(focus_owner):
_open_for(focus_owner)
get_viewport().set_input_as_handled()
func _can_edit(control: Control) -> bool:
if control is LineEdit:
return (control as LineEdit).editable
if control is TextEdit:
return (control as TextEdit).editable
return false
func _open_for(control: Control) -> void:
_target = control
_target_virtual_keyboard_enabled = bool(
_target.get("virtual_keyboard_enabled")
)
_target.set("virtual_keyboard_enabled", false)
_buffer = str(_target.get("text"))
_page = Page.LOWER
show()
_refresh_preview()
_rebuild_keys()
func _close_keyboard(restore_focus: bool) -> void:
var prior_target: Control = _target
if is_instance_valid(prior_target):
prior_target.set(
"virtual_keyboard_enabled",
_target_virtual_keyboard_enabled
)
hide()
get_viewport().gui_release_focus()
_target = null
if restore_focus and is_instance_valid(prior_target):
prior_target.grab_focus()
func _submit() -> void:
var submitted_target: Control = _target
var submitted_text: String = _buffer
_close_keyboard(false)
if submitted_target is LineEdit:
(submitted_target as LineEdit).text_submitted.emit(submitted_text)
elif submitted_target is TextEdit:
(submitted_target as TextEdit).text_changed.emit()
text_submitted.emit(submitted_text)
func _type_character(character: String) -> void:
if not is_instance_valid(_target):
_close_keyboard(false)
return
var caret: int = _get_caret_column()
var next_text: String = (
_buffer.substr(0, caret)
+ character
+ _buffer.substr(caret)
)
if _target is LineEdit:
var line_edit := _target as LineEdit
if line_edit.max_length > 0 and next_text.length() > line_edit.max_length:
return
_buffer = next_text
_set_target_text(caret + character.length())
func _type_space() -> void:
_type_character(" ")
func _backspace() -> void:
if not is_instance_valid(_target) or _buffer.is_empty():
return
var caret: int = _get_caret_column()
if caret <= 0:
return
_buffer = _buffer.erase(caret - 1, 1)
_set_target_text(caret - 1)
func _move_caret(direction: int) -> void:
if not is_instance_valid(_target):
_close_keyboard(false)
return
var caret: int = clampi(
_get_caret_column() + direction,
0,
_buffer.length(),
)
if _target is LineEdit:
(_target as LineEdit).caret_column = caret
elif _target is TextEdit:
(_target as TextEdit).set_caret_column(caret)
_refresh_preview()
func _handle_trigger_shortcut(event: InputEventJoypadMotion) -> bool:
if event.axis == JOY_AXIS_TRIGGER_LEFT:
if event.axis_value <= TRIGGER_RELEASE_THRESHOLD:
_left_trigger_pressed = false
elif (
event.axis_value >= TRIGGER_PRESS_THRESHOLD
and not _left_trigger_pressed
):
_left_trigger_pressed = true
_move_caret(-1)
return true
elif event.axis == JOY_AXIS_TRIGGER_RIGHT:
if event.axis_value <= TRIGGER_RELEASE_THRESHOLD:
_right_trigger_pressed = false
elif (
event.axis_value >= TRIGGER_PRESS_THRESHOLD
and not _right_trigger_pressed
):
_right_trigger_pressed = true
_move_caret(1)
return true
return false
func _get_caret_column() -> int:
if _target is LineEdit:
return clampi(
(_target as LineEdit).caret_column,
0,
_buffer.length()
)
return _buffer.length()
func _set_target_text(caret: int) -> void:
if _target is LineEdit:
var line_edit := _target as LineEdit
line_edit.text = _buffer
line_edit.caret_column = caret
line_edit.text_changed.emit(_buffer)
elif _target is TextEdit:
var text_edit := _target as TextEdit
text_edit.text = _buffer
text_edit.text_changed.emit()
_refresh_preview()
func _refresh_preview() -> void:
if _preview == null:
return
var displayed_text: String = _buffer
if _target is LineEdit and (_target as LineEdit).secret:
displayed_text = "*".repeat(_buffer.length())
var caret: int = clampi(_get_caret_column(), 0, displayed_text.length())
_preview.text = displayed_text.insert(caret, "|")
func _set_page(page_index: int) -> void:
_page = page_index as Page
_rebuild_keys()
func _rebuild_keys() -> void:
for child: Node in _keys_host.get_children():
_keys_host.remove_child(child)
child.queue_free()
_key_buttons.clear()
var rows: Array = _rows_for_page()
for row_value: Variant in rows:
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.size_flags_vertical = Control.SIZE_SHRINK_CENTER
row.add_theme_constant_override("separation", 10)
_keys_host.add_child(row)
for character_value: Variant in row_value as Array:
var character: String = str(character_value)
var key_button: Button = _make_button(character, CHARACTER_KEY_SIZE)
key_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
key_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER
key_button.add_theme_font_size_override(
"font_size", CHARACTER_FONT_SIZE
)
key_button.pressed.connect(_type_character.bind(character))
row.add_child(key_button)
_key_buttons.append(key_button)
for index: int in _page_buttons.size():
_page_buttons[index].button_pressed = index == int(_page)
call_deferred("_configure_key_focus")
func _configure_key_focus() -> void:
var focus_controls: Array[Control] = []
for button: Button in _key_buttons:
focus_controls.append(button)
for button: Button in [
_caret_left_button,
_caret_right_button,
_backspace_button,
_space_button,
_check_button,
]:
focus_controls.append(button)
ControllerFocusNavigationType.configure_spatial_neighbors(focus_controls)
if not _key_buttons.is_empty():
_key_buttons[0].grab_focus()
func _rows_for_page() -> Array:
match _page:
Page.UPPER:
return [
["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
["A", "S", "D", "F", "G", "H", "J", "K", "L"],
["Z", "X", "C", "V", "B", "N", "M"],
]
Page.SYMBOLS:
return [
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
["!", "@", "#", "$", "%", "^", "&", "*", "(", ")"],
["-", "_", "=", "+", "[", "]", "{", "}"],
[".", ",", "?", "/", ":", ";", "'", "\""]
]
_:
return [
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
["a", "s", "d", "f", "g", "h", "j", "k", "l"],
["z", "x", "c", "v", "b", "n", "m"],
]
func _build_interface() -> void:
var backdrop := ColorRect.new()
backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
backdrop.color = Color(0.012, 0.075, 0.105, 0.82)
backdrop.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(backdrop)
var screen_margin := MarginContainer.new()
screen_margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
for side: StringName in [
&"margin_left", &"margin_top", &"margin_right", &"margin_bottom"
]:
screen_margin.add_theme_constant_override(side, 14)
add_child(screen_margin)
var panel := PanelContainer.new()
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
panel.add_theme_stylebox_override(
"panel",
_make_style(Color(0.075, 0.27, 0.34, 0.98), 18, 4)
)
screen_margin.add_child(panel)
var margin := MarginContainer.new()
for side: StringName in [&"margin_left", &"margin_top", &"margin_right", &"margin_bottom"]:
margin.add_theme_constant_override(side, 18)
panel.add_child(margin)
var layout := VBoxContainer.new()
layout.add_theme_constant_override("separation", 10)
margin.add_child(layout)
var preview_panel := PanelContainer.new()
preview_panel.custom_minimum_size = Vector2(0.0, 86.0)
preview_panel.add_theme_stylebox_override(
"panel",
_make_style(Color(0.82, 0.94, 0.95, 1.0), 10, 2)
)
layout.add_child(preview_panel)
_preview = Label.new()
_preview.add_theme_color_override("font_color", Color(0.025, 0.12, 0.17, 1.0))
_preview.add_theme_font_size_override("font_size", 30)
_preview.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
_preview.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_preview.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
_preview.add_theme_constant_override("outline_size", 0)
preview_panel.add_child(_preview)
var page_row := HBoxContainer.new()
page_row.alignment = BoxContainer.ALIGNMENT_CENTER
page_row.add_theme_constant_override("separation", 12)
layout.add_child(page_row)
for page_name: String in ["lower", "upper", "symbols"]:
var page_button: Button = _make_button(page_name, Vector2(150.0, 44.0))
page_button.toggle_mode = true
page_button.focus_mode = Control.FOCUS_NONE
page_button.pressed.connect(_set_page.bind(_page_buttons.size()))
page_row.add_child(page_button)
_page_buttons.append(page_button)
_keys_host = VBoxContainer.new()
_keys_host.size_flags_vertical = Control.SIZE_EXPAND_FILL
_keys_host.alignment = BoxContainer.ALIGNMENT_CENTER
_keys_host.add_theme_constant_override("separation", 12)
layout.add_child(_keys_host)
var utility_row := HBoxContainer.new()
utility_row.alignment = BoxContainer.ALIGNMENT_CENTER
utility_row.add_theme_constant_override("separation", 12)
layout.add_child(utility_row)
_caret_left_button = _make_button("LT <", Vector2(118.0, 64.0))
_caret_left_button.tooltip_text = "move text cursor left"
_caret_left_button.pressed.connect(_move_caret.bind(-1))
utility_row.add_child(_caret_left_button)
_caret_right_button = _make_button("> RT", Vector2(118.0, 64.0))
_caret_right_button.tooltip_text = "move text cursor right"
_caret_right_button.pressed.connect(_move_caret.bind(1))
utility_row.add_child(_caret_right_button)
_backspace_button = _make_button("backspace X", Vector2(180.0, 64.0))
_backspace_button.pressed.connect(_backspace)
utility_row.add_child(_backspace_button)
_space_button = _make_button("space", Vector2(350.0, 64.0))
_space_button.size_flags_stretch_ratio = 4.0
_space_button.pressed.connect(_type_space)
utility_row.add_child(_space_button)
_check_button = _make_button("", Vector2(92.0, 64.0))
_check_button.icon = CHECK_ICON
_check_button.expand_icon = true
_check_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
_check_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
_check_button.vertical_icon_alignment = VERTICAL_ALIGNMENT_CENTER
_check_button.tooltip_text = "submit"
_check_button.accessibility_name = "submit text"
_check_button.add_theme_constant_override("icon_max_width", 42)
_check_button.pressed.connect(_submit)
utility_row.add_child(_check_button)
_rebuild_keys()
func _make_button(label: String, minimum_size: Vector2) -> Button:
var button := Button.new()
button.text = label
button.custom_minimum_size = minimum_size
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.size_flags_vertical = Control.SIZE_EXPAND_FILL
button.focus_mode = Control.FOCUS_ALL
button.add_theme_font_size_override("font_size", 22)
button.add_theme_color_override("font_color", Color(0.025, 0.12, 0.17, 1.0))
button.add_theme_color_override("font_hover_color", Color(0.025, 0.12, 0.17, 1.0))
button.add_theme_color_override("font_focus_color", Color(0.025, 0.12, 0.17, 1.0))
button.add_theme_stylebox_override(
"normal",
_make_style(Color(0.72, 0.88, 0.91, 1.0), 10, 2)
)
button.add_theme_stylebox_override(
"hover",
_make_style(Color(0.87, 0.96, 0.96, 1.0), 10, 3)
)
button.add_theme_stylebox_override("focus", button.get_theme_stylebox("hover"))
button.add_theme_stylebox_override(
"pressed",
_make_style(Color(0.44, 0.72, 0.77, 1.0), 10, 3)
)
return button
func _make_style(color: Color, radius: int, border_width: int) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = color
style.border_color = Color(0.025, 0.12, 0.17, 1.0)
style.set_border_width_all(border_width)
style.set_corner_radius_all(radius)
return style

View file

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

View file

@ -85,6 +85,12 @@ const MAIN_SHOP_BUYER_ID: StringName = &"main_fishing_shop"
signal menu_visibility_changed(is_open: bool)
signal inventory_hotbar_context_changed(show_hotbar: bool)
signal controller_hotbar_placement_requested(
assignment_kind: PlayerHotbarType.AssignmentKind,
identity: StringName,
initial_slot: int,
)
signal controller_hotbar_placement_ended
signal menu_exit_started
signal shop_cooler_modal_changed(is_open: bool)
@ -124,6 +130,12 @@ enum CloseReason {
TEARDOWN,
}
enum ControllerOwnership {
ITEM_LIST,
NOTEPAD_ACTIONS,
HOTBAR_PLACEMENT,
}
const INVENTORY_MAIN_POSITION := Vector2(54.0, 166.0)
const INVENTORY_MAIN_SIZE := Vector2(882.0, 484.0)
const INVENTORY_PANEL_GAP := 16.0
@ -302,6 +314,15 @@ var _last_inventory_section: Section = Section.COOLER
var _bag_view: BagView = BagView.EQUIPMENT
var _tackle_view: TackleView = TackleView.BAIT
var _selected_tackle_item_id: StringName
var _controller_ownership: ControllerOwnership = ControllerOwnership.ITEM_LIST
var _controller_source_section: Section = Section.COOLER
var _controller_source_identity: StringName
var _controller_notepad_actions: Array[BaseButton] = []
var _controller_hotbar_assignment_kind: PlayerHotbarType.AssignmentKind = (
PlayerHotbarType.AssignmentKind.EMPTY
)
var _controller_hotbar_identity: StringName
var _controller_previous_hotbar_slot: int = 0
var _sort_mode: SortMode = SortMode.CATCH_ORDER
var _sort_descending: bool = true
var _fish_selection := FishBatchSelectionType.new()
@ -589,12 +610,25 @@ func set_profile_preview_world_pixel_size(pixel_size: int) -> void:
_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:
if event is InputEventKey and event.echo:
return
if _handle_controller_ownership_input(event):
get_viewport().set_input_as_handled()
return
if visible:
_reserve_main_navigation_for_page_switching()
_reserve_visible_secondary_navigation()
if _handle_controller_page_switch(event):
get_viewport().set_input_as_handled()
return
if _handle_controller_secondary_switch(event):
get_viewport().set_input_as_handled()
return
if _handle_direct_page_shortcut(event):
get_viewport().set_input_as_handled()
return
@ -613,47 +647,360 @@ func _input(event: InputEvent) -> void:
get_viewport().set_input_as_handled()
func _handle_controller_ownership_input(event: InputEvent) -> bool:
if not visible:
return false
var button_event := event as InputEventJoypadButton
var accept_pressed: bool = (
button_event != null
and button_event.pressed
and _event_matches_controller_role(
event,
ControllerMappingManagerType.ROLE_A,
JOY_BUTTON_A,
)
)
var cancel_pressed: bool = (
button_event != null
and button_event.pressed
and _event_matches_controller_role(
event,
ControllerMappingManagerType.ROLE_B,
JOY_BUTTON_B,
)
)
var alternate_pressed: bool = (
button_event != null
and button_event.pressed
and _event_matches_controller_role(
event,
ControllerMappingManagerType.ROLE_Y,
JOY_BUTTON_Y,
)
)
if _controller_ownership == ControllerOwnership.HOTBAR_PLACEMENT:
if accept_pressed:
_confirm_controller_hotbar_placement()
return true
if cancel_pressed:
_release_controller_ownership(true, true)
return true
if alternate_pressed:
return true
return false
if _controller_ownership == ControllerOwnership.NOTEPAD_ACTIONS:
if cancel_pressed:
_release_controller_ownership(true, false)
return true
if alternate_pressed:
return true
var direction: int = 0
if event.is_action_pressed("ui_left") or event.is_action_pressed("ui_up"):
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 false
if alternate_pressed:
return _try_begin_controller_hotbar_placement()
if accept_pressed:
return _try_enter_notepad_controller_ownership()
return false
func _event_matches_controller_role(
event: InputEvent,
role: StringName,
fallback_button: JoyButton,
) -> bool:
if _controller_mapping_manager != null:
return _controller_mapping_manager.event_matches_role(event, role)
var button_event := event as InputEventJoypadButton
return button_event != null and button_event.button_index == fallback_button
func _try_enter_notepad_controller_ownership() -> bool:
var focus_owner: Control = get_viewport().gui_get_focus_owner()
if focus_owner == null:
return false
var source_identity: StringName
var actions: Array[BaseButton] = []
if _current_section == Section.COOLER:
var fish_node := focus_owner as CoolerFishSpriteType
if fish_node == null or fish_node.catch_id.is_empty():
return false
source_identity = fish_node.catch_id
_on_catch_card_pressed(source_identity)
if not _favorite_bubble.disabled:
actions.append(_favorite_bubble)
if not _sell_bubble.disabled:
actions.append(_sell_bubble)
elif _current_section == Section.TACKLE_BOX:
if not focus_owner.has_meta(&"controller_tackle_item_id"):
return false
source_identity = StringName(
str(focus_owner.get_meta(&"controller_tackle_item_id"))
)
_select_tackle_item(source_identity)
if _tackle_equip_button.visible and not _tackle_equip_button.disabled:
actions.append(_tackle_equip_button)
else:
return false
if actions.is_empty():
_restore_controller_item_focus(_current_section, source_identity)
return true
_controller_source_section = _current_section
_controller_source_identity = source_identity
_controller_notepad_actions = actions
_controller_ownership = ControllerOwnership.NOTEPAD_ACTIONS
actions.front().call_deferred("grab_focus")
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:
if _hotbar == null:
return false
var focus_owner: Control = get_viewport().gui_get_focus_owner()
if focus_owner == null:
return false
var assignment_kind: PlayerHotbarType.AssignmentKind = (
PlayerHotbarType.AssignmentKind.EMPTY
)
var identity: StringName
if _current_section == Section.COOLER:
var fish_node := focus_owner as CoolerFishSpriteType
if fish_node == null or fish_node.catch_id.is_empty():
return false
assignment_kind = PlayerHotbarType.AssignmentKind.FISH
identity = fish_node.catch_id
elif _current_section == Section.BAG:
var item_node := focus_owner as BagItemSpriteType
if item_node == null or item_node.item_id.is_empty():
return false
var item: ItemDataType = (
_item_catalog.get_item_by_id(item_node.item_id)
if _item_catalog != null
else null
)
if item == null or not item.hotbar_allowed:
return true
assignment_kind = PlayerHotbarType.AssignmentKind.ITEM
identity = item_node.item_id
else:
return false
_controller_source_section = _current_section
_controller_source_identity = identity
_controller_hotbar_assignment_kind = assignment_kind
_controller_hotbar_identity = identity
_controller_previous_hotbar_slot = _hotbar.get_selected_slot()
var initial_slot: int = _find_controller_hotbar_assignment(
assignment_kind,
identity,
)
if initial_slot < 0:
initial_slot = _controller_previous_hotbar_slot
_controller_ownership = ControllerOwnership.HOTBAR_PLACEMENT
controller_hotbar_placement_requested.emit(
assignment_kind,
identity,
initial_slot,
)
return true
func _find_controller_hotbar_assignment(
assignment_kind: PlayerHotbarType.AssignmentKind,
identity: StringName,
) -> int:
for slot_index: int in range(PlayerHotbarType.SLOT_COUNT):
if (
assignment_kind == PlayerHotbarType.AssignmentKind.FISH
and _hotbar.get_fish_catch_id(slot_index) == identity
):
return slot_index
if (
assignment_kind == PlayerHotbarType.AssignmentKind.ITEM
and _hotbar.get_item_id(slot_index) == identity
):
return slot_index
return -1
func _confirm_controller_hotbar_placement() -> void:
if (
_controller_ownership != ControllerOwnership.HOTBAR_PLACEMENT
or _hotbar == null
):
return
var slot_index: int = _hotbar.get_selected_slot()
var assigned: bool = false
if (
_controller_hotbar_assignment_kind
== PlayerHotbarType.AssignmentKind.FISH
):
assigned = _hotbar.assign_fish(
slot_index,
_controller_hotbar_identity,
)
elif (
_controller_hotbar_assignment_kind
== PlayerHotbarType.AssignmentKind.ITEM
):
assigned = _hotbar.assign_item(
slot_index,
_controller_hotbar_identity,
)
if assigned:
_release_controller_ownership(true, false)
func _release_controller_ownership(
restore_source_focus: bool,
restore_previous_hotbar_slot: bool,
) -> void:
if _controller_ownership == ControllerOwnership.ITEM_LIST:
return
var prior_ownership: ControllerOwnership = _controller_ownership
var source_section: Section = _controller_source_section
var source_identity: StringName = _controller_source_identity
_controller_ownership = ControllerOwnership.ITEM_LIST
_controller_notepad_actions.clear()
_controller_source_identity = StringName()
_controller_hotbar_assignment_kind = PlayerHotbarType.AssignmentKind.EMPTY
_controller_hotbar_identity = StringName()
if prior_ownership == ControllerOwnership.HOTBAR_PLACEMENT:
if restore_previous_hotbar_slot and _hotbar != null:
_hotbar.select_slot(_controller_previous_hotbar_slot)
controller_hotbar_placement_ended.emit()
if restore_source_focus:
call_deferred(
"_restore_controller_item_focus",
source_section,
source_identity,
)
func _restore_controller_item_focus(
section: Section,
identity: StringName,
) -> void:
if not visible or section != _current_section or identity.is_empty():
return
var target: Control
if section == Section.COOLER:
target = _fish_nodes.get(identity) as CoolerFishSpriteType
elif section == Section.BAG:
target = _bag_item_nodes.get(identity) as BagItemSpriteType
elif section == Section.TACKLE_BOX:
for child: Node in _tackle_item_list.get_children():
var button := child as BaseButton
if (
button != null
and button.has_meta(&"controller_tackle_item_id")
and StringName(str(button.get_meta(
&"controller_tackle_item_id"
))) == identity
):
target = button
break
if (
target != null
and is_instance_valid(target)
and target.is_visible_in_tree()
and target.focus_mode != Control.FOCUS_NONE
):
target.grab_focus()
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_bumper: bool = (
var uses_left_trigger: bool = (
_controller_mapping_manager.event_uses_role(
event, ControllerMappingManagerType.ROLE_LB
event, ControllerMappingManagerType.ROLE_POINTER_MODIFIER
)
if use_mapping
else (
button_event != null
and button_event.button_index == JOY_BUTTON_LEFT_SHOULDER
motion_event != null
and motion_event.axis == JOY_AXIS_TRIGGER_LEFT
)
)
var uses_right_bumper: bool = (
var uses_right_trigger: bool = (
_controller_mapping_manager.event_uses_role(
event, ControllerMappingManagerType.ROLE_RB
event, ControllerMappingManagerType.ROLE_CAMERA_ZOOM
)
if use_mapping
else (
button_event != null
and button_event.button_index == JOY_BUTTON_RIGHT_SHOULDER
motion_event != null
and motion_event.axis == JOY_AXIS_TRIGGER_RIGHT
)
)
if (
button_event == null
or not (uses_left_bumper or uses_right_bumper)
not (uses_left_trigger or uses_right_trigger)
or not visible
):
return false
if not button_event.pressed:
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
# Shoulder input belongs to the Player Menu while it is visible, even when
# a transition or modal temporarily prevents changing pages. This keeps LB
# from opening Chat and RB from leaking into gameplay behind the menu.
if (
(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] = [
@ -670,13 +1017,131 @@ func _handle_controller_page_switch(event: InputEvent) -> bool:
if current_index < 0:
current_index = 0
var direction: int = (
-1 if uses_left_bumper else 1
-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
if button_event == null or not visible:
return false
var use_mapping: bool = _controller_mapping_manager != null
var uses_left_bumper: bool = (
_controller_mapping_manager.event_uses_role(
event, ControllerMappingManagerType.ROLE_LB
)
if use_mapping
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 use_mapping
else button_event.button_index == JOY_BUTTON_RIGHT_SHOULDER
)
if not (uses_left_bumper or uses_right_bumper):
return false
if not button_event.pressed:
return true
if (
_transitioning
or _page_transitioning
or _sale_confirmation.visible
or get_viewport().gui_is_dragging()
or _controller_ownership != ControllerOwnership.ITEM_LIST
):
return true
var direction: int = -1 if uses_left_bumper else 1
if _is_inventory_section(_current_section):
var inventory_sections: Array[Section] = [
Section.COOLER,
Section.BAG,
Section.TACKLE_BOX,
]
var inventory_index: int = inventory_sections.find(_current_section)
_show_section(inventory_sections[wrapi(
inventory_index + direction,
0,
inventory_sections.size(),
)])
return true
_cycle_visible_secondary_tabs(direction)
return true
func _cycle_visible_secondary_tabs(direction: int) -> bool:
var navigation_cluster := get_node_or_null("%NavigationCluster") as Control
var grouped_buttons: Dictionary = {}
_collect_visible_toggle_buttons(self, navigation_cluster, grouped_buttons)
var selected_group: Variant = null
var selected_group_y: float = INF
for group_value: Variant in grouped_buttons.keys():
var buttons := grouped_buttons[group_value] as Array
if buttons.size() < 2:
continue
var group_y: float = INF
for item: Variant in buttons:
var button := item as BaseButton
group_y = minf(group_y, button.global_position.y)
if group_y < selected_group_y:
selected_group = group_value
selected_group_y = group_y
if selected_group == null:
return false
var tabs := grouped_buttons[selected_group] as Array
tabs.sort_custom(func(first: BaseButton, second: BaseButton) -> bool:
return first.global_position.x < second.global_position.x
)
var current_index: int = 0
for index: int in tabs.size():
var tab := tabs[index] as BaseButton
if tab.button_pressed:
current_index = index
break
var target := tabs[wrapi(
current_index + direction, 0, tabs.size()
)] as BaseButton
target.set_pressed_no_signal(true)
target.pressed.emit()
return true
func _collect_visible_toggle_buttons(
root: Node,
navigation_cluster: Control,
grouped_buttons: Dictionary,
) -> void:
for child: Node in root.get_children():
var child_control := child as Control
if child_control != null and not child_control.is_visible_in_tree():
continue
var button := child as BaseButton
if (
button != null
and button.toggle_mode
and (
navigation_cluster == null
or not navigation_cluster.is_ancestor_of(button)
)
):
var group_key: Variant = (
button.button_group
if button.button_group != null
else button.get_parent()
)
if not grouped_buttons.has(group_key):
grouped_buttons[group_key] = []
var buttons := grouped_buttons[group_key] as Array
buttons.append(button)
_collect_visible_toggle_buttons(
child, navigation_cluster, grouped_buttons
)
func _handle_direct_page_shortcut(event: InputEvent) -> bool:
var key_event := event as InputEventKey
if (
@ -772,6 +1237,7 @@ func open_menu() -> void:
or not _fishing_spot.can_open_player_menu()
):
return
_release_controller_ownership(false, true)
_menu_generation += 1
_transition_generation += 1
_cancel_presentation_tween()
@ -921,6 +1387,7 @@ func close_menu(
if reason != CloseReason.USER:
_finish_close(reason, restore_controls, _menu_generation)
return
_release_controller_ownership(false, true)
get_viewport().gui_cancel_drag()
_close_sale_confirmation()
if reason in [
@ -949,6 +1416,7 @@ func close_for_session_end() -> void:
func _exit_tree() -> void:
_release_controller_ownership(false, true)
_cancel_presentation_tween()
_cancel_page_tween()
if visible:
@ -1007,6 +1475,7 @@ func _show_section(section: Section) -> void:
and _profile_page.request_close_confirmation()
):
return
_release_controller_ownership(false, true)
_begin_page_transition(section)
@ -1122,22 +1591,39 @@ func _focus_current_section() -> void:
if _shop_cooler_context_active:
_focus_shop_cooler()
return
if _current_section == Section.COOLER:
_cooler_sub_tab.grab_focus()
elif _current_section == Section.BAG:
_bag_sub_tab.grab_focus()
elif _current_section == Section.TACKLE_BOX:
_tackle_sub_tab.grab_focus()
elif _current_section == Section.LOGBOOK:
_catalog_logbook.focus_initial()
elif _current_section == Section.NET:
_the_net_page.focus_initial()
elif _current_section == Section.MAIL:
_mail_tab.grab_focus()
elif _current_section == Section.PLAYERS:
_players_tab.grab_focus()
else:
_profile_tab.grab_focus()
_reserve_main_navigation_for_page_switching()
_reserve_visible_secondary_navigation()
var navigation_cluster := get_node_or_null("%NavigationCluster") as Control
var candidates: Array[Control] = []
_collect_focusable_content(self, navigation_cluster, candidates)
if candidates.is_empty():
return
candidates.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
)
candidates[0].grab_focus()
func _collect_focusable_content(
root: Node,
navigation_cluster: Control,
output: Array[Control],
) -> void:
for child: Node in root.get_children():
if child == navigation_cluster:
continue
var control := child as Control
if control != null and not control.is_visible_in_tree():
continue
if (
control != null
and control.focus_mode != Control.FOCUS_NONE
and not control is ScrollBar
):
output.append(control)
_collect_focusable_content(child, navigation_cluster, output)
func _process(delta: float) -> void:
@ -1161,6 +1647,7 @@ func _process(delta: float) -> void:
func _configure_navigation_focus() -> void:
_reserve_main_navigation_for_page_switching()
var navigation: Array[BubbleButtonType] = [
_inventory_tab,
_logbook_tab,
@ -1182,6 +1669,48 @@ func _configure_navigation_focus() -> void:
bubble.focus_neighbor_right = bubble.get_path_to(next)
bubble.focus_neighbor_top = bubble.focus_neighbor_left
bubble.focus_neighbor_bottom = bubble.focus_neighbor_right
func _reserve_main_navigation_for_page_switching() -> void:
var navigation_cluster := get_node_or_null("%NavigationCluster") as Control
if navigation_cluster == null:
return
_set_descendant_focus_disabled(navigation_cluster)
func _set_descendant_focus_disabled(root: Node) -> void:
for child: Node in root.get_children():
var control := child as Control
if control != null:
control.focus_mode = Control.FOCUS_NONE
_set_descendant_focus_disabled(child)
func _reserve_visible_secondary_navigation() -> void:
var navigation_cluster := get_node_or_null("%NavigationCluster") as Control
var grouped_buttons: Dictionary = {}
_collect_visible_toggle_buttons(self, navigation_cluster, grouped_buttons)
var selected_tabs: Array = []
var selected_group_y: float = INF
for group_value: Variant in grouped_buttons.keys():
var buttons := grouped_buttons[group_value] as Array
if buttons.size() < 2:
continue
var group_y: float = INF
for item: Variant in buttons:
var button := item as BaseButton
group_y = minf(group_y, button.global_position.y)
if group_y < selected_group_y:
selected_tabs = buttons
selected_group_y = group_y
var focus_owner: Control = get_viewport().gui_get_focus_owner()
var displaced_focus: bool = selected_tabs.has(focus_owner)
for item: Variant in selected_tabs:
var tab := item as Control
tab.focus_mode = Control.FOCUS_NONE
if displaced_focus:
focus_owner.release_focus()
call_deferred("_focus_current_section")
var inventory_tabs: Array[Button] = [
_cooler_sub_tab,
_tackle_sub_tab,
@ -1359,6 +1888,7 @@ func _refresh_tackle_box() -> void:
row.text = "%s ×%d" % [item.display_name, owned.quantity]
row.alignment = HORIZONTAL_ALIGNMENT_LEFT
row.toggle_mode = true
row.set_meta(&"controller_tackle_item_id", owned.item_id)
row.button_pressed = owned.item_id == _selected_tackle_item_id
if item.is_bait() and item.icon != null:
_apply_tackle_bait_button_style(row)

View file

@ -336,7 +336,32 @@ func _confirm(text: String, action: Callable) -> void:
func _focus_first() -> void:
for child: Node in _tabs.get_children():
if child is Button and child.visible and not child.disabled:
child.grab_focus()
return
var candidates: Array[Control] = []
_collect_focusable_player_controls(self, candidates)
if candidates.is_empty():
return
candidates.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
)
candidates[0].grab_focus()
func _collect_focusable_player_controls(
root: Node,
output: Array[Control],
) -> void:
for child: Node in root.get_children():
if child == _tabs:
continue
var control := child as Control
if control != null and not control.is_visible_in_tree():
continue
if (
control != null
and control.focus_mode != Control.FOCUS_NONE
and not control is ScrollBar
):
output.append(control)
_collect_focusable_player_controls(child, output)

View file

@ -1,6 +1,10 @@
class_name SettingsBubblePage
extends Control
const ControllerFocusNavigationType = preload(
"res://ui/controller_focus_navigation.gd"
)
@export var page_id: StringName
@export var cluster_path: NodePath = ^"BubbleCluster"
@export var bubble_paths: Array[NodePath] = []
@ -169,23 +173,13 @@ func _update_layout() -> void:
_cluster.position = _resting_cluster_position
_cluster.size = field_size
_cluster.apply_layout(field_size, compact)
ControllerFocusNavigationType.configure_spatial_neighbors(_focus_bubbles)
func _configure_focus_order() -> void:
for bubble: BubbleButton in _bubbles:
bubble.focus_mode = Control.FOCUS_NONE
for index: int in _focus_bubbles.size():
var bubble: BubbleButton = _focus_bubbles[index]
if index > 0:
bubble.focus_neighbor_top = bubble.get_path_to(
_focus_bubbles[index - 1]
)
bubble.focus_neighbor_left = bubble.focus_neighbor_top
if index + 1 < _focus_bubbles.size():
bubble.focus_neighbor_bottom = bubble.get_path_to(
_focus_bubbles[index + 1]
)
bubble.focus_neighbor_right = bubble.focus_neighbor_bottom
ControllerFocusNavigationType.configure_spatial_neighbors(_focus_bubbles)
func _set_interactive(interactive: bool) -> void:

View file

@ -53,6 +53,7 @@ enum PresentationMode {
@onready var _mouse_value: BubbleButton = %MouseValue
@onready var _controller_value: BubbleButton = %ControllerValue
@onready var _invert_y_toggle: BubbleButton = %InvertYToggle
@onready var _on_screen_keyboard_toggle: BubbleButton = %OnScreenKeyboardToggle
@onready var _auto_click_toggle: BubbleButton = %AutoClickToggle
@onready var _auto_click_interval: BubbleButton = %AutoClickIntervalValue
@ -84,6 +85,7 @@ var _auto_click_interval_value: float = 0.20
var _mouse_sensitivity: float = 0.005
var _controller_sensitivity: float = 2.5
var _invert_camera_y: bool = false
var _on_screen_keyboard_enabled: bool = false
var _network_profile: NetworkProfilePreferences
var _network_session: NetworkSession
var _data_root: PlayerDataRoot
@ -161,6 +163,7 @@ func _ready() -> void:
_adjust_controller_sensitivity.bind(1)
)
_invert_y_toggle.pressed.connect(_toggle_invert_y)
_on_screen_keyboard_toggle.pressed.connect(_toggle_on_screen_keyboard)
_auto_click_toggle.pressed.connect(_toggle_auto_click)
for control: Control in [_auto_click_toggle, _auto_click_interval]:
control.mouse_entered.connect(
@ -704,6 +707,7 @@ func _apply_settings() -> void:
edited.mouse_camera_sensitivity = _mouse_sensitivity
edited.controller_camera_sensitivity = _controller_sensitivity
edited.invert_camera_y = _invert_camera_y
edited.on_screen_keyboard_enabled = _on_screen_keyboard_enabled
if _settings_manager.apply_settings(edited):
if (
_presentation_mode == PresentationMode.TITLE_EMBEDDED
@ -725,6 +729,7 @@ func _load_controls() -> void:
_mouse_sensitivity = settings.mouse_camera_sensitivity
_controller_sensitivity = settings.controller_camera_sensitivity
_invert_camera_y = settings.invert_camera_y
_on_screen_keyboard_enabled = settings.on_screen_keyboard_enabled
_refresh_value_labels()
@ -755,6 +760,10 @@ func _refresh_value_labels() -> void:
"invert\nvertical\ncamera\n"
+ ("on" if _invert_camera_y else "off")
)
_on_screen_keyboard_toggle.text = (
"on-screen\nkeyboard\n"
+ ("on" if _on_screen_keyboard_enabled else "off")
)
_auto_click_toggle.text = (
"accessibility\nauto-click\n"
+ ("on" if _auto_click_enabled else "off")
@ -883,6 +892,11 @@ func _toggle_invert_y() -> void:
_refresh_value_labels()
func _toggle_on_screen_keyboard() -> void:
_on_screen_keyboard_enabled = not _on_screen_keyboard_enabled
_refresh_value_labels()
func _toggle_auto_click() -> void:
_auto_click_enabled = not _auto_click_enabled
_refresh_value_labels()

View file

@ -397,8 +397,8 @@ grow_horizontal = 2
grow_vertical = 2
script = ExtResource("3_page")
page_id = &"controls"
bubble_paths = Array[NodePath]([NodePath("BubbleCluster/MouseDecrease"), NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/MouseIncrease"), NodePath("BubbleCluster/ControllerDecrease"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/ControllerIncrease"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/ControlsBackButton")])
focus_paths = Array[NodePath]([NodePath("BubbleCluster/MouseDecrease"), NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/MouseIncrease"), NodePath("BubbleCluster/ControllerDecrease"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/ControllerIncrease"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/ControlsBackButton")])
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")])
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")])
initial_focus_path = NodePath("BubbleCluster/MouseValue")
back_focus_path = NodePath("BubbleCluster/ControlsBackButton")
@ -491,17 +491,29 @@ neutral_size = Vector2(176, 170)
compact_minimum_size = Vector2(170, 164)
minimum_font_size = 14
maximum_font_size = 24
desktop_anchor = Vector2(525, 145)
compact_anchor = Vector2(485, 135)
desktop_anchor = Vector2(525, 105)
compact_anchor = Vector2(485, 95)
motion_phase = 4.5
[node name="OnScreenKeyboardToggle" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 0)
text = "on-screen\nkeyboard\noff"
neutral_size = Vector2(170, 150)
compact_minimum_size = Vector2(154, 136)
minimum_font_size = 14
maximum_font_size = 22
desktop_anchor = Vector2(525, 255)
compact_anchor = Vector2(485, 235)
motion_phase = 4.7
[node name="ControllerMapping" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 0)
text = "map\ncontroller"
neutral_size = Vector2(160, 150)
desktop_anchor = Vector2(525, 330)
compact_anchor = Vector2(485, 300)
desktop_anchor = Vector2(525, 390)
compact_anchor = Vector2(485, 355)
compact_minimum_size = Vector2(148, 138)
minimum_font_size = 14
maximum_font_size = 23
@ -512,8 +524,8 @@ unique_name_in_owner = true
custom_minimum_size = Vector2(0, 0)
text = "back"
neutral_size = Vector2(100, 94)
desktop_anchor = Vector2(635, 445)
compact_anchor = Vector2(555, 405)
desktop_anchor = Vector2(635, 470)
compact_anchor = Vector2(555, 420)
compact_minimum_size = Vector2(84, 80)
minimum_font_size = 14
maximum_font_size = 17

View file

@ -608,6 +608,10 @@ func _prepare_awaiting_start_input() -> void:
_navigation_focus_active = false
_modal_restore_navigation_focus = false
_button_center.show()
var bubble_field := get_node_or_null("%BubbleField") as Control
if bubble_field != null:
bubble_field.hide()
call_deferred("set_process", visible)
_feedback_label.show()
_feedback_label.modulate.a = 0.0
_continue_stats_hovered = false
@ -650,6 +654,10 @@ func _reveal_primary_menu() -> void:
):
return
_awaiting_start_input = false
_button_center.show()
var bubble_field := get_node_or_null("%BubbleField") as Control
if bubble_field != null:
bubble_field.show()
_stop_entry_prompt_animation()
_title_entry_generation += 1
_title_entry_transition_active = true

View file

@ -5,6 +5,13 @@ const MIN_UI_VIEWPORT_SIZE: Vector2i = Vector2i(256, 180)
const UIReferencePresentationType = preload(
"res://ui/ui_reference_presentation.gd"
)
const ControllerFocusPresentationType = preload(
"res://ui/controller_focus_presentation.gd"
)
const ControllerFocusRecoveryType = preload(
"res://ui/controller_focus_recovery.gd"
)
const OnScreenKeyboardType = preload("res://ui/on_screen_keyboard.gd")
signal effective_pixel_size_changed(
requested_pixel_size: int,
@ -16,6 +23,9 @@ signal effective_pixel_size_changed(
@onready var _canonical_stage: Control = (
$UIViewport/GameUI/UIRoot/CanonicalStage
)
@onready var _hotbar: HotbarUI = (
$UIViewport/GameUI/UIRoot/CanonicalStage/Hotbar
)
@onready var _chat_ui: ChatUI = $UIViewport/GameUI/UIRoot/ChatUI
@onready var _title_content_stage: Control = (
$UIViewport/GameUI/UIRoot/TitleScreen/ResponsiveTitleStage
@ -26,14 +36,25 @@ var _effective_pixel_size: int = PlayerSettings.DEFAULT_UI_PIXEL_SIZE
var _gameplay_active: bool = false
var _interactive_ui_open: bool = false
var _passive_pointer_ui_enabled: bool = false
var _on_screen_keyboard: OnScreenKeyboardType
func _ready() -> void:
_on_screen_keyboard = OnScreenKeyboardType.new()
_ui_root.add_child(_on_screen_keyboard)
var controller_focus_recovery := ControllerFocusRecoveryType.new()
_ui_root.add_child(controller_focus_recovery)
var controller_focus_presentation := ControllerFocusPresentationType.new()
_ui_root.add_child(controller_focus_presentation)
var root_viewport: Viewport = get_viewport()
root_viewport.size_changed.connect(_resize_presentation)
_resize_presentation()
func set_on_screen_keyboard_enabled(enabled: bool) -> void:
_on_screen_keyboard.set_enabled(enabled)
func set_pixel_size(pixel_size: int) -> void:
_requested_pixel_size = clampi(
pixel_size,
@ -120,6 +141,7 @@ func _resize_presentation() -> void:
UIReferencePresentationType.get_stage_position(display_size)
)
_canonical_stage.size = UIReferencePresentationType.REFERENCE_SIZE
_hotbar.position = Vector2(0.0, _canonical_stage.position.y)
_title_content_stage.set_anchors_preset(Control.PRESET_TOP_LEFT)
_title_content_stage.position = _canonical_stage.position
_title_content_stage.size = UIReferencePresentationType.REFERENCE_SIZE