From 9c89116713b11d26aa911406d8003c2cc8676895 Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 3 Aug 2026 09:31:13 -0400 Subject: [PATCH] Improve Android controller support --- .../textures/grass_lite.png.import | 5 +- .../textures/grass_olive_tile.png.import | 5 +- .../environment/textures/sand.png.import | 5 +- player/player.gd | 43 ++- project.godot | 30 +- tests/android_readiness_validation.gd | 22 +- ui/chat_ui.gd | 7 + ui/controller_virtual_cursor.gd | 40 +++ ui/controller_virtual_cursor.gd.uid | 1 + ui/emote_radial_menu.gd | 26 +- ui/game_ui.gd | 295 +++++++++++++++++- ui/game_ui.tscn | 13 +- ui/player_menu.gd | 19 ++ ui/quick_radial_menu.gd | 152 +++++++++ ui/quick_radial_menu.gd.uid | 1 + ui/quick_radial_menu.tscn | 14 + 16 files changed, 647 insertions(+), 31 deletions(-) create mode 100644 ui/controller_virtual_cursor.gd create mode 100644 ui/controller_virtual_cursor.gd.uid create mode 100644 ui/quick_radial_menu.gd create mode 100644 ui/quick_radial_menu.gd.uid create mode 100644 ui/quick_radial_menu.tscn diff --git a/art/exported/environment/textures/grass_lite.png.import b/art/exported/environment/textures/grass_lite.png.import index a5ec41c..a878153 100644 --- a/art/exported/environment/textures/grass_lite.png.import +++ b/art/exported/environment/textures/grass_lite.png.import @@ -4,15 +4,16 @@ importer="texture" type="CompressedTexture2D" uid="uid://crci2on30ctoc" path.s3tc="res://.godot/imported/grass_lite.png-f89cf6413257cd31582bd4f9f875ae85.s3tc.ctex" +path.etc2="res://.godot/imported/grass_lite.png-f89cf6413257cd31582bd4f9f875ae85.etc2.ctex" metadata={ -"imported_formats": ["s3tc_bptc"], +"imported_formats": ["s3tc_bptc", "etc2_astc"], "vram_texture": true } [deps] source_file="res://art/exported/environment/textures/grass_lite.png" -dest_files=["res://.godot/imported/grass_lite.png-f89cf6413257cd31582bd4f9f875ae85.s3tc.ctex"] +dest_files=["res://.godot/imported/grass_lite.png-f89cf6413257cd31582bd4f9f875ae85.s3tc.ctex", "res://.godot/imported/grass_lite.png-f89cf6413257cd31582bd4f9f875ae85.etc2.ctex"] [params] diff --git a/art/exported/environment/textures/grass_olive_tile.png.import b/art/exported/environment/textures/grass_olive_tile.png.import index bd6ee69..c880374 100644 --- a/art/exported/environment/textures/grass_olive_tile.png.import +++ b/art/exported/environment/textures/grass_olive_tile.png.import @@ -4,15 +4,16 @@ importer="texture" type="CompressedTexture2D" uid="uid://btxhq3rdlum2" path.s3tc="res://.godot/imported/grass_olive_tile.png-72072a382d0948818df97773afa5493f.s3tc.ctex" +path.etc2="res://.godot/imported/grass_olive_tile.png-72072a382d0948818df97773afa5493f.etc2.ctex" metadata={ -"imported_formats": ["s3tc_bptc"], +"imported_formats": ["s3tc_bptc", "etc2_astc"], "vram_texture": true } [deps] source_file="res://art/exported/environment/textures/grass_olive_tile.png" -dest_files=["res://.godot/imported/grass_olive_tile.png-72072a382d0948818df97773afa5493f.s3tc.ctex"] +dest_files=["res://.godot/imported/grass_olive_tile.png-72072a382d0948818df97773afa5493f.s3tc.ctex", "res://.godot/imported/grass_olive_tile.png-72072a382d0948818df97773afa5493f.etc2.ctex"] [params] diff --git a/art/exported/environment/textures/sand.png.import b/art/exported/environment/textures/sand.png.import index 82f5791..fdf8720 100644 --- a/art/exported/environment/textures/sand.png.import +++ b/art/exported/environment/textures/sand.png.import @@ -4,15 +4,16 @@ importer="texture" type="CompressedTexture2D" uid="uid://bnje415gq85nn" path.s3tc="res://.godot/imported/sand.png-41fc96c392119deeba43c2d4e49d88a5.s3tc.ctex" +path.etc2="res://.godot/imported/sand.png-41fc96c392119deeba43c2d4e49d88a5.etc2.ctex" metadata={ -"imported_formats": ["s3tc_bptc"], +"imported_formats": ["s3tc_bptc", "etc2_astc"], "vram_texture": true } [deps] source_file="res://art/exported/environment/textures/sand.png" -dest_files=["res://.godot/imported/sand.png-41fc96c392119deeba43c2d4e49d88a5.s3tc.ctex"] +dest_files=["res://.godot/imported/sand.png-41fc96c392119deeba43c2d4e49d88a5.s3tc.ctex", "res://.godot/imported/sand.png-41fc96c392119deeba43c2d4e49d88a5.etc2.ctex"] [params] diff --git a/player/player.gd b/player/player.gd index 08c4217..99a8e65 100644 --- a/player/player.gd +++ b/player/player.gd @@ -94,6 +94,8 @@ class ShowcaseCameraSnapshot: @export var maximum_zoom: float = 8.0 @export var zoom_step: float = 0.75 @export var zoom_smoothing: float = 12.0 +@export_range(0.1, 12.0, 0.1) var controller_zoom_speed: float = 4.0 +@export_range(0.0, 1.0, 0.01) var controller_trigger_threshold: float = 0.55 @onready var _visuals: Node3D = %Visuals @onready var _character_animation_player: AnimationPlayer = ( @@ -290,16 +292,26 @@ func _process(delta: float) -> void: Input.get_joy_axis(0, JOY_AXIS_RIGHT_Y) ) if stick.length() > controller_camera_deadzone: - var adjusted_strength: float = ( - (stick.length() - controller_camera_deadzone) - / (1.0 - controller_camera_deadzone) - ) - _rotate_camera( - stick.normalized() - * adjusted_strength - * controller_camera_speed - * delta - ) + if ( + Input.get_joy_axis(0, JOY_AXIS_TRIGGER_RIGHT) + >= controller_trigger_threshold + ): + var vertical_zoom_input: float = _apply_axis_deadzone(stick.y) + _set_target_zoom( + _target_zoom + + vertical_zoom_input * controller_zoom_speed * delta + ) + else: + var adjusted_strength: float = ( + (stick.length() - controller_camera_deadzone) + / (1.0 - controller_camera_deadzone) + ) + _rotate_camera( + stick.normalized() + * adjusted_strength + * controller_camera_speed + * delta + ) var zoom_weight: float = 1.0 - exp(-zoom_smoothing * delta) _spring_arm.spring_length = lerpf( @@ -435,6 +447,17 @@ func _set_target_zoom(value: float) -> void: _target_zoom = clampf(value, minimum_zoom, maximum_zoom) +func _apply_axis_deadzone(value: float) -> float: + var magnitude: float = absf(value) + if magnitude <= controller_camera_deadzone: + return 0.0 + return ( + signf(value) + * (magnitude - controller_camera_deadzone) + / (1.0 - controller_camera_deadzone) + ) + + func set_local_control(enabled: bool) -> void: local_control_enabled = enabled if is_node_ready(): diff --git a/project.godot b/project.godot index 9c0fd9a..9ec4067 100644 --- a/project.godot +++ b/project.godot @@ -80,13 +80,13 @@ slow_walk={ interact={ "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":69,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) -, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":2,"pressure":0.0,"pressed":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":3,"pressure":0.0,"pressed":false,"script":null) ] } fish_primary={ "deadzone": 0.2, "events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":32,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null) -, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":0,"axis":5,"axis_value":1.0,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":10,"pressure":0.0,"pressed":false,"script":null) ] } camera_drag={ @@ -97,13 +97,11 @@ camera_drag={ camera_zoom_in={ "deadzone": 0.2, "events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":32,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":4,"canceled":false,"pressed":false,"double_click":false,"script":null) -, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":10,"pressure":0.0,"pressed":false,"script":null) ] } camera_zoom_out={ "deadzone": 0.2, "events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":32,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":5,"canceled":false,"pressed":false,"double_click":false,"script":null) -, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":9,"pressure":0.0,"pressed":false,"script":null) ] } hotbar_1={ @@ -178,6 +176,16 @@ open_emotes={ , Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":11,"pressure":0.0,"pressed":false,"script":null) ] } +open_quick_actions={ +"deadzone": 0.2, +"events": [Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":12,"pressure":0.0,"pressed":false,"script":null) +] +} +open_chat={ +"deadzone": 0.2, +"events": [Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":9,"pressure":0.0,"pressed":false,"script":null) +] +} open_tacklebox={ "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":66,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) @@ -213,6 +221,20 @@ alternate_fishing={ "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":92,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } +ui_accept={ +"deadzone": 0.5, +"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":4194309,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, 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":4194310,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, 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":32,"physical_keycode":0,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":0,"pressure":0.0,"pressed":false,"script":null) +] +} +ui_cancel={ +"deadzone": 0.5, +"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":4194305,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":1,"pressure":0.0,"pressed":false,"script":null) +] +} [physics] diff --git a/tests/android_readiness_validation.gd b/tests/android_readiness_validation.gd index 5a03dc5..480fcce 100644 --- a/tests/android_readiness_validation.gd +++ b/tests/android_readiness_validation.gd @@ -15,16 +15,30 @@ func _init() -> void: assert(_has_joypad_motion(&"move_left", JOY_AXIS_LEFT_X, -1.0)) assert(_has_joypad_motion(&"move_right", JOY_AXIS_LEFT_X, 1.0)) assert(_has_joypad_button(&"jump", JOY_BUTTON_A)) - assert(_has_joypad_button(&"interact", JOY_BUTTON_X)) - assert(_has_joypad_motion(&"fish_primary", JOY_AXIS_TRIGGER_RIGHT, 1.0)) + assert(_has_joypad_button(&"ui_accept", JOY_BUTTON_A)) + assert(_has_joypad_button(&"ui_cancel", JOY_BUTTON_B)) + assert(_has_joypad_button(&"interact", JOY_BUTTON_Y)) + assert(_has_joypad_button(&"fish_primary", JOY_BUTTON_RIGHT_SHOULDER)) + assert(not _has_joypad_motion( + &"fish_primary", JOY_AXIS_TRIGGER_LEFT, 1.0 + )) + assert(not _has_joypad_motion( + &"fish_primary", JOY_AXIS_TRIGGER_RIGHT, 1.0 + )) assert(_has_joypad_button(&"sprint", JOY_BUTTON_LEFT_STICK)) - assert(_has_joypad_button(&"camera_zoom_out", JOY_BUTTON_LEFT_SHOULDER)) - assert(_has_joypad_button(&"camera_zoom_in", JOY_BUTTON_RIGHT_SHOULDER)) + assert(not _has_joypad_button( + &"camera_zoom_out", JOY_BUTTON_LEFT_SHOULDER + )) + assert(not _has_joypad_button( + &"camera_zoom_in", JOY_BUTTON_RIGHT_SHOULDER + )) assert(_has_joypad_button(&"hotbar_previous", JOY_BUTTON_DPAD_LEFT)) assert(_has_joypad_button(&"hotbar_next", JOY_BUTTON_DPAD_RIGHT)) assert(_has_joypad_button(&"open_backpack", JOY_BUTTON_BACK)) assert(_has_joypad_button(&"open_system_menu", JOY_BUTTON_START)) assert(_has_joypad_button(&"open_emotes", JOY_BUTTON_DPAD_UP)) + assert(_has_joypad_button(&"open_quick_actions", JOY_BUTTON_DPAD_DOWN)) + assert(_has_joypad_button(&"open_chat", JOY_BUTTON_LEFT_SHOULDER)) print("android readiness validation passed") quit() diff --git a/ui/chat_ui.gd b/ui/chat_ui.gd index 9167a18..58b7c81 100644 --- a/ui/chat_ui.gd +++ b/ui/chat_ui.gd @@ -194,6 +194,13 @@ func close_chat() -> void: func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed("open_chat"): + if _opened: + close_chat() + elif _available: + open_chat() + get_viewport().set_input_as_handled() + return if event is InputEventKey and event.pressed and not event.echo: if event.keycode in [KEY_ENTER, KEY_KP_ENTER]: if _opened: diff --git a/ui/controller_virtual_cursor.gd b/ui/controller_virtual_cursor.gd new file mode 100644 index 0000000..f56bb3e --- /dev/null +++ b/ui/controller_virtual_cursor.gd @@ -0,0 +1,40 @@ +class_name ControllerVirtualCursor +extends Control + +const CURSOR_SIZE: Vector2 = Vector2(28.0, 28.0) +const OUTER_COLOR: Color = Color(0.02, 0.10, 0.14, 0.96) +const INNER_COLOR: Color = Color(0.34, 0.86, 0.91, 1.0) +const CENTER_COLOR: Color = Color(0.96, 1.0, 1.0, 1.0) + + +func _ready() -> void: + size = CURSOR_SIZE + mouse_filter = Control.MOUSE_FILTER_IGNORE + focus_mode = Control.FOCUS_NONE + visible = false + queue_redraw() + + +func set_pointer_position(pointer_position: Vector2) -> void: + position = pointer_position - CURSOR_SIZE * 0.5 + + +func _draw() -> void: + var center: Vector2 = CURSOR_SIZE * 0.5 + draw_circle(center, 8.0, OUTER_COLOR) + draw_circle(center, 5.0, INNER_COLOR) + draw_circle(center, 1.75, CENTER_COLOR) + draw_line(Vector2(center.x, 0.0), Vector2(center.x, 6.0), CENTER_COLOR, 2.0) + draw_line( + Vector2(center.x, CURSOR_SIZE.y - 6.0), + Vector2(center.x, CURSOR_SIZE.y), + CENTER_COLOR, + 2.0, + ) + draw_line(Vector2(0.0, center.y), Vector2(6.0, center.y), CENTER_COLOR, 2.0) + draw_line( + Vector2(CURSOR_SIZE.x - 6.0, center.y), + Vector2(CURSOR_SIZE.x, center.y), + CENTER_COLOR, + 2.0, + ) diff --git a/ui/controller_virtual_cursor.gd.uid b/ui/controller_virtual_cursor.gd.uid new file mode 100644 index 0000000..309a5c4 --- /dev/null +++ b/ui/controller_virtual_cursor.gd.uid @@ -0,0 +1 @@ +uid://dehegl7ccn057 diff --git a/ui/emote_radial_menu.gd b/ui/emote_radial_menu.gd index 3ff0622..e0c35de 100644 --- a/ui/emote_radial_menu.gd +++ b/ui/emote_radial_menu.gd @@ -13,10 +13,12 @@ const SECTOR_COUNT: int = 8 const SIT_SECTOR: int = 0 const RING_RADIUS: float = 172.0 const BUBBLE_SIZE: Vector2 = Vector2(92.0, 88.0) +const CONTROLLER_SELECTION_DEADZONE: float = 0.35 var _buttons: Array[BubbleButton] = [] var _is_open: bool = false var _selected_sector: int = SIT_SECTOR +var _controller_selection_mode: bool = false func _ready() -> void: @@ -42,7 +44,7 @@ func handle_input(event: InputEvent, can_open: bool) -> bool: if event.is_pressed(): if _is_open or not can_open: return _is_open - open_menu() + open_menu(event is InputEventJoypadButton) return true if not _is_open: return false @@ -53,9 +55,10 @@ func handle_input(event: InputEvent, can_open: bool) -> bool: return true -func open_menu() -> void: +func open_menu(controller_selection: bool = false) -> void: _is_open = true _selected_sector = SIT_SECTOR + _controller_selection_mode = controller_selection visible = true _layout_bubbles() _apply_selection_styles() @@ -74,7 +77,7 @@ func _process(_delta: float) -> void: if not _is_open: return _layout_bubbles() - _update_mouse_selection() + _update_selection() func _layout_bubbles() -> void: @@ -88,10 +91,27 @@ func _layout_bubbles() -> void: bubble.pivot_offset = BUBBLE_SIZE * 0.5 +func _update_selection() -> void: + var stick: Vector2 = Vector2( + Input.get_joy_axis(0, JOY_AXIS_RIGHT_X), + Input.get_joy_axis(0, JOY_AXIS_RIGHT_Y), + ) + if stick.length() >= CONTROLLER_SELECTION_DEADZONE: + _select_sector_from_offset(stick) + return + if _controller_selection_mode: + return + _update_mouse_selection() + + func _update_mouse_selection() -> void: var offset: Vector2 = get_local_mouse_position() - size * 0.5 if offset.length() < 24.0: return + _select_sector_from_offset(offset) + + +func _select_sector_from_offset(offset: Vector2) -> void: var angle: float = fposmod(offset.angle() + PI * 0.5 + PI / 8.0, TAU) var sector: int = int(floor(angle / (TAU / float(SECTOR_COUNT)))) if sector == _selected_sector: diff --git a/ui/game_ui.gd b/ui/game_ui.gd index 16ad69d..8b2b02d 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -34,6 +34,10 @@ const PlayerCoolerCapacityType = preload( ) const ChatUIType = preload("res://ui/chat_ui.gd") const EmoteRadialMenuType = preload("res://ui/emote_radial_menu.gd") +const QuickRadialMenuType = preload("res://ui/quick_radial_menu.gd") +const ControllerVirtualCursorType = preload( + "res://ui/controller_virtual_cursor.gd" +) const SurfaceDrawingToolbarType = preload( "res://ui/surface_drawing_toolbar.gd" ) @@ -58,6 +62,11 @@ signal passive_pointer_ui_changed(is_enabled: bool) signal player_menu_backdrop_visibility_changed(is_visible: bool) signal shop_backdrop_visibility_changed(is_visible: bool) +const VIRTUAL_MOUSE_INPUT_OWNER: StringName = &"controller_virtual_mouse" +const VIRTUAL_MOUSE_TRIGGER_THRESHOLD: float = 0.55 +const VIRTUAL_MOUSE_STICK_DEADZONE: float = 0.18 +const VIRTUAL_MOUSE_SPEED: float = 720.0 + @onready var _status_label: Label = %StatusLabel @onready var _gameplay_transient_hud: Control = %GameplayTransientHUD @onready var _experience_presentation: Control = %ExperiencePresentation @@ -77,6 +86,7 @@ signal shop_backdrop_visibility_changed(is_visible: bool) @onready var _experience_bubble: PanelContainer = %ExperienceBubble @onready var _experience_bubble_label: Label = %ExperienceBubbleLabel @onready var _canonical_stage: Control = %CanonicalStage +@onready var _ui_root: Control = %UIRoot @onready var _player_menu: PlayerMenuType = %PlayerMenu @onready var _screen_fade: ScreenFade = %ScreenFade @onready var _title_screen: TitleScreenType = %TitleScreen @@ -87,6 +97,10 @@ signal shop_backdrop_visibility_changed(is_visible: bool) @onready var _effect_status: Label = %EffectStatus @onready var _chat_ui: ChatUIType = %ChatUI @onready var _emote_radial_menu: EmoteRadialMenuType = %EmoteRadialMenu +@onready var _quick_radial_menu: QuickRadialMenuType = %QuickRadialMenu +@onready var _controller_virtual_cursor: ControllerVirtualCursorType = ( + %ControllerVirtualCursor +) @onready var _surface_drawing_toolbar: SurfaceDrawingToolbarType = ( %SurfaceDrawingToolbar ) @@ -115,6 +129,15 @@ var _experience_award_queue: Array[Dictionary] = [] var _experience_animation_active: bool = false var _experience_animation_generation: int = 0 var _experience_panel_rest_y: float = 18.0 +var _emote_prior_camera_input_enabled: bool = true +var _quick_prior_camera_input_enabled: bool = true +var _virtual_mouse_active: bool = false +var _virtual_mouse_prior_camera_input_enabled: bool = true +var _virtual_mouse_prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE +var _virtual_mouse_window_position: Vector2 = Vector2.ZERO +var _virtual_mouse_button_mask: int = 0 +var _virtual_mouse_right_pressed: bool = false +var _injecting_virtual_mouse_event: bool = false func _ready() -> void: @@ -128,6 +151,7 @@ func _ready() -> void: presentation_parent.get_child_count() - 1, ) _emote_radial_menu.emote_selected.connect(_on_emote_selected) + _quick_radial_menu.action_selected.connect(_on_quick_action_selected) _chat_ui.text_entry_ownership_changed.connect( _on_chat_text_entry_ownership_changed ) @@ -274,6 +298,17 @@ func setup( func _input(event: InputEvent) -> void: + if ( + _virtual_mouse_active + and _injecting_virtual_mouse_event + and event is InputEventMouseMotion + ): + _update_virtual_cursor_position( + (event as InputEventMouseMotion).position + ) + if _handle_virtual_mouse_input(event): + get_viewport().set_input_as_handled() + return if ( _surface_drawing_toolbar != null and _surface_drawing_toolbar.owns_pointer_event(event) @@ -295,7 +330,7 @@ func _input(event: InputEvent) -> void: ): get_viewport().set_input_as_handled() return - if _emote_radial_menu == null: + if _emote_radial_menu == null or _quick_radial_menu == null: return var can_open: bool = ( _gameplay_ui_enabled @@ -304,8 +339,42 @@ func _input(event: InputEvent) -> void: and not _shop_open and not _chat_input_open and not _showcase_active + and not _virtual_mouse_active ) - if _emote_radial_menu.handle_input(event, can_open): + var emote_was_open: bool = _emote_radial_menu.is_open() + if _emote_radial_menu.handle_input( + event, + can_open and not _quick_radial_menu.is_open(), + ): + var emote_is_open: bool = _emote_radial_menu.is_open() + if emote_is_open != emote_was_open and _player != null: + if emote_is_open: + _emote_prior_camera_input_enabled = ( + _player.is_camera_input_enabled() + ) + _player.set_camera_input_enabled(false) + else: + _player.set_camera_input_enabled( + _emote_prior_camera_input_enabled + ) + get_viewport().set_input_as_handled() + return + var quick_was_open: bool = _quick_radial_menu.is_open() + if _quick_radial_menu.handle_input( + event, + can_open and not _emote_radial_menu.is_open(), + ): + var quick_is_open: bool = _quick_radial_menu.is_open() + if quick_is_open != quick_was_open and _player != null: + if quick_is_open: + _quick_prior_camera_input_enabled = ( + _player.is_camera_input_enabled() + ) + _player.set_camera_input_enabled(false) + else: + _player.set_camera_input_enabled( + _quick_prior_camera_input_enabled + ) get_viewport().set_input_as_handled() @@ -314,6 +383,212 @@ func _on_emote_selected(emote_id: StringName) -> void: _player.toggle_sitting() +func _on_quick_action_selected(action_id: StringName) -> void: + match action_id: + &"stuff": + _player_menu.open_section(PlayerMenuType.Section.COOLER) + &"logbook": + _player_menu.open_section(PlayerMenuType.Section.LOGBOOK) + &"fishnet": + _player_menu.open_section(PlayerMenuType.Section.NET) + &"mail": + _player_menu.open_section(PlayerMenuType.Section.MAIL) + &"profile": + _player_menu.open_section(PlayerMenuType.Section.PROFILE) + &"online": + _player_menu.open_section(PlayerMenuType.Section.PLAYERS) + &"paint": + _toggle_surface_drawing() + &"chat": + _chat_ui.open_chat() + + +func _handle_virtual_mouse_input(event: InputEvent) -> bool: + var motion_event: InputEventJoypadMotion = event as InputEventJoypadMotion + if motion_event != null: + if motion_event.axis == JOY_AXIS_TRIGGER_LEFT: + var was_active: bool = _virtual_mouse_active + if motion_event.axis_value >= VIRTUAL_MOUSE_TRIGGER_THRESHOLD: + if not _virtual_mouse_active and _can_start_virtual_mouse(): + _begin_virtual_mouse() + elif _virtual_mouse_active: + _end_virtual_mouse() + return was_active or _virtual_mouse_active + if ( + _virtual_mouse_active + and motion_event.axis == JOY_AXIS_TRIGGER_RIGHT + ): + _set_virtual_mouse_button( + MOUSE_BUTTON_RIGHT, + motion_event.axis_value >= VIRTUAL_MOUSE_TRIGGER_THRESHOLD, + ) + return true + var button_event: InputEventJoypadButton = event as InputEventJoypadButton + if ( + button_event != null + and button_event.button_index == JOY_BUTTON_RIGHT_SHOULDER + and ( + _virtual_mouse_active + or Input.get_joy_axis(0, JOY_AXIS_TRIGGER_LEFT) + >= VIRTUAL_MOUSE_TRIGGER_THRESHOLD + ) + ): + if not _virtual_mouse_active and _can_start_virtual_mouse(): + _begin_virtual_mouse() + if _virtual_mouse_active: + _set_virtual_mouse_button(MOUSE_BUTTON_LEFT, button_event.pressed) + return true + return false + + +func _can_start_virtual_mouse() -> bool: + return ( + _gameplay_ui_enabled + and not _showcase_active + and _player != null + and _fishing_spot != null + and _fishing_spot.can_use_surface_drawing() + and not _emote_radial_menu.is_open() + and not _quick_radial_menu.is_open() + ) + + +func _begin_virtual_mouse() -> void: + if _virtual_mouse_active: + return + _virtual_mouse_active = true + _virtual_mouse_prior_camera_input_enabled = ( + _player.is_camera_input_enabled() + ) + _player.set_camera_input_enabled(false) + if _fishing_spot != null: + _fishing_spot.set_local_menu_input_suppressed( + VIRTUAL_MOUSE_INPUT_OWNER, + true, + ) + _virtual_mouse_prior_mouse_mode = Input.mouse_mode + Input.mouse_mode = Input.MOUSE_MODE_HIDDEN + _virtual_mouse_button_mask = 0 + _virtual_mouse_right_pressed = false + _virtual_mouse_window_position = Vector2(get_window().size) * 0.5 + _controller_virtual_cursor.visible = true + _emit_virtual_mouse_motion(Vector2.ZERO) + + +func _end_virtual_mouse() -> void: + if not _virtual_mouse_active: + return + if (_virtual_mouse_button_mask & MOUSE_BUTTON_MASK_LEFT) != 0: + _set_virtual_mouse_button(MOUSE_BUTTON_LEFT, false) + if _virtual_mouse_right_pressed: + _set_virtual_mouse_button(MOUSE_BUTTON_RIGHT, false) + _virtual_mouse_active = false + _controller_virtual_cursor.visible = false + if _fishing_spot != null: + _fishing_spot.set_local_menu_input_suppressed( + VIRTUAL_MOUSE_INPUT_OWNER, + false, + ) + if _player != null and is_instance_valid(_player): + _player.set_camera_input_enabled( + _virtual_mouse_prior_camera_input_enabled + ) + Input.mouse_mode = _virtual_mouse_prior_mouse_mode + + +func _update_virtual_mouse(delta: float) -> void: + if not _virtual_mouse_active: + return + if ( + Input.get_joy_axis(0, JOY_AXIS_TRIGGER_LEFT) + < VIRTUAL_MOUSE_TRIGGER_THRESHOLD + ): + _end_virtual_mouse() + return + var stick: Vector2 = Vector2( + Input.get_joy_axis(0, JOY_AXIS_RIGHT_X), + Input.get_joy_axis(0, JOY_AXIS_RIGHT_Y), + ) + var stick_length: float = stick.length() + if stick_length <= VIRTUAL_MOUSE_STICK_DEADZONE: + return + var adjusted_strength: float = ( + (stick_length - VIRTUAL_MOUSE_STICK_DEADZONE) + / (1.0 - VIRTUAL_MOUSE_STICK_DEADZONE) + ) + var display_scale: float = UIReferencePresentationType.get_scale( + Vector2(get_window().size) + ) + var relative_motion: Vector2 = ( + stick.normalized() + * adjusted_strength + * VIRTUAL_MOUSE_SPEED + * display_scale + * delta + ) + var window_size: Vector2 = Vector2(get_window().size) + _virtual_mouse_window_position = Vector2( + clampf( + _virtual_mouse_window_position.x + relative_motion.x, + 0.0, + window_size.x, + ), + clampf( + _virtual_mouse_window_position.y + relative_motion.y, + 0.0, + window_size.y, + ), + ) + _emit_virtual_mouse_motion(relative_motion) + + +func _set_virtual_mouse_button(button: MouseButton, pressed: bool) -> void: + var mask: int = ( + MOUSE_BUTTON_MASK_LEFT + if button == MOUSE_BUTTON_LEFT + else MOUSE_BUTTON_MASK_RIGHT + ) + var already_pressed: bool = bool(_virtual_mouse_button_mask & mask) + if already_pressed == pressed: + return + if pressed: + _virtual_mouse_button_mask |= mask + else: + _virtual_mouse_button_mask &= ~mask + if button == MOUSE_BUTTON_RIGHT: + _virtual_mouse_right_pressed = pressed + var mouse_event := InputEventMouseButton.new() + mouse_event.position = _virtual_mouse_window_position + mouse_event.global_position = _virtual_mouse_window_position + mouse_event.button_index = button + mouse_event.button_mask = _virtual_mouse_button_mask + mouse_event.pressed = pressed + mouse_event.factor = 1.0 + _parse_virtual_mouse_event(mouse_event) + + +func _emit_virtual_mouse_motion(relative_motion: Vector2) -> void: + var motion_event := InputEventMouseMotion.new() + motion_event.position = _virtual_mouse_window_position + motion_event.global_position = _virtual_mouse_window_position + motion_event.relative = relative_motion + motion_event.button_mask = _virtual_mouse_button_mask + _parse_virtual_mouse_event(motion_event) + + +func _parse_virtual_mouse_event(event: InputEventMouse) -> void: + _injecting_virtual_mouse_event = true + Input.parse_input_event(event) + _injecting_virtual_mouse_event = false + + +func _update_virtual_cursor_position(viewport_position: Vector2) -> void: + var root_transform: Transform2D = _ui_root.get_global_transform_with_canvas() + _controller_virtual_cursor.set_pointer_position( + root_transform.affine_inverse() * viewport_position + ) + + func _toggle_surface_drawing() -> void: if _surface_drawing == null: return @@ -344,7 +619,8 @@ func setup_data_and_identity( ) -func _process(_delta: float) -> void: +func _process(delta: float) -> void: + _update_virtual_mouse(delta) _update_experience_bubble_position() if _item_effects == null or not _gameplay_ui_enabled: _effect_status.hide() @@ -403,6 +679,19 @@ func set_gameplay_ui_enabled(enabled: bool) -> void: _experience_presentation.visible = enabled _refresh_chat_availability() if not enabled: + _end_virtual_mouse() + if _emote_radial_menu != null and _emote_radial_menu.is_open(): + _emote_radial_menu.close_menu() + if _player != null: + _player.set_camera_input_enabled( + _emote_prior_camera_input_enabled + ) + if _quick_radial_menu != null and _quick_radial_menu.is_open(): + _quick_radial_menu.close_menu() + if _player != null: + _player.set_camera_input_enabled( + _quick_prior_camera_input_enabled + ) if _surface_drawing != null: _surface_drawing.deactivate() close_player_menu_for_session_end() diff --git a/ui/game_ui.tscn b/ui/game_ui.tscn index 9d63e54..0726da0 100644 --- a/ui/game_ui.tscn +++ b/ui/game_ui.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=19 format=3] +[gd_scene load_steps=21 format=3] [ext_resource type="Script" path="res://ui/game_ui.gd" id="1_ui"] [ext_resource type="PackedScene" path="res://ui/player_menu.tscn" id="2_menu"] @@ -11,6 +11,8 @@ [ext_resource type="Script" path="res://ui/chat_ui.gd" id="9_chat"] [ext_resource type="PackedScene" path="res://ui/emote_radial_menu.tscn" id="10_emote"] [ext_resource type="PackedScene" path="res://ui/surface_drawing_toolbar.tscn" id="11_drawing_toolbar"] +[ext_resource type="PackedScene" path="res://ui/quick_radial_menu.tscn" id="12_quick"] +[ext_resource type="Script" path="res://ui/controller_virtual_cursor.gd" id="13_cursor"] [sub_resource type="StyleBoxFlat" id="StyleBox_chase_background"] bg_color = Color(0.032, 0.118, 0.15, 1) @@ -328,6 +330,9 @@ unique_name_in_owner = true [node name="EmoteRadialMenu" parent="UIRoot/CanonicalStage" instance=ExtResource("10_emote")] unique_name_in_owner = true +[node name="QuickRadialMenu" parent="UIRoot/CanonicalStage" instance=ExtResource("12_quick")] +unique_name_in_owner = true + [node name="ShopPrompt" type="PanelContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD"] unique_name_in_owner = true visible = false @@ -386,6 +391,12 @@ grow_vertical = 2 mouse_filter = 2 script = ExtResource("9_chat") +[node name="ControllerVirtualCursor" type="Control" parent="UIRoot"] +unique_name_in_owner = true +z_index = 500 +mouse_filter = 2 +script = ExtResource("13_cursor") + [node name="ScreenFade" type="ColorRect" parent="UIRoot"] unique_name_in_owner = true visible = false diff --git a/ui/player_menu.gd b/ui/player_menu.gd index af8148d..d3ccfcf 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -701,6 +701,25 @@ func open_menu() -> void: menu_visibility_changed.emit(true) +func open_section(section: Section) -> bool: + if ( + _shop_cooler_context_active + or _player == null + or _fishing_spot == null + or not _fishing_spot.can_open_player_menu() + ): + return false + if visible: + if _transitioning or _page_transitioning or _sale_confirmation.visible: + return false + if section != _current_section: + _show_section(section) + return true + _current_section = section + open_menu() + return visible + + func mount_shop_cooler( host: Control, buyer: FishBuyerProfileType, diff --git a/ui/quick_radial_menu.gd b/ui/quick_radial_menu.gd new file mode 100644 index 0000000..585bae2 --- /dev/null +++ b/ui/quick_radial_menu.gd @@ -0,0 +1,152 @@ +class_name QuickRadialMenu +extends Control + +signal action_selected(action_id: StringName) + +const BubbleButtonScene: PackedScene = preload( + "res://ui/components/bubble_menu/bubble_button.tscn" +) +const BubbleProfile: BubbleMenuProfile = preload( + "res://ui/components/bubble_menu/bubble_menu_profile.tres" +) +const SECTOR_COUNT: int = 8 +const RING_RADIUS: float = 172.0 +const BUBBLE_SIZE: Vector2 = Vector2(92.0, 88.0) +const CONTROLLER_SELECTION_DEADZONE: float = 0.35 +const ACTIONS: Array[StringName] = [ + &"stuff", + &"logbook", + &"fishnet", + &"mail", + &"profile", + &"online", + &"paint", + &"chat", +] +const LABELS: Array[String] = [ + "stuff", + "logbook", + "fishnet", + "mail", + "profile", + "online", + "paint", + "chat", +] + +var _buttons: Array[BubbleButton] = [] +var _is_open: bool = false +var _selected_sector: int = 0 +var _controller_selection_mode: bool = false + + +func _ready() -> void: + visible = false + mouse_filter = Control.MOUSE_FILTER_IGNORE + for sector: int in SECTOR_COUNT: + var bubble: BubbleButton = BubbleButtonScene.instantiate() as BubbleButton + bubble.profile = BubbleProfile + bubble.focus_mode = Control.FOCUS_NONE + bubble.mouse_filter = Control.MOUSE_FILTER_IGNORE + bubble.text = LABELS[sector] + add_child(bubble) + _buttons.append(bubble) + _apply_selection_styles() + + +func handle_input(event: InputEvent, can_open: bool) -> bool: + if not event.is_action("open_quick_actions"): + return false + var key_event: InputEventKey = event as InputEventKey + if key_event != null and key_event.echo: + return false + if event.is_pressed(): + if _is_open or not can_open: + return _is_open + open_menu(event is InputEventJoypadButton) + return true + if not _is_open: + return false + var selected: int = _selected_sector + close_menu() + call_deferred("_emit_selected_action", ACTIONS[selected]) + return true + + +func open_menu(controller_selection: bool = false) -> void: + _is_open = true + _selected_sector = 0 + _controller_selection_mode = controller_selection + visible = true + _layout_bubbles() + _apply_selection_styles() + + +func close_menu() -> void: + _is_open = false + visible = false + + +func is_open() -> bool: + return _is_open + + +func _emit_selected_action(action_id: StringName) -> void: + action_selected.emit(action_id) + + +func _process(_delta: float) -> void: + if not _is_open: + return + _layout_bubbles() + _update_selection() + + +func _layout_bubbles() -> void: + var center: Vector2 = size * 0.5 + for sector: int in SECTOR_COUNT: + var angle: float = -PI * 0.5 + TAU * float(sector) / float(SECTOR_COUNT) + var bubble_center: Vector2 = center + Vector2.from_angle(angle) * RING_RADIUS + var bubble: BubbleButton = _buttons[sector] + bubble.position = bubble_center - BUBBLE_SIZE * 0.5 + bubble.size = BUBBLE_SIZE + bubble.pivot_offset = BUBBLE_SIZE * 0.5 + + +func _update_selection() -> void: + var stick: Vector2 = Vector2( + Input.get_joy_axis(0, JOY_AXIS_RIGHT_X), + Input.get_joy_axis(0, JOY_AXIS_RIGHT_Y), + ) + if stick.length() >= CONTROLLER_SELECTION_DEADZONE: + _select_sector_from_offset(stick) + return + if _controller_selection_mode: + return + _update_mouse_selection() + + +func _update_mouse_selection() -> void: + var offset: Vector2 = get_local_mouse_position() - size * 0.5 + if offset.length() < 24.0: + return + _select_sector_from_offset(offset) + + +func _select_sector_from_offset(offset: Vector2) -> void: + var angle: float = fposmod(offset.angle() + PI * 0.5 + PI / 8.0, TAU) + var sector: int = int(floor(angle / (TAU / float(SECTOR_COUNT)))) + if sector == _selected_sector: + return + _selected_sector = sector + _apply_selection_styles() + + +func _apply_selection_styles() -> void: + for sector: int in _buttons.size(): + var bubble: BubbleButton = _buttons[sector] + bubble.apply_profile() + if sector == _selected_sector: + bubble.add_theme_stylebox_override( + "normal", BubbleProfile.make_hover_style() + ) diff --git a/ui/quick_radial_menu.gd.uid b/ui/quick_radial_menu.gd.uid new file mode 100644 index 0000000..ee4327f --- /dev/null +++ b/ui/quick_radial_menu.gd.uid @@ -0,0 +1 @@ +uid://5nr1c8x881sw diff --git a/ui/quick_radial_menu.tscn b/ui/quick_radial_menu.tscn new file mode 100644 index 0000000..66a0f45 --- /dev/null +++ b/ui/quick_radial_menu.tscn @@ -0,0 +1,14 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://ui/quick_radial_menu.gd" id="1_quick"] + +[node name="QuickRadialMenu" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +z_index = 90 +script = ExtResource("1_quick")