feat(world-map): Implement biome details and collectibles display in the tile info panel with new UI elements and unit tests

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-16 23:14:46 -07:00
parent bf06b1a8d6
commit 53135542b5
3 changed files with 210 additions and 39 deletions

View file

@ -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)]

View file

@ -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

View file

@ -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")