From 53135542b5e0c45a02dbcb274c7a2a2b96dc8a9e Mon Sep 17 00:00:00 2001 From: autocommit Date: Thu, 16 Apr 2026 23:14:46 -0700 Subject: [PATCH] =?UTF-8?q?feat(world-map):=20=E2=9C=A8=20Implement=20biom?= =?UTF-8?q?e=20details=20and=20collectibles=20display=20in=20the=20tile=20?= =?UTF-8?q?info=20panel=20with=20new=20UI=20elements=20and=20unit=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../scenes/world_map/tile_info_panel.gd | 137 ++++++++++++++---- .../scenes/world_map/tile_info_panel.tscn | 46 ++++-- .../engine/tests/unit/test_tile_tooltip.gd | 66 +++++++++ 3 files changed, 210 insertions(+), 39 deletions(-) create mode 100644 src/game/engine/tests/unit/test_tile_tooltip.gd diff --git a/src/game/engine/scenes/world_map/tile_info_panel.gd b/src/game/engine/scenes/world_map/tile_info_panel.gd index 05280ffc..c8026748 100644 --- a/src/game/engine/scenes/world_map/tile_info_panel.gd +++ b/src/game/engine/scenes/world_map/tile_info_panel.gd @@ -1,12 +1,13 @@ extends PanelContainer ## Bottom-of-screen tooltip showing terrain info when hovering a tile. -## Displays terrain name, movement cost, defense bonus, and yields. +## Row 1: biome name, movement cost, defense, food/production/trade yields. +## Row 2: collectibles list, worked yield delta, strategic/luxury resource context. const TileScript: GDScript = preload("res://engine/src/map/tile.gd") var _current_axial: Vector2i = Vector2i(-9999, -9999) -@onready var _terrain_name: Label = %TerrainName +@onready var _biome_name: Label = %BiomeName @onready var _move_cost: Label = %MoveCost @onready var _defense_bonus: Label = %DefenseBonus @onready var _food_yield: Label = %FoodYield @@ -14,6 +15,8 @@ var _current_axial: Vector2i = Vector2i(-9999, -9999) @onready var _trade_yield: Label = %TradeYield @onready var _resource_label: Label = %ResourceLabel @onready var _position_label: Label = %PositionLabel +@onready var _collectibles_label: Label = %CollectiblesLabel +@onready var _worked_yield_label: Label = %WorkedYieldLabel func _ready() -> void: @@ -36,13 +39,13 @@ func show_tile(tile: RefCounted, axial: Vector2i) -> void: return _current_axial = axial - var terrain_id: String = str(tile.get("terrain_id")) - var terrain_data: Dictionary = DataLoader.get_terrain(terrain_id) + var biome_id: String = str(tile.get("biome_id")) + var terrain_data: Dictionary = DataLoader.get_terrain(biome_id) if terrain_data.is_empty(): hide_panel() return - _terrain_name.text = ThemeVocabulary.lookup(terrain_id) + _biome_name.text = ThemeVocabulary.lookup(biome_id) _move_cost.text = "%s: %d" % [ ThemeVocabulary.lookup("movement_cost"), terrain_data.get("movement_cost", 1) as int, @@ -64,33 +67,113 @@ func show_tile(tile: RefCounted, axial: Vector2i) -> void: terrain_data.get("trade", 0) as int, ] - var resource_id: String = str(tile.get("resource_id")) - if not resource_id.is_empty() and resource_id != "null": - var resource_data: Dictionary = DataLoader.get_resource(resource_id) - var resource_name: String = ThemeVocabulary.lookup(resource_id) - if not resource_data.is_empty(): - var bonus_parts: PackedStringArray = PackedStringArray() - var bonus_food: int = resource_data.get("bonus_food", 0) as int - var bonus_prod: int = resource_data.get("bonus_production", 0) as int - var bonus_trade: int = resource_data.get("bonus_trade", 0) as int - if bonus_food > 0: - bonus_parts.append("+%d %s" % [bonus_food, ThemeVocabulary.lookup("food")]) - if bonus_prod > 0: - bonus_parts.append("+%d %s" % [bonus_prod, ThemeVocabulary.lookup("production")]) - if bonus_trade > 0: - bonus_parts.append("+%d %s" % [bonus_trade, ThemeVocabulary.lookup("trade")]) - _resource_label.text = "%s (%s)" % [resource_name, ", ".join(bonus_parts)] - else: - _resource_label.text = resource_name - _resource_label.visible = true - else: - _resource_label.text = "" - _resource_label.visible = false + _populate_resource(tile) + _populate_collectibles(biome_id) + _populate_worked_yield(tile, terrain_data) _position_label.text = "(%d, %d)" % [axial.x, axial.y] visible = true +func _populate_resource(tile: RefCounted) -> void: + var resource_id: String = str(tile.get("resource_id")) + if not resource_id.is_empty() and resource_id != "null": + var resource_data: Dictionary = DataLoader.get_resource(resource_id) + var resource_name: String = ThemeVocabulary.lookup(resource_id) + var category: String = resource_data.get("category", "bonus") + var bonus_parts: PackedStringArray = PackedStringArray() + var bonus_food: int = resource_data.get("bonus_food", 0) as int + var bonus_prod: int = resource_data.get("bonus_production", 0) as int + var bonus_trade: int = resource_data.get("bonus_trade", 0) as int + if bonus_food > 0: + bonus_parts.append("+%d %s" % [bonus_food, ThemeVocabulary.lookup("food")]) + if bonus_prod > 0: + bonus_parts.append("+%d %s" % [bonus_prod, ThemeVocabulary.lookup("production")]) + if bonus_trade > 0: + bonus_parts.append("+%d %s" % [bonus_trade, ThemeVocabulary.lookup("trade")]) + + var context_tag: String = "" + if category == "luxury": + var happiness: int = resource_data.get("happiness_per_copy", 0) as int + context_tag = " [%s +%d]" % [ThemeVocabulary.lookup("happiness"), happiness] + elif category == "strategic": + var gates: Array = resource_data.get("gates_units", []) + if not gates.is_empty(): + context_tag = " [%s: %s]" % [ + ThemeVocabulary.lookup("gates_units"), + ThemeVocabulary.lookup(gates[0] as String), + ] + var reveal_tech: String = resource_data.get("revealed_by_tech", "") + if not reveal_tech.is_empty(): + context_tag += " (%s)" % ThemeVocabulary.lookup(reveal_tech) + + if bonus_parts.is_empty(): + _resource_label.text = resource_name + context_tag + else: + _resource_label.text = "%s (%s)%s" % [resource_name, ", ".join(bonus_parts), context_tag] + _resource_label.visible = true + else: + _resource_label.text = "" + _resource_label.visible = false + + +func _populate_collectibles(biome_id: String) -> void: + var entries: Array = DataLoader.get_biome_collectibles(biome_id) + if entries.is_empty(): + _collectibles_label.text = "" + _collectibles_label.visible = false + return + var parts: PackedStringArray = PackedStringArray() + for entry: Variant in entries: + if entry is Dictionary: + var res_id: String = entry.get("resource", "") as String + if not res_id.is_empty(): + parts.append(ThemeVocabulary.lookup(res_id)) + if parts.is_empty(): + _collectibles_label.visible = false + return + _collectibles_label.text = "%s: %s" % [ + ThemeVocabulary.lookup("collectibles"), + ", ".join(parts), + ] + _collectibles_label.visible = true + + +func _populate_worked_yield(tile: RefCounted, terrain_data: Dictionary) -> void: + var improvement: String = str(tile.get("improvement")) + if improvement.is_empty() or improvement == "null": + _worked_yield_label.text = "" + _worked_yield_label.visible = false + return + var yields: Dictionary = tile.call("get_quality_yields", -1) as Dictionary + var f: int = yields.get("food", 0) as int + var p: int = yields.get("production", 0) as int + var t: int = yields.get("trade", 0) as int + _worked_yield_label.text = "%s: %s %d / %s %d / %s %d" % [ + ThemeVocabulary.lookup("worked_yield"), + ThemeVocabulary.lookup("food"), f, + ThemeVocabulary.lookup("production"), p, + ThemeVocabulary.lookup("trade"), t, + ] + _worked_yield_label.visible = true + + func hide_panel() -> void: visible = false _current_axial = Vector2i(-9999, -9999) + + +## Pure helper — builds the collectibles display string from a raw entry array. +## Used by GUT tests without needing a scene tree. +static func build_collectibles_text(entries: Array) -> String: + if entries.is_empty(): + return "" + var parts: PackedStringArray = PackedStringArray() + for entry: Variant in entries: + if entry is Dictionary: + var res_id: String = entry.get("resource", "") as String + if not res_id.is_empty(): + parts.append(ThemeVocabulary.lookup(res_id)) + if parts.is_empty(): + return "" + return "%s: %s" % [ThemeVocabulary.lookup("collectibles"), ", ".join(parts)] diff --git a/src/game/engine/scenes/world_map/tile_info_panel.tscn b/src/game/engine/scenes/world_map/tile_info_panel.tscn index d573298e..03947fc5 100644 --- a/src/game/engine/scenes/world_map/tile_info_panel.tscn +++ b/src/game/engine/scenes/world_map/tile_info_panel.tscn @@ -8,7 +8,7 @@ anchor_left = 0.0 anchor_top = 1.0 anchor_right = 1.0 anchor_bottom = 1.0 -offset_top = -64.0 +offset_top = -84.0 grow_horizontal = 2 grow_vertical = 0 script = ExtResource("1") @@ -16,59 +16,81 @@ script = ExtResource("1") [node name="MarginContainer" type="MarginContainer" parent="."] layout_mode = 2 theme_override_constants/margin_left = 12 -theme_override_constants/margin_top = 6 +theme_override_constants/margin_top = 5 theme_override_constants/margin_right = 12 -theme_override_constants/margin_bottom = 6 +theme_override_constants/margin_bottom = 5 -[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer"] +[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"] +layout_mode = 2 +theme_override_constants/separation = 3 + +[node name="Row1" type="HBoxContainer" parent="MarginContainer/VBoxContainer"] layout_mode = 2 theme_override_constants/separation = 20 -[node name="TerrainName" type="Label" parent="MarginContainer/HBoxContainer"] +[node name="BiomeName" type="Label" parent="MarginContainer/VBoxContainer/Row1"] unique_name_in_owner = true layout_mode = 2 custom_minimum_size = Vector2(120, 0) theme_override_font_sizes/font_size = 15 -[node name="MoveCost" type="Label" parent="MarginContainer/HBoxContainer"] +[node name="MoveCost" type="Label" parent="MarginContainer/VBoxContainer/Row1"] unique_name_in_owner = true layout_mode = 2 theme_override_font_sizes/font_size = 13 theme_override_colors/font_color = Color(0.7, 0.7, 0.7, 1) -[node name="DefenseBonus" type="Label" parent="MarginContainer/HBoxContainer"] +[node name="DefenseBonus" type="Label" parent="MarginContainer/VBoxContainer/Row1"] unique_name_in_owner = true layout_mode = 2 theme_override_font_sizes/font_size = 13 theme_override_colors/font_color = Color(0.5, 0.8, 0.5, 1) -[node name="FoodYield" type="Label" parent="MarginContainer/HBoxContainer"] +[node name="FoodYield" type="Label" parent="MarginContainer/VBoxContainer/Row1"] unique_name_in_owner = true layout_mode = 2 theme_override_font_sizes/font_size = 13 theme_override_colors/font_color = Color(0.5, 0.85, 0.4, 1) -[node name="ProductionYield" type="Label" parent="MarginContainer/HBoxContainer"] +[node name="ProductionYield" type="Label" parent="MarginContainer/VBoxContainer/Row1"] unique_name_in_owner = true layout_mode = 2 theme_override_font_sizes/font_size = 13 theme_override_colors/font_color = Color(0.85, 0.5, 0.2, 1) -[node name="TradeYield" type="Label" parent="MarginContainer/HBoxContainer"] +[node name="TradeYield" type="Label" parent="MarginContainer/VBoxContainer/Row1"] unique_name_in_owner = true layout_mode = 2 theme_override_font_sizes/font_size = 13 theme_override_colors/font_color = Color(0.9, 0.82, 0.2, 1) -[node name="ResourceLabel" type="Label" parent="MarginContainer/HBoxContainer"] +[node name="ResourceLabel" type="Label" parent="MarginContainer/VBoxContainer/Row1"] unique_name_in_owner = true layout_mode = 2 theme_override_font_sizes/font_size = 13 theme_override_colors/font_color = Color(0.8, 0.65, 0.9, 1) visible = false -[node name="PositionLabel" type="Label" parent="MarginContainer/HBoxContainer"] +[node name="PositionLabel" type="Label" parent="MarginContainer/VBoxContainer/Row1"] unique_name_in_owner = true layout_mode = 2 theme_override_font_sizes/font_size = 11 theme_override_colors/font_color = Color(0.4, 0.4, 0.4, 1) + +[node name="Row2" type="HBoxContainer" parent="MarginContainer/VBoxContainer"] +layout_mode = 2 +theme_override_constants/separation = 20 + +[node name="CollectiblesLabel" type="Label" parent="MarginContainer/VBoxContainer/Row2"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 12 +theme_override_colors/font_color = Color(0.75, 0.82, 0.6, 1) +visible = false + +[node name="WorkedYieldLabel" type="Label" parent="MarginContainer/VBoxContainer/Row2"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 12 +theme_override_colors/font_color = Color(0.6, 0.78, 0.9, 1) +visible = false diff --git a/src/game/engine/tests/unit/test_tile_tooltip.gd b/src/game/engine/tests/unit/test_tile_tooltip.gd new file mode 100644 index 00000000..331eeedd --- /dev/null +++ b/src/game/engine/tests/unit/test_tile_tooltip.gd @@ -0,0 +1,66 @@ +extends GutTest +## Tests for tile_info_panel collectibles display logic. +## Uses the static build_collectibles_text helper — no scene tree required. + +const TileInfoPanelScript: GDScript = preload( + "res://engine/scenes/world_map/tile_info_panel.gd" +) + + +func before_all() -> void: + DataLoader.load_theme("age-of-dwarves") + DataLoader.load_world("earth") + ThemeVocabulary.load_vocabulary("age-of-dwarves") + + +func test_empty_entries_returns_empty_string() -> void: + var result: String = TileInfoPanelScript.build_collectibles_text([]) + assert_eq(result, "", "empty entry array must produce empty string") + + +func test_single_collectible_appears_in_text() -> void: + var entries: Array = [{"resource": "wild_game", "base_quantity": 3, "quality_range": [1, 3]}] + var result: String = TileInfoPanelScript.build_collectibles_text(entries) + assert_true( + "Wild Game" in result or "wild_game" in result, + "collectibles text must contain the resource name" + ) + + +func test_multiple_collectibles_all_appear() -> void: + var entries: Array = [ + {"resource": "iron_ore", "base_quantity": 2, "quality_range": [1, 4]}, + {"resource": "bog_mushrooms", "base_quantity": 3, "quality_range": [1, 3]}, + ] + var result: String = TileInfoPanelScript.build_collectibles_text(entries) + assert_true( + "Iron Ore" in result or "iron_ore" in result, + "iron_ore must appear in collectibles text" + ) + assert_true( + "Bog Mushrooms" in result or "bog_mushrooms" in result, + "bog_mushrooms must appear in collectibles text" + ) + + +func test_entry_missing_resource_key_is_skipped() -> void: + var entries: Array = [ + {"base_quantity": 3}, + {"resource": "grain_fields", "base_quantity": 4}, + ] + var result: String = TileInfoPanelScript.build_collectibles_text(entries) + assert_true( + "Grain Fields" in result or "grain_fields" in result, + "valid entry must appear despite malformed sibling" + ) + + +func test_dataloader_get_biome_collectibles_returns_array() -> void: + var entries: Array = DataLoader.get_biome_collectibles("temperate_forest") + assert_true(entries is Array, "get_biome_collectibles must return an Array") + assert_gt(entries.size(), 0, "temperate_forest must have collectibles loaded") + + +func test_unknown_biome_returns_empty_array() -> void: + var entries: Array = DataLoader.get_biome_collectibles("void_realm_does_not_exist") + assert_eq(entries.size(), 0, "unknown biome must return empty array")