From 969ed8f10f7b599ba440a54d4daee0e4b4627765 Mon Sep 17 00:00:00 2001 From: Voyager Date: Thu, 30 Jul 2026 23:16:28 -0400 Subject: [PATCH] Add categorized Logbook foundation --- tests/logbook_runtime_validation.gd | 98 ++++ tests/logbook_runtime_validation.gd.uid | 1 + tests/logbook_validation.gd | 142 ++++++ tests/logbook_validation.gd.uid | 1 + ui/logbook_catalog.gd | 72 +++ ui/logbook_catalog.gd.uid | 1 + ui/logbook_page.gd | 617 ++++++++++++++++++++++++ ui/logbook_page.gd.uid | 1 + ui/logbook_page.tscn | 12 + ui/mail_page.gd | 4 + ui/player_menu.gd | 103 ++-- ui/player_menu.tscn | 10 +- ui/profile_page.gd | 7 + 13 files changed, 1001 insertions(+), 68 deletions(-) create mode 100644 tests/logbook_runtime_validation.gd create mode 100644 tests/logbook_runtime_validation.gd.uid create mode 100644 tests/logbook_validation.gd create mode 100644 tests/logbook_validation.gd.uid create mode 100644 ui/logbook_catalog.gd create mode 100644 ui/logbook_catalog.gd.uid create mode 100644 ui/logbook_page.gd create mode 100644 ui/logbook_page.gd.uid create mode 100644 ui/logbook_page.tscn diff --git a/tests/logbook_runtime_validation.gd b/tests/logbook_runtime_validation.gd new file mode 100644 index 0000000..8131739 --- /dev/null +++ b/tests/logbook_runtime_validation.gd @@ -0,0 +1,98 @@ +extends SceneTree + +const MainScene = preload("res://main/main.tscn") + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var main := MainScene.instantiate() + root.add_child(main) + for _frame: int in 6: + await process_frame + assert(bool(main.get("_application_initialized"))) + var save_manager := main.get("_save_manager") as PlayerSaveManager + assert(save_manager.initialize_new_game()) + main.call("_enter_gameplay") + for _frame: int in 8: + await process_frame + assert(bool(main.get("_gameplay_started"))) + + var game_ui := main.get_node("%GameUI") as GameUI + var player_menu := game_ui.get_node("%PlayerMenu") as PlayerMenu + var logbook := player_menu.get_node("%CatalogLogbook") as LogbookPage + var backdrop := main.get_node("%PlayerMenuBackdrop") as ColorRect + var hotbar := game_ui.get_node("%Hotbar") as Control + + var shortcut := InputEventKey.new() + shortcut.pressed = true + shortcut.physical_keycode = KEY_L + assert(bool(player_menu.call("_handle_direct_page_shortcut", shortcut))) + await create_timer(2.2).timeout + assert(player_menu.visible) + assert(logbook.visible) + assert(backdrop.visible) + assert(not bool(game_ui.get("_player_menu_hotbar_visible"))) + assert(not hotbar.visible) + + var entry_buttons: Dictionary = logbook.get("_entry_buttons") + assert(entry_buttons.size() == 4) + await _capture_if_requested("-unknown") + logbook.call( + "_select_category", LogbookCatalog.Category.FRESH_WATER + ) + await create_timer(0.25).timeout + assert((logbook.get("_entry_buttons") as Dictionary).is_empty()) + await _capture_if_requested("-fresh") + logbook.call("_select_category", LogbookCatalog.Category.SALT_WATER) + await create_timer(0.25).timeout + assert((logbook.get("_entry_buttons") as Dictionary).is_empty()) + logbook.call("_select_category", LogbookCatalog.Category.OTHER) + await create_timer(0.25).timeout + assert((logbook.get("_entry_buttons") as Dictionary).size() == 4) + var player := main.get("_player") as Player + player.collection_log.mark_discovered(&"bluegill") + await process_frame + logbook.call("_select_entry", &"bluegill", &"bluegill") + await create_timer(0.2).timeout + await _capture_if_requested("-known") + + assert(bool(player_menu.call("_handle_direct_page_shortcut", shortcut))) + await create_timer(2.2).timeout + assert(not player_menu.visible) + + assert(bool(player_menu.call("_handle_direct_page_shortcut", shortcut))) + await create_timer(2.2).timeout + player_menu.call( + "_show_section_immediate", PlayerMenu.Section.PROFILE + ) + await process_frame + assert(not bool( + player_menu.call("_handle_direct_page_shortcut", shortcut) + )) + assert(player_menu.visible) + player_menu.close_menu(PlayerMenu.CloseReason.TEARDOWN, false) + + print( + "Logbook runtime validation: PASS at %dx%d" + % [root.size.x, root.size.y] + ) + main.queue_free() + await process_frame + quit() + + +func _capture_if_requested(suffix: String) -> void: + if not OS.has_environment("NETFISHING_LOGBOOK_CAPTURE"): + return + await process_frame + await process_frame + var image: Image = root.get_texture().get_image() + var path: String = ( + OS.get_environment("NETFISHING_LOGBOOK_CAPTURE") + + suffix + + ".png" + ) + assert(image.save_png(path) == OK) diff --git a/tests/logbook_runtime_validation.gd.uid b/tests/logbook_runtime_validation.gd.uid new file mode 100644 index 0000000..1d23e1d --- /dev/null +++ b/tests/logbook_runtime_validation.gd.uid @@ -0,0 +1 @@ +uid://ciii3u308pk4n diff --git a/tests/logbook_validation.gd b/tests/logbook_validation.gd new file mode 100644 index 0000000..443f0c1 --- /dev/null +++ b/tests/logbook_validation.gd @@ -0,0 +1,142 @@ +extends SceneTree + +const FishCatchType = preload("res://fish/fish_catch.gd") +const FishInventoryType = preload("res://inventory/fish_inventory.gd") +const FishPoolType = preload("res://fish/fish_pool.gd") +const CollectionLogType = preload("res://collection/collection_log.gd") +const LogbookPageScene = preload("res://ui/logbook_page.tscn") +const CatalogResource: FishPoolType = preload( + "res://fish/pools/test_water_pool.tres" +) + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + if OS.has_environment("NETFISHING_TEST_CREATE_ROOT"): + var data_root := PlayerDataRoot.new() + root.add_child(data_root) + var result: Dictionary = data_root.create_unbound_root( + OS.get_environment("NETFISHING_TEST_CREATE_ROOT") + ) + assert(bool(result.get("ok", false))) + data_root.queue_free() + print("Logbook portable test root: PASS") + quit() + return + _validate_catalog() + await _validate_page() + print("Logbook validation: PASS") + quit() + + +func _validate_catalog() -> void: + var expected_ids: Array[StringName] = [ + &"bluegill", &"bass", &"carp", &"sunfish", + ] + assert(LogbookCatalog.CATALOG_ORDER == expected_ids) + for index: int in expected_ids.size(): + var fish := CatalogResource.get_fish_by_id(expected_ids[index]) + assert(fish != null) + assert( + LogbookCatalog.category_for(fish) + == LogbookCatalog.Category.OTHER + ) + assert(LogbookCatalog.catalog_number(fish.id) == index + 1) + assert( + LogbookCatalog.empty_state(LogbookCatalog.Category.FRESH_WATER) + == "No freshwater catches cataloged yet." + ) + assert( + LogbookCatalog.empty_state(LogbookCatalog.Category.SALT_WATER) + == "No saltwater catches cataloged yet." + ) + + +func _validate_page() -> void: + var collection := CollectionLogType.new() + var inventory := FishInventoryType.new() + root.add_child(collection) + root.add_child(inventory) + var page := LogbookPageScene.instantiate() as LogbookPage + root.add_child(page) + await process_frame + page.setup(collection, inventory, CatalogResource) + page.activate() + await process_frame + + var entries: Dictionary = page.get("_entry_buttons") + assert(entries.size() == 4) + for entry_value: Variant in entries.values(): + var entry := entry_value as Button + assert(entry != null) + assert(entry.text.contains("???")) + assert(entry.tooltip_text.is_empty()) + assert(entry.icon == null) + assert(entry.accessibility_name == "Unknown catalog entry") + for fish in CatalogResource.candidates: + assert(not entry.text.contains(fish.display_name)) + + page.call( + "_select_category", LogbookCatalog.Category.FRESH_WATER + ) + await create_timer(0.25).timeout + assert((page.get("_entry_buttons") as Dictionary).is_empty()) + assert( + (page.get("_empty_state") as Label).text + == "No freshwater catches cataloged yet." + ) + page.call("_select_category", LogbookCatalog.Category.SALT_WATER) + await create_timer(0.25).timeout + assert((page.get("_entry_buttons") as Dictionary).is_empty()) + assert( + (page.get("_empty_state") as Label).text + == "No saltwater catches cataloged yet." + ) + page.call("_select_category", LogbookCatalog.Category.OTHER) + await create_timer(0.25).timeout + + var bluegill = CatalogResource.get_fish_by_id(&"bluegill") + collection.mark_discovered(&"bluegill") + await process_frame + entries = page.get("_entry_buttons") + var known := entries.get(&"bluegill") as Button + assert(known != null) + assert(known.text == bluegill.display_name) + assert(known.icon == bluegill.display_texture) + + var fish_catch := FishCatchType.new() + fish_catch.fish = bluegill + fish_catch.fish_id = bluegill.id + fish_catch.weight_lb = bluegill.get_minimum_weight() + fish_catch.display_scale = bluegill.get_display_scale_for_weight( + fish_catch.weight_lb + ) + fish_catch.sale_value = bluegill.get_sale_value_for_weight( + fish_catch.weight_lb + ) + fish_catch.ensure_identity() + inventory.add_catch(fish_catch) + page.call("_select_entry", &"bluegill", &"bluegill") + await process_frame + assert(_detail_text(page).contains("number owned\n1")) + assert(_detail_text(page).contains("number caught\nUnknown")) + assert(_detail_text(page).contains("body of water\nUnknown")) + assert(_detail_text(page).contains("catalog number\n#001")) + + inventory.remove_catch_by_id(fish_catch.catch_id) + await process_frame + assert(_detail_text(page).contains("number owned\n0")) + page.queue_free() + collection.queue_free() + inventory.queue_free() + + +func _detail_text(page: LogbookPage) -> String: + var values: PackedStringArray = [] + var detail_body := page.get("_detail_body") as VBoxContainer + for node: Node in detail_body.find_children("*", "Label", true, false): + values.append((node as Label).text) + return "\n".join(values) diff --git a/tests/logbook_validation.gd.uid b/tests/logbook_validation.gd.uid new file mode 100644 index 0000000..1f35fa1 --- /dev/null +++ b/tests/logbook_validation.gd.uid @@ -0,0 +1 @@ +uid://c8pt0ibr37rmw diff --git a/ui/logbook_catalog.gd b/ui/logbook_catalog.gd new file mode 100644 index 0000000..47dc174 --- /dev/null +++ b/ui/logbook_catalog.gd @@ -0,0 +1,72 @@ +class_name LogbookCatalog +extends RefCounted + +const FishDataType = preload("res://fish/fish_data.gd") + +enum Category { + FRESH_WATER, + SALT_WATER, + OTHER, +} + +# Catalog numbers are a presentation contract. Add future species deliberately; +# filtering and display order must not redefine an existing number. +const CATALOG_ORDER: Array[StringName] = [ + &"bluegill", + &"bass", + &"carp", + &"sunfish", +] + +# These maps remain explicit until fish resources gain authored habitat fields. +const FRESH_WATER_IDS: Array[StringName] = [] +const SALT_WATER_IDS: Array[StringName] = [] + + +static func category_for(fish: FishDataType) -> Category: + if fish == null: + return Category.OTHER + if fish.id in FRESH_WATER_IDS: + return Category.FRESH_WATER + if fish.id in SALT_WATER_IDS: + return Category.SALT_WATER + return Category.OTHER + + +static func category_label(category: Category) -> String: + match category: + Category.FRESH_WATER: + return "Fresh Water" + Category.SALT_WATER: + return "Salt Water" + _: + return "Other" + + +static func empty_state(category: Category) -> String: + match category: + Category.FRESH_WATER: + return "No freshwater catches cataloged yet." + Category.SALT_WATER: + return "No saltwater catches cataloged yet." + _: + return "No entries available." + + +static func catalog_number(fish_id: StringName) -> int: + var index: int = CATALOG_ORDER.find(fish_id) + return index + 1 if index >= 0 else 0 + + +static func ordered_species( + candidates: Array[FishDataType], +) -> Array[FishDataType]: + var by_id: Dictionary[StringName, FishDataType] = {} + for fish: FishDataType in candidates: + if fish != null and not fish.id.is_empty(): + by_id[fish.id] = fish + var ordered: Array[FishDataType] = [] + for fish_id: StringName in CATALOG_ORDER: + if by_id.has(fish_id): + ordered.append(by_id[fish_id]) + return ordered diff --git a/ui/logbook_catalog.gd.uid b/ui/logbook_catalog.gd.uid new file mode 100644 index 0000000..9a14366 --- /dev/null +++ b/ui/logbook_catalog.gd.uid @@ -0,0 +1 @@ +uid://5vvacdo53k0n diff --git a/ui/logbook_page.gd b/ui/logbook_page.gd new file mode 100644 index 0000000..be1dc00 --- /dev/null +++ b/ui/logbook_page.gd @@ -0,0 +1,617 @@ +class_name LogbookPage +extends Control + +const CollectionLogType = preload("res://collection/collection_log.gd") +const FishDataType = preload("res://fish/fish_data.gd") +const FishInventoryType = preload("res://inventory/fish_inventory.gd") +const FishPoolType = preload("res://fish/fish_pool.gd") + +const DETAIL_FADE_DURATION: float = 0.12 +const CATEGORY_FADE_DURATION: float = UIMotion.UTILITY_EXIT_DURATION +const INK := Color("251b10") +const MUTED_INK := Color("6d5b45") +const PAPER := Color("f2e6c9") +const PAPER_DARK := Color("e8d7b4") + +var _collection_log: CollectionLogType +var _inventory: FishInventoryType +var _catalog: FishPoolType +var _category: LogbookCatalog.Category = LogbookCatalog.Category.OTHER +var _selected_id: StringName +var _selected_entry_key: StringName +var _active: bool = false +var _interactive: bool = false +var _entry_buttons: Dictionary[StringName, Button] = {} +var _detail_generation: int = 0 +var _detail_tween: Tween +var _category_generation: int = 0 +var _category_tween: Tween + +var _category_tabs: Array[Button] = [] +var _catalog_scroll: ScrollContainer +var _catalog_grid: GridContainer +var _empty_state: Label +var _detail_body: VBoxContainer + + +func _ready() -> void: + _build_interface() + resized.connect(_update_responsive_layout) + _update_responsive_layout() + set_interactive(false) + + +func setup( + collection_log: CollectionLogType, + inventory: FishInventoryType, + catalog: FishPoolType, +) -> void: + _collection_log = collection_log + _inventory = inventory + _catalog = catalog + if not _collection_log.fish_discovered.is_connected( + _on_fish_discovered + ): + _collection_log.fish_discovered.connect(_on_fish_discovered) + if not _collection_log.collection_changed.is_connected( + _on_collection_changed + ): + _collection_log.collection_changed.connect(_on_collection_changed) + if not _inventory.catches_changed.is_connected(_on_inventory_changed): + _inventory.catches_changed.connect(_on_inventory_changed) + _refresh_catalog() + + +func activate() -> void: + _active = true + set_interactive(true) + _refresh_catalog() + + +func deactivate() -> void: + _active = false + set_interactive(false) + _cancel_detail_tween() + _cancel_category_tween() + + +func set_interactive(value: bool) -> void: + _interactive = value and _active + mouse_filter = ( + Control.MOUSE_FILTER_PASS + if _interactive + else Control.MOUSE_FILTER_IGNORE + ) + for tab: Button in _category_tabs: + tab.focus_mode = ( + Control.FOCUS_ALL if _interactive else Control.FOCUS_NONE + ) + tab.mouse_filter = ( + Control.MOUSE_FILTER_STOP + if _interactive + else Control.MOUSE_FILTER_IGNORE + ) + for entry: Button in _entry_buttons.values(): + entry.focus_mode = ( + Control.FOCUS_ALL if _interactive else Control.FOCUS_NONE + ) + entry.mouse_filter = ( + Control.MOUSE_FILTER_STOP + if _interactive + else Control.MOUSE_FILTER_IGNORE + ) + + +func focus_initial() -> void: + if not _active or not _interactive: + return + var selected := _entry_buttons.get(_selected_id) as Button + if selected != null: + selected.grab_focus() + elif not _entry_buttons.is_empty(): + var first := _entry_buttons.values().front() as Button + first.grab_focus() + else: + _category_tabs[int(_category)].grab_focus() + + +func _build_interface() -> void: + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var outer := MarginContainer.new() + outer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + outer.add_theme_constant_override("margin_left", 52) + outer.add_theme_constant_override("margin_top", 116) + outer.add_theme_constant_override("margin_right", 52) + 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 tabs := HBoxContainer.new() + tabs.add_theme_constant_override("separation", 8) + layout.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) + 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) + + var book := HBoxContainer.new() + book.size_flags_vertical = Control.SIZE_EXPAND_FILL + book.add_theme_constant_override("separation", 12) + layout.add_child(book) + + var left_page := PanelContainer.new() + left_page.size_flags_horizontal = Control.SIZE_EXPAND_FILL + left_page.size_flags_stretch_ratio = 1.12 + left_page.add_theme_stylebox_override( + "panel", _paper_style(PAPER, true) + ) + book.add_child(left_page) + var left_margin := MarginContainer.new() + _set_margins(left_margin, 18, 16, 18, 16) + left_page.add_child(left_margin) + var left_layout := VBoxContainer.new() + left_layout.add_theme_constant_override("separation", 8) + left_margin.add_child(left_layout) + var heading := _label("catch catalog", 25) + heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + left_layout.add_child(heading) + _empty_state = _label("", 18) + _empty_state.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _empty_state.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _empty_state.size_flags_vertical = Control.SIZE_EXPAND_FILL + left_layout.add_child(_empty_state) + _catalog_scroll = ScrollContainer.new() + _catalog_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + _catalog_scroll.horizontal_scroll_mode = ( + ScrollContainer.SCROLL_MODE_DISABLED + ) + left_layout.add_child(_catalog_scroll) + _catalog_grid = GridContainer.new() + _catalog_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _catalog_grid.add_theme_constant_override("h_separation", 8) + _catalog_grid.add_theme_constant_override("v_separation", 8) + _catalog_scroll.add_child(_catalog_grid) + + var gutter := ColorRect.new() + gutter.custom_minimum_size.x = 10 + gutter.color = Color("795f3f") + gutter.mouse_filter = Control.MOUSE_FILTER_IGNORE + book.add_child(gutter) + + var right_page := PanelContainer.new() + right_page.size_flags_horizontal = Control.SIZE_EXPAND_FILL + right_page.add_theme_stylebox_override( + "panel", _paper_style(PAPER_DARK, false) + ) + book.add_child(right_page) + var right_margin := MarginContainer.new() + _set_margins(right_margin, 22, 16, 22, 16) + right_page.add_child(right_margin) + var details_scroll := ScrollContainer.new() + details_scroll.horizontal_scroll_mode = ( + ScrollContainer.SCROLL_MODE_DISABLED + ) + right_margin.add_child(details_scroll) + _detail_body = VBoxContainer.new() + _detail_body.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _detail_body.add_theme_constant_override("separation", 8) + details_scroll.add_child(_detail_body) + _show_no_selection() + + +func _refresh_catalog() -> void: + if not is_node_ready(): + return + var preserved_selection: StringName = _selected_id + var preserved_entry_key: StringName = _selected_entry_key + _clear_entries() + var species: Array[FishDataType] = [] + if _catalog != null: + species = LogbookCatalog.ordered_species(_catalog.candidates) + for fish: FishDataType in species: + if LogbookCatalog.category_for(fish) != _category: + continue + var discovered: bool = _collection_log.has_discovered(fish.id) + var entry: Button = _make_entry(fish if discovered else null) + var selection_key: StringName = ( + fish.id if discovered else _unknown_selection_key(fish) + ) + _entry_buttons[selection_key] = entry + entry.pressed.connect( + _select_entry.bind( + selection_key, + fish.id if discovered else StringName(), + ) + ) + entry.focus_entered.connect( + _select_entry.bind( + selection_key, + fish.id if discovered else StringName(), + ) + ) + _catalog_grid.add_child(entry) + _empty_state.text = LogbookCatalog.empty_state(_category) + _empty_state.visible = _entry_buttons.is_empty() + _catalog_scroll.visible = not _entry_buttons.is_empty() + if ( + not preserved_selection.is_empty() + and _entry_buttons.has(preserved_selection) + ): + _selected_id = preserved_selection + _selected_entry_key = preserved_selection + elif _entry_buttons.has(preserved_entry_key): + _selected_id = StringName() + _selected_entry_key = preserved_entry_key + else: + _selected_id = StringName() + _selected_entry_key = StringName() + _refresh_selection_styles() + _refresh_details(false) + set_interactive(_interactive) + + +func _make_entry(fish: FishDataType) -> Button: + var entry := Button.new() + entry.custom_minimum_size = Vector2(220, 122) + entry.toggle_mode = true + entry.clip_text = true + entry.add_theme_font_override("font", UtilityPageStyle.TuffyFont) + entry.add_theme_font_size_override("font_size", 17) + entry.add_theme_color_override("font_color", INK) + entry.add_theme_color_override("font_focus_color", INK) + entry.add_theme_color_override("font_hover_color", INK) + entry.add_theme_stylebox_override("normal", _entry_style(false)) + entry.add_theme_stylebox_override("hover", _entry_style(true)) + entry.add_theme_stylebox_override("focus", _entry_style(true)) + entry.add_theme_stylebox_override("pressed", _entry_style(true)) + if fish == null: + entry.text = "?\n???\nCatch this creature\nto learn more." + entry.tooltip_text = "" + entry.accessibility_name = "Unknown catalog entry" + else: + entry.text = fish.display_name + entry.icon = fish.display_texture + entry.add_theme_constant_override("icon_max_width", 118) + entry.expand_icon = true + entry.vertical_icon_alignment = VERTICAL_ALIGNMENT_TOP + entry.tooltip_text = fish.display_name + entry.accessibility_name = fish.display_name + return entry + + +func _select_category(category: LogbookCatalog.Category) -> void: + if category == _category: + return + _category = category + _selected_id = StringName() + _selected_entry_key = StringName() + for index: int in _category_tabs.size(): + _category_tabs[index].button_pressed = index == int(_category) + _begin_category_transition() + + +func _begin_category_transition() -> void: + _cancel_category_tween() + _category_generation += 1 + var generation: int = _category_generation + var restore_interactive: bool = _interactive + set_interactive(false) + _category_tween = create_tween() + _category_tween.tween_method( + _set_category_content_alpha, + 1.0, + 0.0, + CATEGORY_FADE_DURATION * 0.5, + ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) + _category_tween.tween_callback( + func() -> void: + if generation != _category_generation: + return + _refresh_catalog() + _set_category_content_alpha(0.0) + ) + _category_tween.tween_method( + _set_category_content_alpha, + 0.0, + 1.0, + CATEGORY_FADE_DURATION * 0.5, + ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) + _category_tween.finished.connect( + func() -> void: + if generation != _category_generation: + return + _category_tween = null + _set_category_content_alpha(1.0) + set_interactive(restore_interactive) + focus_initial(), + CONNECT_ONE_SHOT, + ) + + +func _cancel_category_tween() -> void: + _category_generation += 1 + if _category_tween != null: + _category_tween.kill() + _category_tween = null + _set_category_content_alpha(1.0) + + +func _set_category_content_alpha(alpha: float) -> void: + if _catalog_scroll != null: + _catalog_scroll.modulate.a = alpha + if _empty_state != null: + _empty_state.modulate.a = alpha + if _detail_body != null: + _detail_body.modulate.a = alpha + + +func _select_entry(entry_key: StringName, fish_id: StringName) -> void: + if fish_id.is_empty(): + _selected_id = StringName() + _selected_entry_key = entry_key + _refresh_selection_styles() + _show_unknown() + return + if ( + _collection_log == null + or not _collection_log.has_discovered(fish_id) + ): + return + _selected_id = fish_id + _selected_entry_key = entry_key + _refresh_selection_styles() + _refresh_details(true) + + +func _refresh_details(animate: bool) -> void: + if _selected_id.is_empty(): + _show_no_selection() + return + if ( + _collection_log == null + or not _collection_log.has_discovered(_selected_id) + ): + _show_unknown() + return + var fish: FishDataType = ( + _catalog.get_fish_by_id(_selected_id) + if _catalog != null + else null + ) + if fish == null: + _show_no_selection() + return + var rebuild: Callable = _build_known_details.bind(fish) + if animate: + _crossfade_details(rebuild) + else: + rebuild.call() + + +func _build_known_details(fish: FishDataType) -> void: + _clear_details() + var catalog_number: int = LogbookCatalog.catalog_number(fish.id) + var title := _label(fish.display_name, 30) + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _detail_body.add_child(title) + if fish.display_texture != null: + var artwork := TextureRect.new() + artwork.custom_minimum_size = Vector2(0, 150) + artwork.texture = fish.display_texture + artwork.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + artwork.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + artwork.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + artwork.mouse_filter = Control.MOUSE_FILTER_IGNORE + _detail_body.add_child(artwork) + _add_detail_row( + "catalog number", + "#%03d" % catalog_number if catalog_number > 0 else "Unknown", + ) + _add_detail_row("rarity", fish.get_rarity_name()) + _add_detail_row("body of water", "Unknown") + _add_detail_row("time of day", _availability_text(fish)) + _add_detail_row( + "weight range", + "%.2f–%.2f lb" % [ + fish.get_minimum_weight(), + fish.get_maximum_weight(), + ], + ) + _add_detail_row( + "value range", + "%d–%d fish coin" % [ + fish.sell_value_min, + fish.sell_value_max, + ], + ) + _add_detail_row("number caught", "Unknown") + _add_detail_row( + "number owned", + str(_inventory.get_count(fish.id) if _inventory != null else 0), + ) + _add_detail_row("facts", "Unknown") + + +func _show_no_selection() -> void: + _clear_details() + var instruction := _label("Select an entry to read its catch record.", 21) + instruction.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + instruction.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + instruction.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + instruction.size_flags_vertical = Control.SIZE_EXPAND_FILL + _detail_body.add_child(instruction) + + +func _show_unknown() -> void: + _clear_details() + var unknown := _label("???", 34) + unknown.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _detail_body.add_child(unknown) + var instruction := _label("Catch this creature to learn more.", 20) + instruction.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + instruction.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _detail_body.add_child(instruction) + + +func _add_detail_row(row_name: String, value: String) -> void: + var row := VBoxContainer.new() + row.add_theme_constant_override("separation", 1) + var heading := _label(row_name, 15) + heading.add_theme_color_override("font_color", MUTED_INK) + var content := _label(value, 20) + content.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + row.add_child(heading) + row.add_child(content) + _detail_body.add_child(row) + + +func _availability_text(fish: FishDataType) -> String: + if fish.availability == null: + return "Unknown" + if fish.availability.allow_day and fish.availability.allow_night: + return "day and night" + if fish.availability.allow_day: + return "day" + if fish.availability.allow_night: + return "night" + return "Unknown" + + +func _crossfade_details(rebuild: Callable) -> void: + _cancel_detail_tween() + _detail_generation += 1 + var generation: int = _detail_generation + _detail_tween = create_tween() + _detail_tween.tween_property( + _detail_body, "modulate:a", 0.0, DETAIL_FADE_DURATION * 0.5 + ) + _detail_tween.tween_callback( + func() -> void: + if generation == _detail_generation: + rebuild.call() + ) + _detail_tween.tween_property( + _detail_body, "modulate:a", 1.0, DETAIL_FADE_DURATION * 0.5 + ) + _detail_tween.finished.connect( + func() -> void: + if generation == _detail_generation: + _detail_tween = null, + CONNECT_ONE_SHOT, + ) + + +func _cancel_detail_tween() -> void: + _detail_generation += 1 + if _detail_tween != null: + _detail_tween.kill() + _detail_tween = null + if _detail_body != null: + _detail_body.modulate.a = 1.0 + + +func _clear_entries() -> void: + for child: Node in _catalog_grid.get_children(): + _catalog_grid.remove_child(child) + child.queue_free() + _entry_buttons.clear() + + +func _clear_details() -> void: + for child: Node in _detail_body.get_children(): + _detail_body.remove_child(child) + child.queue_free() + + +func _refresh_selection_styles() -> void: + for fish_id: StringName in _entry_buttons: + _entry_buttons[fish_id].button_pressed = ( + fish_id == _selected_entry_key + ) + + +func _on_fish_discovered(fish_id: StringName) -> void: + if _catalog == null or _catalog.get_fish_by_id(fish_id) == null: + return + _selected_id = fish_id + _selected_entry_key = fish_id + _refresh_catalog() + + +func _on_collection_changed() -> void: + _refresh_catalog() + + +func _on_inventory_changed() -> void: + _refresh_details(false) + + +func _update_responsive_layout() -> void: + if _catalog_grid == null: + return + _catalog_grid.columns = 1 if size.x < 900.0 else 2 + + +func _unknown_selection_key(fish: FishDataType) -> StringName: + var number: int = LogbookCatalog.catalog_number(fish.id) + return StringName("unknown_%d" % number) + + +func _label(text: String, font_size: int) -> Label: + var label := Label.new() + label.text = text + label.add_theme_font_override("font", UtilityPageStyle.TuffyFont) + label.add_theme_font_size_override("font_size", font_size) + label.add_theme_color_override("font_color", INK) + return label + + +func _paper_style(color: Color, left: bool) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = color + style.border_color = Color("9b7a4f") + style.set_border_width_all(3) + style.corner_radius_top_left = 26 if left else 6 + style.corner_radius_bottom_left = 32 if left else 6 + style.corner_radius_top_right = 6 if left else 28 + style.corner_radius_bottom_right = 6 if left else 34 + return style + + +func _entry_style(selected: bool) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = Color("fff5dc") if selected else Color("eadcbd") + style.border_color = Color("247a8b") if selected else Color("9b7a4f") + style.set_border_width_all(3 if selected else 2) + style.set_corner_radius_all(8) + style.content_margin_left = 8 + style.content_margin_top = 8 + style.content_margin_right = 8 + style.content_margin_bottom = 8 + return style + + +func _set_margins( + container: MarginContainer, + left: int, + top: int, + right: int, + bottom: int, +) -> void: + container.add_theme_constant_override("margin_left", left) + container.add_theme_constant_override("margin_top", top) + container.add_theme_constant_override("margin_right", right) + container.add_theme_constant_override("margin_bottom", bottom) diff --git a/ui/logbook_page.gd.uid b/ui/logbook_page.gd.uid new file mode 100644 index 0000000..6234c9c --- /dev/null +++ b/ui/logbook_page.gd.uid @@ -0,0 +1 @@ +uid://oo5eek8i04xy diff --git a/ui/logbook_page.tscn b/ui/logbook_page.tscn new file mode 100644 index 0000000..f5c8c51 --- /dev/null +++ b/ui/logbook_page.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://ui/logbook_page.gd" id="1_logbook"] + +[node name="LogbookPage" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_logbook") diff --git a/ui/mail_page.gd b/ui/mail_page.gd index 430375b..8aaad70 100644 --- a/ui/mail_page.gd +++ b/ui/mail_page.gd @@ -98,6 +98,10 @@ func consume_escape() -> bool: return false +func is_composing_letter() -> bool: + return _compose != null and _compose.visible + + func set_interactive(value: bool) -> void: mouse_filter = Control.MOUSE_FILTER_PASS if value else Control.MOUSE_FILTER_IGNORE for node: Node in find_children("*", "BaseButton", true, false): diff --git a/ui/player_menu.gd b/ui/player_menu.gd index f0422fa..a57e5f0 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -182,6 +182,7 @@ enum CloseReason { @onready var _bag_sprite_detail_name: Label = %BagSpriteDetailName @onready var _bag_sprite_detail_data: Label = %BagSpriteDetailData @onready var _logbook_page: Control = %LogbookPage +@onready var _catalog_logbook: LogbookPage = %CatalogLogbook @onready var _mail_page: MailPage = %MailPage @onready var _profile_page: ProfilePage = %ProfilePage @onready var _players_page: PlayersPage = %PlayersPage @@ -376,10 +377,7 @@ func _ready() -> void: _fish_field.gui_input.connect(_on_fish_field_gui_input) _apply_bag_styles() _bag_item_field.gui_input.connect(_on_bag_field_gui_input) - _apply_logbook_styles() _apply_mail_notification_style() - _logbook_previous.pressed.connect(_request_logbook_page.bind(-1)) - _logbook_next.pressed.connect(_request_logbook_page.bind(1)) resized.connect(_update_shell_layout) _show_section_immediate(_current_section) call_deferred("_update_shell_layout") @@ -456,6 +454,7 @@ func setup( ) _profile_page.setup(network_profile_service) _players_page.setup(network_player_list) + _catalog_logbook.setup(collection_log, inventory, catalog) _network_mail_service.unread_count_changed.connect( _on_mail_unread_count_changed ) @@ -475,8 +474,6 @@ func setup( _fish_selection.clear() if not _inventory.catches_changed.is_connected(_on_inventory_changed): _inventory.catches_changed.connect(_on_inventory_changed) - if not _collection_log.fish_discovered.is_connected(_on_fish_discovered): - _collection_log.fish_discovered.connect(_on_fish_discovered) if not _wallet.balance_changed.is_connected(_on_wallet_balance_changed): _wallet.balance_changed.connect(_on_wallet_balance_changed) if not _fishing_spot.bite_activated.is_connected(_on_bite_activated): @@ -495,7 +492,7 @@ func setup( func _input(event: InputEvent) -> void: if event is InputEventKey and event.echo: return - if _handle_inventory_shortcut(event): + if _handle_direct_page_shortcut(event): get_viewport().set_input_as_handled() return if visible: @@ -513,24 +510,32 @@ func _input(event: InputEvent) -> void: get_viewport().set_input_as_handled() -func _handle_inventory_shortcut(event: InputEvent) -> bool: +func _handle_direct_page_shortcut(event: InputEvent) -> bool: var key_event := event as InputEventKey if ( key_event == null or not key_event.pressed or _is_text_input_active() + or _shortcut_blocked_by_modal_state() ): return false var physical_key: Key = key_event.physical_keycode var inventory_requested: bool = physical_key == KEY_I + var logbook_requested: bool = physical_key == KEY_L var tackle_requested: bool = ( physical_key == KEY_V or event.is_action_pressed("open_tacklebox") ) - if not inventory_requested and not tackle_requested: + if not inventory_requested and not tackle_requested and not logbook_requested: return false var requested_section: Section = ( - Section.TACKLE_BOX if tackle_requested else _last_inventory_section + Section.LOGBOOK + if logbook_requested + else ( + Section.TACKLE_BOX + if tackle_requested + else _last_inventory_section + ) ) if visible: if ( @@ -552,6 +557,20 @@ func _handle_inventory_shortcut(event: InputEvent) -> bool: return true +func _shortcut_blocked_by_modal_state() -> bool: + return ( + _sale_confirmation.visible + or ( + _current_section == Section.MAIL + and _mail_page.is_composing_letter() + ) + or ( + _current_section == Section.PROFILE + and _profile_page.has_modal_confirmation() + ) + ) + + func _is_text_input_active() -> bool: var focus_owner: Control = get_viewport().gui_get_focus_owner() return ( @@ -729,7 +748,6 @@ func _show_section_immediate(section: Section) -> void: _content_shell.visible = false _inventory_section.visible = false _bag_section.visible = false - _logbook_section.visible = section == Section.LOGBOOK _content_shell.set_background_visible(true) _header.visible = section != Section.COOLER _separator.visible = section != Section.COOLER @@ -745,6 +763,9 @@ func _show_section_immediate(section: Section) -> void: _refresh_tackle_box() if section != Section.LOGBOOK: _cancel_logbook_page_transition(true) + _catalog_logbook.deactivate() + else: + _catalog_logbook.activate() if section == Section.MAIL: _mail_page.activate() else: @@ -788,7 +809,7 @@ func _focus_current_section() -> void: elif _current_section == Section.TACKLE_BOX: _tackle_sub_tab.grab_focus() elif _current_section == Section.LOGBOOK: - _logbook_tab.grab_focus() + _catalog_logbook.focus_initial() elif _current_section == Section.MAIL: _mail_tab.grab_focus() elif _current_section == Section.PLAYERS: @@ -812,8 +833,6 @@ func _process(delta: float) -> void: _motion_elapsed, bag_motion_enabled, ) - if _current_section == Section.LOGBOOK: - _advance_logbook_page_controls(delta) func _configure_navigation_focus() -> void: @@ -899,7 +918,7 @@ func _configure_active_page_focus() -> void: Section.TACKLE_BOX: _bait_filter.grab_focus() Section.LOGBOOK: - _configure_logbook_focus() + _catalog_logbook.focus_initial() Section.MAIL: _mail_page.activate() Section.PROFILE: @@ -1268,7 +1287,6 @@ func _set_navigation_target(section: Section) -> void: func _update_shell_layout() -> void: if not is_node_ready(): return - _cancel_logbook_page_transition(true) var compact: bool = false _compact_layout = compact var reference_size := DESKTOP_REFERENCE_SIZE @@ -1468,45 +1486,6 @@ func _update_shell_layout() -> void: _players_page.set_anchors_preset(Control.PRESET_TOP_LEFT) _players_page.position = Vector2.ZERO _players_page.size = reference_size - _book_backing.position = ( - Vector2(14.0, 164.0) if compact else Vector2(58.0, 128.0) - ) - _book_backing.size = ( - Vector2(612.0, 232.0) if compact else Vector2(1164.0, 522.0) - ) - _right_page.visible = not compact - _book_gutter.visible = not compact - _left_entry_field.vertical = not compact - _right_entry_field.vertical = true - _left_heading.text = "field notes" if not compact else "field notes • page" - _left_heading.add_theme_font_size_override( - "font_size", - 17 if compact else 20, - ) - _right_heading.add_theme_font_size_override("font_size", 20) - _logbook_empty_state.position = ( - Vector2(120.0, 240.0) if compact else Vector2(402.0, 314.0) - ) - _logbook_empty_state.size = ( - Vector2(400.0, 52.0) if compact else Vector2(476.0, 52.0) - ) - _logbook_previous.apply_layout( - Vector2(58.0, 332.0) if compact else Vector2(116.0, 622.0), - Vector2(66.0, 62.0) if compact else Vector2(76.0, 72.0), - 0.18, - ) - _logbook_next.apply_layout( - Vector2(582.0, 332.0) if compact else Vector2(1164.0, 622.0), - Vector2(66.0, 62.0) if compact else Vector2(76.0, 72.0), - 0.18, - ) - _logbook_page_status.position = ( - Vector2(255.0, 308.0) if compact else Vector2(555.0, 608.0) - ) - _logbook_page_status.size = ( - Vector2(130.0, 46.0) if compact else Vector2(170.0, 56.0) - ) - _refresh_logbook_page(false) _sale_confirmation.position = ( Vector2(70.0, 128.0) if compact else Vector2(320.0, 200.0) ) @@ -1518,7 +1497,6 @@ func _update_shell_layout() -> void: 512.0 if compact else 420.0, ) _bag_grid.columns = 2 if compact else 3 - _logbook_grid.columns = 3 if compact else 4 _bag_list.custom_minimum_size.x = 300.0 if compact else 360.0 _bag_detail.custom_minimum_size.x = 176.0 if compact else 220.0 _content_stage.custom_minimum_size.y = 220.0 if compact else 260.0 @@ -1986,6 +1964,9 @@ func _set_content_interactive(interactive: bool) -> void: if interactive and _current_section == Section.LOGBOOK else Control.MOUSE_FILTER_IGNORE ) + _catalog_logbook.set_interactive( + interactive and _current_section == Section.LOGBOOK + ) _mail_page.set_interactive( interactive and _current_section == Section.MAIL ) @@ -2040,12 +2021,6 @@ func _set_content_interactive(interactive: bool) -> void: if bag_interactive else Control.MOUSE_FILTER_IGNORE ) - var logbook_interactive: bool = ( - interactive - and _current_section == Section.LOGBOOK - and not _logbook_page_transitioning - ) - _update_logbook_page_control_state(logbook_interactive) func _cancel_presentation_tween() -> void: @@ -2128,7 +2103,6 @@ func _refresh_all() -> void: _refresh_economy_summary() _refresh_inventory() _refresh_bag() - _refresh_logbook() func _on_bag_changed() -> void: @@ -2447,11 +2421,6 @@ func _on_inventory_changed() -> void: _refresh_economy_summary() _revalidate_confirmation() _refresh_inventory() - _refresh_logbook() - - -func _on_fish_discovered(_fish_id: StringName) -> void: - _refresh_logbook() func _on_wallet_balance_changed( diff --git a/ui/player_menu.tscn b/ui/player_menu.tscn index adf030a..8fce1c3 100644 --- a/ui/player_menu.tscn +++ b/ui/player_menu.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=22 format=3] +[gd_scene load_steps=23 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"] @@ -20,6 +20,7 @@ [ext_resource type="Script" path="res://ui/mail_page.gd" id="18_mail_page"] [ext_resource type="PackedScene" path="res://ui/profile_page.tscn" id="19_profile_page"] [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"] [sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_collection"] @@ -694,6 +695,9 @@ offset_right = 1280.0 offset_bottom = 720.0 mouse_filter = 1 +[node name="CatalogLogbook" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage" instance=ExtResource("21_logbook_page")] +unique_name_in_owner = true + [node name="MailPage" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"] unique_name_in_owner = true layout_mode = 0 @@ -721,6 +725,7 @@ script = ExtResource("20_players_page") [node name="BookBacking" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage"] unique_name_in_owner = true +visible = false layout_mode = 0 offset_left = 58.0 offset_top = 128.0 @@ -838,6 +843,7 @@ vertical_alignment = 1 [node name="LogbookPrevious" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage"] unique_name_in_owner = true +visible = false custom_minimum_size = Vector2(76, 72) layout_mode = 0 offset_left = 78.0 @@ -859,6 +865,7 @@ deformation_amplitude = 0.006 [node name="LogbookNext" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage"] unique_name_in_owner = true +visible = false custom_minimum_size = Vector2(76, 72) layout_mode = 0 offset_left = 1126.0 @@ -881,6 +888,7 @@ deformation_amplitude = 0.006 [node name="LogbookPageStatus" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage" instance=ExtResource("10_status_scene")] unique_name_in_owner = true +visible = false layout_mode = 0 offset_left = 555.0 offset_top = 524.0 diff --git a/ui/profile_page.gd b/ui/profile_page.gd index ccfaafe..c0fe7cb 100644 --- a/ui/profile_page.gd +++ b/ui/profile_page.gd @@ -100,6 +100,13 @@ func request_close_confirmation() -> bool: return true +func has_modal_confirmation() -> bool: + return ( + _discard_confirmation != null + and _discard_confirmation.visible + ) + + func _build_ui() -> void: var paper := PanelContainer.new() paper.set_anchors_preset(Control.PRESET_FULL_RECT)