From 78b91c350b5bfcb84d57223ca37373c76fbeab76 Mon Sep 17 00:00:00 2001 From: Voyager Date: Fri, 31 Jul 2026 10:20:18 -0400 Subject: [PATCH] Add animated organizer tabs --- ui/components/organizer_tab.gd | 188 +++++++++++++++++++++++++++++ ui/components/organizer_tab.gd.uid | 1 + ui/logbook_page.gd | 51 ++++++-- ui/player_menu.gd | 45 ++++++- ui/player_menu.tscn | 21 ++-- 5 files changed, 285 insertions(+), 21 deletions(-) create mode 100644 ui/components/organizer_tab.gd create mode 100644 ui/components/organizer_tab.gd.uid diff --git a/ui/components/organizer_tab.gd b/ui/components/organizer_tab.gd new file mode 100644 index 0000000..7f000e0 --- /dev/null +++ b/ui/components/organizer_tab.gd @@ -0,0 +1,188 @@ +class_name OrganizerTab +extends Button + +const INACTIVE_COLOR := Color("d6b875") +const SELECTED_COLOR := Color("f0d995") +const BORDER_COLOR := Color("4c3a25") +const TEXT_COLOR := Color("251b10") +const DISABLED_COLOR := Color("aa9a76") +const RISE_DURATION: float = UIMotion.UTILITY_ENTER_DURATION +const INACTIVE_SETTLE_Y: float = 8.0 +const SELECTED_SETTLE_Y: float = 0.0 +const HOVER_SETTLE_Y: float = 4.0 +const HIDDEN_SETTLE_Y: float = 30.0 + +var _motion_tween: Tween +var _hovered: bool = false +var _motion_ready: bool = false +var _visual_y: float = INACTIVE_SETTLE_Y + + +func _ready() -> void: + toggle_mode = true + add_theme_font_override("font", UtilityPageStyle.TuffyFont) + add_theme_font_size_override("font_size", 18) + for color_name: StringName in [ + &"font_color", + &"font_hover_color", + &"font_focus_color", + &"font_pressed_color", + &"font_disabled_color", + ]: + add_theme_color_override(color_name, Color.TRANSPARENT) + mouse_entered.connect(_set_hovered.bind(true)) + mouse_exited.connect(_set_hovered.bind(false)) + focus_entered.connect(refresh_state) + focus_exited.connect(refresh_state) + toggled.connect(_on_toggled) + _apply_empty_styles() + call_deferred("_initialize_motion") + + +func refresh_state(animate: bool = true) -> void: + queue_redraw() + if _motion_ready: + _move_to_target(animate) + + +func animate_entrance(delay: float = 0.0) -> void: + _cancel_motion() + _motion_ready = true + _set_visual_y(HIDDEN_SETTLE_Y) + _motion_tween = create_tween() + if delay > 0.0: + _motion_tween.tween_interval(delay) + _motion_tween.tween_method( + _set_visual_y, _visual_y, _target_y(), RISE_DURATION + ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT) + _motion_tween.finished.connect(_clear_motion_tween, CONNECT_ONE_SHOT) + + +func settle_for_close() -> void: + _cancel_motion() + if not _motion_ready: + return + _motion_tween = create_tween() + _motion_tween.tween_method( + _set_visual_y, + _visual_y, + HIDDEN_SETTLE_Y, + UIMotion.UTILITY_EXIT_DURATION, + ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN) + _motion_tween.finished.connect(_clear_motion_tween, CONNECT_ONE_SHOT) + + +func _initialize_motion() -> void: + _motion_ready = true + _set_visual_y(_target_y()) + + +func _on_toggled(_pressed: bool) -> void: + refresh_state() + + +func _set_hovered(hovered: bool) -> void: + _hovered = hovered + refresh_state() + + +func _target_y() -> float: + if button_pressed: + return SELECTED_SETTLE_Y + if _hovered or has_focus(): + return HOVER_SETTLE_Y + return INACTIVE_SETTLE_Y + + +func _move_to_target(animate: bool) -> void: + _cancel_motion() + if not animate: + _set_visual_y(_target_y()) + return + _motion_tween = create_tween() + _motion_tween.tween_method( + _set_visual_y, _visual_y, _target_y(), RISE_DURATION + ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT) + _motion_tween.finished.connect(_clear_motion_tween, CONNECT_ONE_SHOT) + + +func _draw() -> void: + var base_color: Color = SELECTED_COLOR if button_pressed else INACTIVE_COLOR + var border_width: int = 2 + var shadow_alpha: float = 0.28 + if disabled: + base_color = DISABLED_COLOR + border_width = 1 + shadow_alpha = 0.12 + elif has_focus(): + base_color = base_color.lightened(0.09) + border_width = 4 + shadow_alpha = 0.40 + elif _hovered: + base_color = base_color.lightened(0.07) + border_width = 3 + shadow_alpha = 0.34 + var visual_rect := Rect2(Vector2(0.0, _visual_y), size) + draw_style_box( + _make_style(base_color, border_width, shadow_alpha), + visual_rect, + ) + var font: Font = UtilityPageStyle.TuffyFont + var ink: Color = Color(TEXT_COLOR, 0.52) if disabled else TEXT_COLOR + draw_string( + font, + Vector2(0.0, _visual_y + 32.0), + text, + HORIZONTAL_ALIGNMENT_CENTER, + size.x, + 18, + ink, + ) + + +func _apply_empty_styles() -> void: + for style_name: StringName in [ + &"normal", + &"hover", + &"focus", + &"pressed", + &"disabled", + ]: + add_theme_stylebox_override(style_name, StyleBoxEmpty.new()) + + +func _set_visual_y(value: float) -> void: + _visual_y = value + queue_redraw() + + +func _make_style(fill: Color, border_width: int, shadow_alpha: float) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = fill + style.border_color = BORDER_COLOR + style.border_width_left = border_width + style.border_width_top = border_width + style.border_width_right = border_width + style.border_width_bottom = 1 + style.corner_radius_top_left = 13 + style.corner_radius_top_right = 13 + style.corner_radius_bottom_left = 2 + style.corner_radius_bottom_right = 2 + style.shadow_color = Color(0.12, 0.08, 0.04, shadow_alpha) + style.shadow_size = 4 + style.shadow_offset = Vector2(0.0, 3.0) + style.content_margin_left = 14.0 + style.content_margin_right = 14.0 + style.content_margin_top = 8.0 + style.content_margin_bottom = 12.0 + return style + + +func _cancel_motion() -> void: + if _motion_tween != null: + _motion_tween.kill() + _motion_tween = null + + +func _clear_motion_tween() -> void: + _motion_tween = null diff --git a/ui/components/organizer_tab.gd.uid b/ui/components/organizer_tab.gd.uid new file mode 100644 index 0000000..bc47750 --- /dev/null +++ b/ui/components/organizer_tab.gd.uid @@ -0,0 +1 @@ +uid://bkk8b2rdqaoa2 diff --git a/ui/logbook_page.gd b/ui/logbook_page.gd index 6c91cb0..9f02f15 100644 --- a/ui/logbook_page.gd +++ b/ui/logbook_page.gd @@ -8,6 +8,7 @@ const FishPoolType = preload("res://fish/fish_pool.gd") const SILHOUETTE_SHADER: Shader = preload( "res://ui/logbook_silhouette.gdshader" ) +const OrganizerTabType = preload("res://ui/components/organizer_tab.gd") const DETAIL_FADE_DURATION: float = 0.12 const CATEGORY_FADE_DURATION: float = UIMotion.UTILITY_EXIT_DURATION @@ -72,11 +73,13 @@ func activate() -> void: _active = true set_interactive(true) _refresh_catalog() + _animate_tab_entry() func deactivate() -> void: _active = false set_interactive(false) + _settle_tabs_for_close() _cancel_detail_tween() _cancel_category_tween() @@ -131,32 +134,38 @@ func _build_interface() -> void: outer.add_theme_constant_override("margin_bottom", 18) add_child(outer) - var layout := VBoxContainer.new() - layout.add_theme_constant_override("separation", 8) - outer.add_child(layout) + var stack := Control.new() + stack.mouse_filter = Control.MOUSE_FILTER_IGNORE + outer.add_child(stack) var tabs := HBoxContainer.new() + tabs.position = Vector2(0.0, 10.0) + tabs.size = Vector2(520.0, 58.0) + tabs.z_index = 10 + tabs.mouse_filter = Control.MOUSE_FILTER_PASS tabs.add_theme_constant_override("separation", 8) - layout.add_child(tabs) + stack.add_child(tabs) for category: LogbookCatalog.Category in [ LogbookCatalog.Category.FRESH_WATER, LogbookCatalog.Category.SALT_WATER, LogbookCatalog.Category.OTHER, ]: - var tab := Button.new() - tab.custom_minimum_size = Vector2(168, 42) + var tab := OrganizerTabType.new() + tab.custom_minimum_size = Vector2(168, 58) tab.toggle_mode = true tab.text = LogbookCatalog.category_label(category) tab.button_pressed = category == _category tab.pressed.connect(_select_category.bind(category)) - UtilityPageStyle.apply_button(tab) tabs.add_child(tab) _category_tabs.append(tab) + _configure_category_focus() var book := HBoxContainer.new() - book.size_flags_vertical = Control.SIZE_EXPAND_FILL + book.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + book.offset_top = 50.0 + book.z_index = 20 book.add_theme_constant_override("separation", 12) - layout.add_child(book) + stack.add_child(book) var left_page := PanelContainer.new() left_page.size_flags_horizontal = Control.SIZE_EXPAND_FILL @@ -357,9 +366,33 @@ func _select_category(category: LogbookCatalog.Category) -> void: _selected_entry_key = StringName() for index: int in _category_tabs.size(): _category_tabs[index].button_pressed = index == int(_category) + (_category_tabs[index] as OrganizerTabType).refresh_state() _begin_category_transition() +func _configure_category_focus() -> void: + for index: int in _category_tabs.size(): + var tab: Button = _category_tabs[index] + tab.focus_neighbor_left = tab.get_path_to( + _category_tabs[maxi(index - 1, 0)] + ) + tab.focus_neighbor_right = tab.get_path_to( + _category_tabs[mini(index + 1, _category_tabs.size() - 1)] + ) + + +func _animate_tab_entry() -> void: + for index: int in _category_tabs.size(): + (_category_tabs[index] as OrganizerTabType).animate_entrance( + float(index) * 0.025 + ) + + +func _settle_tabs_for_close() -> void: + for tab: OrganizerTabType in _category_tabs: + tab.settle_for_close() + + func _begin_category_transition() -> void: _cancel_category_tween() _category_generation += 1 diff --git a/ui/player_menu.gd b/ui/player_menu.gd index b7986f7..ebc6674 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -13,6 +13,7 @@ const FishInventoryType = preload("res://inventory/fish_inventory.gd") const InventoryNotepadType = preload( "res://ui/components/inventory_notepad.gd" ) +const OrganizerTabType = preload("res://ui/components/organizer_tab.gd") const FishPoolType = preload("res://fish/fish_pool.gd") const FishingSpotType = preload("res://fishing/fishing_spot.gd") const PlayerType = preload("res://player/player.gd") @@ -381,8 +382,8 @@ func _ready() -> void: _configure_navigation_focus() _apply_navigation_styles() _apply_inventory_styles() - # Full-page Inventory controls are later siblings and otherwise win GUI - # picking over the tab row even though the tabs draw above them. + # Keep the exposed tab hitboxes above the full-page roots. The individual + # main panels retain a higher z-index and cover the tab bodies below y=166. _inventory_sub_tabs.move_to_front() _bag_filter_tabs.move_to_front() _apply_cooler_control_styles() @@ -797,6 +798,7 @@ func _show_section_immediate(section: Section) -> void: _cooler_sub_tab.button_pressed = section == Section.COOLER _bag_sub_tab.button_pressed = section == Section.BAG _tackle_sub_tab.button_pressed = section == Section.TACKLE_BOX + _refresh_inventory_organizer_tabs() _logbook_tab.button_pressed = section == Section.LOGBOOK _mail_tab.button_pressed = section == Section.MAIL _profile_tab.button_pressed = section == Section.PROFILE @@ -812,6 +814,36 @@ func _is_inventory_section(section: Section) -> bool: return section in [Section.COOLER, Section.BAG, Section.TACKLE_BOX] +func _refresh_inventory_organizer_tabs() -> void: + for tab: OrganizerTabType in [ + _cooler_sub_tab, + _bag_sub_tab, + _tackle_sub_tab, + ]: + tab.refresh_state() + + +func _animate_inventory_tab_entry() -> void: + if not _is_inventory_section(_current_section): + return + var tabs: Array[OrganizerTabType] = [ + _cooler_sub_tab, + _bag_sub_tab, + _tackle_sub_tab, + ] + for index: int in tabs.size(): + tabs[index].animate_entrance(float(index) * 0.025) + + +func _settle_inventory_tabs_for_close() -> void: + for tab: OrganizerTabType in [ + _cooler_sub_tab, + _bag_sub_tab, + _tackle_sub_tab, + ]: + tab.settle_for_close() + + func _show_last_inventory_section() -> void: _show_section(_last_inventory_section) @@ -942,9 +974,6 @@ func _configure_active_page_focus() -> void: func _apply_inventory_styles() -> void: for button: Button in [ - _cooler_sub_tab, - _bag_sub_tab, - _tackle_sub_tab, _equipment_filter, _consumables_filter, _bait_filter, @@ -1556,6 +1585,7 @@ func _layout_cooler_selection_text(compact: bool) -> void: func _begin_menu_entry() -> void: _transitioning = true _set_shell_interactive(false) + _animate_inventory_tab_entry() set_process(true) var generation: int = _transition_generation var start_offset: float = ( @@ -1651,6 +1681,7 @@ func _begin_menu_exit(reason: CloseReason, restore_controls: bool) -> void: _cancel_presentation_tween() _transitioning = true _set_shell_interactive(false) + _settle_inventory_tabs_for_close() get_viewport().gui_release_focus() var generation: int = _transition_generation var closing_generation: int = _menu_generation @@ -1807,6 +1838,8 @@ func _begin_page_transition(section: Section) -> void: _cancel_page_tween() _page_transitioning = true _set_content_interactive(false) + if _is_inventory_section(_current_section) and not _is_inventory_section(section): + _settle_inventory_tabs_for_close() _set_navigation_target(section) var generation: int = _page_transition_generation _page_outgoing_root = _get_section_root(_current_section) @@ -1884,6 +1917,8 @@ func _swap_page(section: Section, generation: int) -> void: if generation != _page_transition_generation or not visible: return _show_section_immediate(section) + if _is_inventory_section(section): + _animate_inventory_tab_entry() if _page_hosts_shared: _page_incoming_root.position = ( _presentation_rest_position + Vector2.DOWN * 18.0 diff --git a/ui/player_menu.tscn b/ui/player_menu.tscn index 741391b..7de60dd 100644 --- a/ui/player_menu.tscn +++ b/ui/player_menu.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=24 format=3] +[gd_scene load_steps=25 format=3] [ext_resource type="Script" path="res://ui/player_menu.gd" id="1_menu"] [ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"] @@ -22,6 +22,7 @@ [ext_resource type="Script" path="res://ui/players_page.gd" id="20_players_page"] [ext_resource type="PackedScene" path="res://ui/logbook_page.tscn" id="21_logbook_page"] [ext_resource type="Script" path="res://ui/components/inventory_notepad.gd" id="22_inventory_notepad"] +[ext_resource type="Script" path="res://ui/components/organizer_tab.gd" id="23_organizer_tab"] [sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_collection"] @@ -65,38 +66,41 @@ mouse_filter = 2 [node name="InventorySubTabs" type="HBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"] unique_name_in_owner = true visible = false -z_index = 50 +z_index = 40 layout_mode = 0 offset_left = 54.0 -offset_top = 118.0 +offset_top = 126.0 offset_right = 504.0 -offset_bottom = 158.0 +offset_bottom = 184.0 mouse_filter = 1 theme_override_constants/separation = 8 [node name="CoolerSubTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/InventorySubTabs"] unique_name_in_owner = true -custom_minimum_size = Vector2(132, 40) +custom_minimum_size = Vector2(132, 58) layout_mode = 2 toggle_mode = true button_group = null text = "Cooler" +script = ExtResource("23_organizer_tab") [node name="BagSubTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/InventorySubTabs"] unique_name_in_owner = true -custom_minimum_size = Vector2(132, 40) +custom_minimum_size = Vector2(132, 58) layout_mode = 2 toggle_mode = true button_group = null text = "Bag" +script = ExtResource("23_organizer_tab") [node name="TackleSubTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/InventorySubTabs"] unique_name_in_owner = true -custom_minimum_size = Vector2(154, 40) +custom_minimum_size = Vector2(154, 58) layout_mode = 2 toggle_mode = true button_group = null text = "Tackle Box" +script = ExtResource("23_organizer_tab") [node name="CoolerPage" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"] unique_name_in_owner = true @@ -107,6 +111,7 @@ mouse_filter = 1 [node name="CoolerOuterWall" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage"] unique_name_in_owner = true +z_index = 50 layout_mode = 0 offset_left = 54.0 offset_top = 126.0 @@ -504,6 +509,7 @@ text = "Consumables" [node name="BagOuterWall" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage"] unique_name_in_owner = true +z_index = 50 layout_mode = 0 offset_left = 122.0 offset_top = 132.0 @@ -622,6 +628,7 @@ mouse_filter = 1 [node name="TackleMainPanel" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage"] unique_name_in_owner = true +z_index = 50 layout_mode = 0 offset_left = 54.0 offset_top = 166.0