From c956301194c6d23a146407538564849e377bac00 Mon Sep 17 00:00:00 2001 From: Voyager Date: Sun, 2 Aug 2026 09:57:05 -0400 Subject: [PATCH] Improve Logbook presentation --- tests/logbook_validation.gd | 158 +++++++++- ui/components/logbook_portrait.gd | 79 +++++ ui/components/logbook_portrait.gd.uid | 1 + ui/logbook_catalog.gd | 42 +++ ui/logbook_page.gd | 405 ++++++++++++++++++++------ 5 files changed, 578 insertions(+), 107 deletions(-) create mode 100644 ui/components/logbook_portrait.gd create mode 100644 ui/components/logbook_portrait.gd.uid diff --git a/tests/logbook_validation.gd b/tests/logbook_validation.gd index aa66108..c584f0e 100644 --- a/tests/logbook_validation.gd +++ b/tests/logbook_validation.gd @@ -5,6 +5,12 @@ 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 FishDataType = preload("res://fish/fish_data.gd") +const LogbookPortraitType = preload( + "res://ui/components/logbook_portrait.gd" +) +const InventoryNotepadType = preload( + "res://ui/components/inventory_notepad.gd" +) const LogbookPageScene = preload("res://ui/logbook_page.tscn") const CatalogResource: FishPoolType = preload( "res://fish/pools/fish_catalog.tres" @@ -48,6 +54,12 @@ func _validate_catalog() -> void: for index: int in expected_ids.size(): var fish := CatalogResource.get_fish_by_id(expected_ids[index]) assert(fish != null) + assert(LogbookCatalog.facts_for(fish.id) != "unknown") + assert(not LogbookCatalog.facts_for(fish.id).is_empty()) + assert( + LogbookCatalog.facts_for(fish.id) + == LogbookCatalog.facts_for(fish.id).to_lower() + ) var expected_category: WaterType.Type = ( WaterType.Type.SALT_WATER if expected_ids[index] in [&"bass", &"sunfish"] @@ -75,6 +87,14 @@ func _validate_page() -> void: page.setup(collection, inventory, CatalogResource) page.activate() await process_frame + assert(page.get("_category") == WaterType.Type.FRESH_WATER) + assert((page.get("_catalog_grid") as GridContainer).columns == 5) + var initial_detail_body := page.get("_detail_body") as VBoxContainer + assert(not initial_detail_body.get_parent() is ScrollContainer) + assert( + page.call("_entry_label_text", "flathead catfish") + == "flathead\ncatfish" + ) var shared_material: Material var silhouette_count: int = 0 @@ -101,9 +121,13 @@ func _validate_page() -> void: assert(entry.icon == null) assert(entry.accessibility_name == "Unknown catalog entry") assert(_has_visible_unknown_name(entry)) - var portrait := _find_unknown_portrait(entry) + var portrait: LogbookPortraitType = _find_portrait(entry) assert(portrait != null) - assert(portrait.texture == fish.display_texture) + assert(portrait.source_texture == fish.display_texture) + assert( + portrait.custom_minimum_size + == LogbookPortraitType.ENTRY_FRAME_SIZE + ) assert( portrait.expand_mode == TextureRect.EXPAND_IGNORE_SIZE @@ -154,10 +178,17 @@ func _validate_page() -> void: var entries: Dictionary = 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) - assert(_find_unknown_portrait(known) == null) + assert(known.custom_minimum_size.x == known.custom_minimum_size.y) + _validate_entry_bubble_styles(known) + assert(known.text.is_empty()) + assert(known.icon == null) + assert(_has_visible_name(known, bluegill.display_name)) + var known_portrait: LogbookPortraitType = _find_portrait(known) + assert(known_portrait != null) + assert(known_portrait.source_texture == bluegill.display_texture) + assert(known_portrait.material == null) assert(known.button_pressed) + _validate_handwritten_logbook_font(page) var fish_catch := FishCatchType.new() fish_catch.fish = bluegill @@ -174,9 +205,30 @@ func _validate_page() -> void: 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\nFresh Water")) + assert(_detail_text(page).contains("number caught\nunknown")) + assert(_detail_text(page).contains("body of water\nfresh water")) assert(_detail_text(page).contains("catalog number\n#001")) + assert(_detail_text(page).contains("fish facts\n")) + assert(_detail_text(page).contains(LogbookCatalog.facts_for(&"bluegill"))) + _validate_detail_field_fonts(page) + _validate_handwritten_numeric_scale(page) + var portrait_button := page.get("_detail_portrait_button") as Button + assert(portrait_button != null) + portrait_button.pressed.emit() + await process_frame + var portrait_overlay := page.get("_portrait_overlay") as Control + assert(portrait_overlay.visible) + var overlay_artwork := ( + page.get("_portrait_overlay_artwork") as LogbookPortraitType + ) + assert(overlay_artwork.source_texture == bluegill.display_texture) + assert( + overlay_artwork.custom_minimum_size.x <= 860.0 + and overlay_artwork.custom_minimum_size.y <= 480.0 + ) + (page.get("_portrait_overlay_backdrop") as Button).pressed.emit() + await process_frame + assert(not portrait_overlay.visible) inventory.remove_catch_by_id(fish_catch.catch_id) await process_frame @@ -194,26 +246,110 @@ func _detail_text(page: LogbookPage) -> String: return "\n".join(values) -func _find_unknown_portrait(entry: Button) -> TextureRect: +func _find_portrait(entry: Button) -> LogbookPortraitType: var pending: Array[Node] = [entry] while not pending.is_empty(): var node: Node = pending.pop_back() - if node is TextureRect: - return node as TextureRect + if node is LogbookPortraitType: + return node as LogbookPortraitType pending.append_array(node.get_children()) return null func _has_visible_unknown_name(entry: Button) -> bool: + return _has_visible_name(entry, "???") + + +func _has_visible_name(entry: Button, expected_name: String) -> bool: var pending: Array[Node] = [entry] while not pending.is_empty(): var node: Node = pending.pop_back() - if node is Label and (node as Label).text == "???": + if node is Label and (node as Label).text == expected_name: return true pending.append_array(node.get_children()) return false +func _validate_handwritten_logbook_font(page: LogbookPage) -> void: + var detail_body := page.get("_detail_body") as VBoxContainer + for node: Node in detail_body.find_children("*", "Label", true, false): + var label := node as Label + if label.text in [ + "fish facts", + "catalog number", + "rarity", + "body of water", + "time of day", + "weight range", + "value range", + "number caught", + "number owned", + ]: + continue + assert( + label.get_theme_font("font") + == InventoryNotepadType.HANDWRITTEN_FONT + ) + + +func _validate_detail_field_fonts(page: LogbookPage) -> void: + var detail_body := page.get("_detail_body") as VBoxContainer + var field_names: Array[String] = [ + "fish facts", + "catalog number", + "rarity", + "body of water", + "time of day", + "weight range", + "value range", + "number caught", + "number owned", + ] + for node: Node in detail_body.find_children("*", "Label", true, false): + var label := node as Label + if label.text not in field_names: + continue + assert(label.get_theme_font("font") == UtilityPageStyle.TuffyFont) + if label.text != "fish facts": + assert( + label.horizontal_alignment + == HORIZONTAL_ALIGNMENT_CENTER + ) + + +func _validate_handwritten_numeric_scale(page: LogbookPage) -> void: + var detail_body := page.get("_detail_body") as VBoxContainer + var text_value: Label + var numeric_value: Label + for node: Node in detail_body.find_children("*", "Label", true, false): + var label := node as Label + if label.text == "common": + text_value = label + elif label.text == "#001": + numeric_value = label + assert(text_value != null) + assert(numeric_value != null) + assert( + numeric_value.get_theme_font_size("font_size") + < text_value.get_theme_font_size("font_size") + ) + + +func _validate_entry_bubble_styles(entry: Button) -> void: + var normal := entry.get_theme_stylebox("normal") as StyleBoxFlat + var hover := entry.get_theme_stylebox("hover") as StyleBoxFlat + var pressed := entry.get_theme_stylebox("pressed") as StyleBoxFlat + assert(normal != null) + assert(hover != null) + assert(pressed != null) + assert(is_zero_approx(normal.bg_color.a)) + assert(hover.bg_color.a > 0.0) + assert(pressed.bg_color.a > 0.0) + assert(hover.border_width_left == 0) + assert(pressed.border_width_left == 0) + assert(hover.corner_radius_top_left == 51) + + func _texture_has_transparency(texture: Texture2D) -> bool: var image: Image = texture.get_image() return image != null and image.detect_alpha() != Image.ALPHA_NONE diff --git a/ui/components/logbook_portrait.gd b/ui/components/logbook_portrait.gd new file mode 100644 index 0000000..ef23922 --- /dev/null +++ b/ui/components/logbook_portrait.gd @@ -0,0 +1,79 @@ +class_name LogbookPortrait +extends TextureRect + +const ENTRY_FRAME_SIZE := Vector2(86.0, 40.0) +const DETAIL_FRAME_SIZE := Vector2(240.0, 132.0) + +static var _normalized_textures: Dictionary[String, Texture2D] = {} + +var source_texture: Texture2D + + +func _init() -> void: + expand_mode = TextureRect.EXPAND_IGNORE_SIZE + stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + mouse_filter = Control.MOUSE_FILTER_IGNORE + focus_mode = Control.FOCUS_NONE + size_flags_horizontal = Control.SIZE_SHRINK_CENTER + size_flags_vertical = Control.SIZE_SHRINK_CENTER + + +func configure( + portrait_texture: Texture2D, + frame_size: Vector2, + portrait_material: Material = null, +) -> void: + source_texture = portrait_texture + custom_minimum_size = frame_size + material = portrait_material + texture = _normalize_visible_bounds(portrait_texture) + + +func configure_fitted( + portrait_texture: Texture2D, + maximum_size: Vector2, + portrait_material: Material = null, +) -> void: + source_texture = portrait_texture + material = portrait_material + texture = _normalize_visible_bounds(portrait_texture) + if texture == null: + custom_minimum_size = Vector2.ZERO + return + var texture_size: Vector2 = texture.get_size() + if texture_size.x <= 0.0 or texture_size.y <= 0.0: + custom_minimum_size = Vector2.ZERO + return + var fit_scale: float = minf( + maximum_size.x / texture_size.x, + maximum_size.y / texture_size.y, + ) + custom_minimum_size = texture_size * fit_scale + + +static func _normalize_visible_bounds( + portrait_texture: Texture2D, +) -> Texture2D: + if portrait_texture == null: + return null + var cache_key: String = portrait_texture.resource_path + if cache_key.is_empty(): + cache_key = "instance_%d" % portrait_texture.get_instance_id() + var cached: Texture2D = _normalized_textures.get(cache_key) as Texture2D + if cached != null: + return cached + var image: Image = portrait_texture.get_image() + if image == null or image.is_empty(): + _normalized_textures[cache_key] = portrait_texture + return portrait_texture + var full_rect := Rect2i(Vector2i.ZERO, image.get_size()) + var visible_rect: Rect2i = image.get_used_rect() + if visible_rect.size == Vector2i.ZERO or visible_rect == full_rect: + _normalized_textures[cache_key] = portrait_texture + return portrait_texture + var normalized := AtlasTexture.new() + normalized.atlas = portrait_texture + normalized.region = Rect2(visible_rect) + _normalized_textures[cache_key] = normalized + return normalized diff --git a/ui/components/logbook_portrait.gd.uid b/ui/components/logbook_portrait.gd.uid new file mode 100644 index 0000000..280b7ec --- /dev/null +++ b/ui/components/logbook_portrait.gd.uid @@ -0,0 +1 @@ +uid://qkduawll2pm2 diff --git a/ui/logbook_catalog.gd b/ui/logbook_catalog.gd index dbd7aa6..ad1a9c9 100644 --- a/ui/logbook_catalog.gd +++ b/ui/logbook_catalog.gd @@ -16,6 +16,44 @@ const CATALOG_ORDER: Array[StringName] = [ &"catfish_white", ] +# Short presentation notes are kept here with the catalog contract rather +# than mixed into gameplay balance resources. Add facts deliberately when a +# species joins the catalog. +const FISH_FACTS: Dictionary[StringName, String] = { + &"bluegill": ( + "bluegill fathers sweep shallow bowls into the bottom for nests, " + + "then stand guard. whole neighborhoods of nests can crowd together." + ), + &"bass": ( + "striped bass split their lives between salt and fresh water, " + + "returning upriver to spawn. a long-lived striper may see three decades." + ), + &"carp": ( + "whisker-like barbels help a common carp investigate the bottom. " + + "nosing through sediment can cloud the water and loosen plants." + ), + &"sunfish": ( + "ocean sunfish are built more like enormous swimming heads than " + + "ordinary fish. they visit the surface and turn sideways to warm up." + ), + &"catfish_blue": ( + "a deep fork in the tail inspired the blue catfish's scientific " + + "name. it rests deep by daylight and becomes more active after dark." + ), + &"catfish_channel": ( + "a channel catfish can taste through skin all over its body, " + + "especially near its gills and whiskers. the male watches the eggs." + ), + &"catfish_flathead": ( + "flatheads favor hideouts made by logs, rocks, ledges, and other " + + "underwater structure. as they grow, fish dominate their menu." + ), + &"catfish_white": ( + "white catfish began along the atlantic coast between new york " + + "and florida. people later carried them far beyond that home range." + ), +} + static func category_for(fish: FishDataType) -> WaterType.Type: if fish == null: return WaterType.Type.OTHER @@ -41,6 +79,10 @@ static func catalog_number(fish_id: StringName) -> int: return index + 1 if index >= 0 else 0 +static func facts_for(fish_id: StringName) -> String: + return str(FISH_FACTS.get(fish_id, "unknown")) + + static func ordered_species( candidates: Array[FishDataType], ) -> Array[FishDataType]: diff --git a/ui/logbook_page.gd b/ui/logbook_page.gd index b2202b1..aa56cbf 100644 --- a/ui/logbook_page.gd +++ b/ui/logbook_page.gd @@ -9,9 +9,17 @@ const SILHOUETTE_SHADER: Shader = preload( "res://ui/logbook_silhouette.gdshader" ) const OrganizerTabType = preload("res://ui/components/organizer_tab.gd") +const InventoryNotepadType = preload( + "res://ui/components/inventory_notepad.gd" +) +const LogbookPortraitType = preload( + "res://ui/components/logbook_portrait.gd" +) const DETAIL_FADE_DURATION: float = 0.12 const CATEGORY_FADE_DURATION: float = UIMotion.UTILITY_EXIT_DURATION +const HANDWRITTEN_NUMERIC_SCALE: float = 0.8 +const PORTRAIT_VIEW_MAX_SIZE := Vector2(860.0, 480.0) const INK := Color("251b10") const MUTED_INK := Color("6d5b45") const PAPER := Color("f2e6c9") @@ -21,7 +29,7 @@ const LOGBOOK_TAB_LEFT_INSET: float = 34.0 var _collection_log: CollectionLogType var _inventory: FishInventoryType var _catalog: FishPoolType -var _category: WaterType.Type = WaterType.Type.OTHER +var _category: WaterType.Type = WaterType.Type.FRESH_WATER var _selected_id: StringName var _selected_entry_key: StringName var _active: bool = false @@ -38,14 +46,16 @@ var _catalog_scroll: ScrollContainer var _catalog_grid: GridContainer var _empty_state: Label var _detail_body: VBoxContainer +var _detail_portrait_button: Button +var _portrait_overlay: Control +var _portrait_overlay_backdrop: Button +var _portrait_overlay_artwork: LogbookPortraitType func _ready() -> void: _silhouette_material = ShaderMaterial.new() _silhouette_material.shader = SILHOUETTE_SHADER _build_interface() - resized.connect(_update_responsive_layout) - _update_responsive_layout() set_interactive(false) @@ -79,6 +89,7 @@ func activate() -> void: func deactivate() -> void: _active = false + _hide_portrait_overlay(false) set_interactive(false) _settle_tabs_for_close() _cancel_detail_tween() @@ -115,6 +126,9 @@ func set_interactive(value: bool) -> void: func focus_initial() -> void: if not _active or not _interactive: return + if _portrait_overlay != null and _portrait_overlay.visible: + _portrait_overlay_backdrop.grab_focus() + return var selected := _entry_buttons.get(_selected_id) as Button if selected != null: selected.grab_focus() @@ -199,6 +213,7 @@ func _build_interface() -> void: ) left_layout.add_child(_catalog_scroll) _catalog_grid = GridContainer.new() + _catalog_grid.columns = 5 _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) @@ -219,16 +234,13 @@ func _build_interface() -> void: 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) + _detail_body.size_flags_vertical = Control.SIZE_EXPAND_FILL + _detail_body.add_theme_constant_override("separation", 16) + right_margin.add_child(_detail_body) _show_no_selection() + _build_portrait_overlay() func _refresh_catalog() -> void: @@ -284,82 +296,65 @@ func _refresh_catalog() -> void: func _make_entry(fish: FishDataType, discovered: bool) -> Button: var entry := Button.new() - entry.custom_minimum_size = Vector2(220, 138) + entry.custom_minimum_size = Vector2(102, 102) + entry.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + entry.size_flags_vertical = Control.SIZE_SHRINK_CENTER 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("hover_pressed", _entry_style(true)) entry.add_theme_stylebox_override("focus", _entry_style(true)) entry.add_theme_stylebox_override("pressed", _entry_style(true)) + entry.text = "" if not discovered: - entry.text = "" entry.tooltip_text = "" entry.accessibility_name = "Unknown catalog entry" - _add_unknown_entry_content(entry, fish.display_texture) + _add_entry_content(entry, fish.display_texture, "???", true) 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 + _add_entry_content( + entry, fish.display_texture, fish.display_name, false + ) return entry -func _add_unknown_entry_content( +func _add_entry_content( entry: Button, portrait: Texture2D, + entry_name: String, + unknown: bool, ) -> void: var content_margin := MarginContainer.new() content_margin.mouse_filter = Control.MOUSE_FILTER_IGNORE content_margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - content_margin.add_theme_constant_override("margin_left", 7) - content_margin.add_theme_constant_override("margin_top", 7) - content_margin.add_theme_constant_override("margin_right", 7) - content_margin.add_theme_constant_override("margin_bottom", 7) + content_margin.add_theme_constant_override("margin_left", 5) + content_margin.add_theme_constant_override("margin_top", 8) + content_margin.add_theme_constant_override("margin_right", 5) + content_margin.add_theme_constant_override("margin_bottom", 2) entry.add_child(content_margin) var content := VBoxContainer.new() content.mouse_filter = Control.MOUSE_FILTER_IGNORE - content.add_theme_constant_override("separation", 2) + content.alignment = BoxContainer.ALIGNMENT_CENTER + content.add_theme_constant_override("separation", 4) content_margin.add_child(content) - var portrait_view := TextureRect.new() - portrait_view.custom_minimum_size = Vector2(0.0, 64.0) - portrait_view.mouse_filter = Control.MOUSE_FILTER_IGNORE - portrait_view.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST - portrait_view.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - portrait_view.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - portrait_view.texture = portrait - portrait_view.material = _silhouette_material + var portrait_view := LogbookPortraitType.new() + portrait_view.configure( + portrait, + LogbookPortraitType.ENTRY_FRAME_SIZE, + _silhouette_material if unknown else null, + ) content.add_child(portrait_view) - var unknown_name := Label.new() - unknown_name.mouse_filter = Control.MOUSE_FILTER_IGNORE - unknown_name.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - unknown_name.text = "???" - unknown_name.add_theme_font_override("font", UtilityPageStyle.TuffyFont) - unknown_name.add_theme_font_size_override("font_size", 18) - unknown_name.add_theme_color_override("font_color", INK) - content.add_child(unknown_name) - - var discovery_hint := Label.new() - discovery_hint.mouse_filter = Control.MOUSE_FILTER_IGNORE - discovery_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - discovery_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - discovery_hint.text = "Catch this creature to learn more." - discovery_hint.add_theme_font_override( - "font", UtilityPageStyle.TuffyFont - ) - discovery_hint.add_theme_font_size_override("font_size", 12) - discovery_hint.add_theme_color_override("font_color", MUTED_INK) - content.add_child(discovery_hint) + var name_label := _label(_entry_label_text(entry_name), 16) + name_label.custom_minimum_size.y = 38.0 + name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + content.add_child(name_label) func _select_category(category: WaterType.Type) -> void: @@ -499,45 +494,115 @@ func _refresh_details(animate: bool) -> void: 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) + var summary_columns := HBoxContainer.new() + summary_columns.size_flags_horizontal = Control.SIZE_EXPAND_FILL + summary_columns.add_theme_constant_override("separation", 24) + _detail_body.add_child(summary_columns) + + var portrait_column := VBoxContainer.new() + portrait_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL + portrait_column.size_flags_stretch_ratio = 1.0 + portrait_column.add_theme_constant_override("separation", 4) + summary_columns.add_child(portrait_column) + var title := _label(fish.display_name, 26) title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _detail_body.add_child(title) + portrait_column.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) + _detail_portrait_button = Button.new() + _detail_portrait_button.custom_minimum_size = ( + LogbookPortraitType.DETAIL_FRAME_SIZE + ) + _detail_portrait_button.size_flags_horizontal = ( + Control.SIZE_SHRINK_CENTER + ) + _detail_portrait_button.tooltip_text = "View larger artwork" + _detail_portrait_button.accessibility_name = ( + "View larger artwork for %s" % fish.display_name + ) + _detail_portrait_button.add_theme_stylebox_override( + "normal", _portrait_button_style(false) + ) + _detail_portrait_button.add_theme_stylebox_override( + "hover", _portrait_button_style(true) + ) + _detail_portrait_button.add_theme_stylebox_override( + "focus", _portrait_button_style(true) + ) + _detail_portrait_button.add_theme_stylebox_override( + "pressed", _portrait_button_style(true) + ) + _detail_portrait_button.pressed.connect( + _show_portrait_overlay.bind(fish.display_texture) + ) + portrait_column.add_child(_detail_portrait_button) + var artwork_center := CenterContainer.new() + artwork_center.mouse_filter = Control.MOUSE_FILTER_IGNORE + artwork_center.set_anchors_and_offsets_preset( + Control.PRESET_FULL_RECT + ) + _detail_portrait_button.add_child(artwork_center) + var artwork := LogbookPortraitType.new() + artwork.configure( + fish.display_texture, + LogbookPortraitType.DETAIL_FRAME_SIZE, + ) + artwork_center.add_child(artwork) + + var facts_column := VBoxContainer.new() + facts_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL + facts_column.size_flags_stretch_ratio = 1.0 + facts_column.add_theme_constant_override("separation", 6) + summary_columns.add_child(facts_column) + var facts_heading := _field_label("fish facts", 16) + facts_column.add_child(facts_heading) + var facts := _label(LogbookCatalog.facts_for(fish.id), 16) + facts.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + facts.size_flags_horizontal = Control.SIZE_EXPAND_FILL + facts_column.add_child(facts) + + var stats_columns := HBoxContainer.new() + stats_columns.size_flags_horizontal = Control.SIZE_EXPAND_FILL + stats_columns.size_flags_vertical = Control.SIZE_EXPAND_FILL + stats_columns.add_theme_constant_override("separation", 28) + _detail_body.add_child(stats_columns) + var left_stats := _make_stats_column() + var right_stats := _make_stats_column() + stats_columns.add_child(left_stats) + stats_columns.add_child(right_stats) _add_detail_row( + left_stats, "catalog number", - "#%03d" % catalog_number if catalog_number > 0 else "Unknown", + "#%03d" % catalog_number if catalog_number > 0 else "unknown", ) - _add_detail_row("rarity", fish.get_rarity_name()) - _add_detail_row("body of water", WaterType.label(fish.get_primary_water_type())) - _add_detail_row("time of day", _availability_text(fish)) _add_detail_row( + left_stats, + "body of water", + WaterType.label(fish.get_primary_water_type()).to_lower(), + ) + _add_detail_row( + left_stats, "weight range", "%.2f–%.2f lb" % [ fish.get_minimum_weight(), fish.get_maximum_weight(), ], ) + _add_detail_row(left_stats, "number caught", "unknown") + _add_detail_row(right_stats, "rarity", fish.get_rarity_name().to_lower()) + _add_detail_row(right_stats, "time of day", _availability_text(fish)) _add_detail_row( + right_stats, "value range", "%d–%d fish coin" % [ fish.sell_value_min, fish.sell_value_max, ], ) - _add_detail_row("number caught", "Unknown") _add_detail_row( + right_stats, "number owned", str(_inventory.get_count(fish.id) if _inventory != null else 0), ) - _add_detail_row("facts", "Unknown") func _show_no_selection() -> void: @@ -561,28 +626,42 @@ func _show_unknown() -> void: _detail_body.add_child(instruction) -func _add_detail_row(row_name: String, value: String) -> void: +func _add_detail_row( + parent: VBoxContainer, + row_name: String, + value: String, +) -> void: var row := VBoxContainer.new() - row.add_theme_constant_override("separation", 1) - var heading := _label(row_name, 15) + row.add_theme_constant_override("separation", 2) + var heading := _field_label(row_name, 14) + heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER heading.add_theme_color_override("font_color", MUTED_INK) - var content := _label(value, 20) + var content := _handwritten_value_label(value.to_lower(), 16) + content.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER content.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART row.add_child(heading) row.add_child(content) - _detail_body.add_child(row) + parent.add_child(row) + + +func _make_stats_column() -> VBoxContainer: + var column := VBoxContainer.new() + column.size_flags_horizontal = Control.SIZE_EXPAND_FILL + column.size_flags_stretch_ratio = 1.0 + column.add_theme_constant_override("separation", 12) + return column func _availability_text(fish: FishDataType) -> String: if fish.availability == null: - return "Unknown" + 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" + return "unknown" func _crossfade_details(rebuild: Callable) -> void: @@ -626,6 +705,7 @@ func _clear_entries() -> void: func _clear_details() -> void: + _detail_portrait_button = null for child: Node in _detail_body.get_children(): _detail_body.remove_child(child) child.queue_free() @@ -658,18 +738,23 @@ 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", InventoryNotepadType.HANDWRITTEN_FONT + ) + label.add_theme_font_size_override("font_size", font_size) + label.add_theme_color_override("font_color", INK) + return label + + +func _field_label(text: String, font_size: int) -> Label: var label := Label.new() label.text = text label.add_theme_font_override("font", UtilityPageStyle.TuffyFont) @@ -678,6 +763,33 @@ func _label(text: String, font_size: int) -> Label: return label +func _handwritten_value_label(text: String, font_size: int) -> Label: + var adjusted_size: int = font_size + if _contains_ascii_digit(text): + adjusted_size = roundi( + float(font_size) * HANDWRITTEN_NUMERIC_SCALE + ) + return _label(text, adjusted_size) + + +func _contains_ascii_digit(text: String) -> bool: + for codepoint: int in text.to_utf32_buffer(): + if codepoint >= 48 and codepoint <= 57: + return true + return false + + +func _entry_label_text(entry_name: String) -> String: + var split_index: int = entry_name.find(" ") + if split_index < 0: + return entry_name + return ( + entry_name.substr(0, split_index) + + "\n" + + entry_name.substr(split_index + 1) + ) + + func _paper_style(color: Color, left: bool) -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = color @@ -690,16 +802,117 @@ func _paper_style(color: Color, left: bool) -> StyleBoxFlat: return style -func _entry_style(selected: bool) -> StyleBoxFlat: +func _build_portrait_overlay() -> void: + _portrait_overlay = Control.new() + _portrait_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _portrait_overlay.z_index = 500 + _portrait_overlay.visible = false + _portrait_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_portrait_overlay) + + _portrait_overlay_backdrop = Button.new() + _portrait_overlay_backdrop.set_anchors_and_offsets_preset( + Control.PRESET_FULL_RECT + ) + _portrait_overlay_backdrop.text = "" + _portrait_overlay_backdrop.tooltip_text = "Return to logbook" + _portrait_overlay_backdrop.accessibility_name = "Return to logbook" + _portrait_overlay_backdrop.focus_neighbor_top = NodePath(".") + _portrait_overlay_backdrop.focus_neighbor_bottom = NodePath(".") + _portrait_overlay_backdrop.focus_neighbor_left = NodePath(".") + _portrait_overlay_backdrop.focus_neighbor_right = NodePath(".") + _portrait_overlay_backdrop.focus_next = NodePath(".") + _portrait_overlay_backdrop.focus_previous = NodePath(".") + var transparent_style := StyleBoxEmpty.new() + for state: StringName in [ + &"normal", &"hover", &"focus", &"pressed", &"disabled", + ]: + _portrait_overlay_backdrop.add_theme_stylebox_override( + state, transparent_style + ) + _portrait_overlay_backdrop.pressed.connect(_hide_portrait_overlay) + _portrait_overlay_backdrop.gui_input.connect( + _on_portrait_overlay_backdrop_input + ) + _portrait_overlay.add_child(_portrait_overlay_backdrop) + + var center := CenterContainer.new() + center.mouse_filter = Control.MOUSE_FILTER_IGNORE + center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _portrait_overlay.add_child(center) + var card := PanelContainer.new() + card.mouse_filter = Control.MOUSE_FILTER_STOP + card.add_theme_stylebox_override("panel", _portrait_view_style()) + center.add_child(card) + var margin := MarginContainer.new() + _set_margins(margin, 22, 22, 22, 22) + card.add_child(margin) + _portrait_overlay_artwork = LogbookPortraitType.new() + margin.add_child(_portrait_overlay_artwork) + + +func _show_portrait_overlay(portrait_texture: Texture2D) -> void: + if portrait_texture == null or _portrait_overlay == null: + return + _portrait_overlay_artwork.configure_fitted( + portrait_texture, + PORTRAIT_VIEW_MAX_SIZE, + ) + _portrait_overlay.visible = true + _portrait_overlay.mouse_filter = Control.MOUSE_FILTER_STOP + _portrait_overlay_backdrop.grab_focus() + + +func _hide_portrait_overlay(restore_focus: bool = true) -> void: + if _portrait_overlay == null or not _portrait_overlay.visible: + return + _portrait_overlay.visible = false + _portrait_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE + if ( + restore_focus + and _detail_portrait_button != null + and is_instance_valid(_detail_portrait_button) + and _active + and _interactive + ): + _detail_portrait_button.grab_focus() + + +func _on_portrait_overlay_backdrop_input(event: InputEvent) -> void: + if event.is_action_pressed("ui_cancel"): + _hide_portrait_overlay() + get_viewport().set_input_as_handled() + + +func _portrait_button_style(highlighted: 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 + style.bg_color = ( + Color(1.0, 0.96, 0.86, 0.48) + if highlighted + else Color.TRANSPARENT + ) + style.set_border_width_all(0) + style.set_corner_radius_all(12) + return style + + +func _portrait_view_style() -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = Color("fff5dc") + style.set_border_width_all(0) + style.set_corner_radius_all(18) + return style + + +func _entry_style(highlighted: bool) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = Color("fff5dc") if highlighted else Color.TRANSPARENT + style.set_border_width_all(0) + style.set_corner_radius_all(51) + style.content_margin_left = 5 + style.content_margin_top = 5 + style.content_margin_right = 5 + style.content_margin_bottom = 5 return style