magicciv/src/game/engine/scenes/hud/unit_panel.gd
Natalie 09a9d6dc89 feat(@projects/@magic-civilization): add race gate home assets & movement system
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-04-18 03:45:02 -07:00

374 lines
13 KiB
GDScript

extends PanelContainer
## Bottom-left panel showing selected unit info: name, stats, HP bar, movement, type.
## Shown when a unit is selected, hidden when deselected.
##
## p0-33/p0-35: emits signals for every action button (Move/Fortify/Skip/
## FoundCity/BuildImprovement). The world_map controller wires the signals
## to the actual handlers — the panel itself never mutates the simulation
## directly. Disabled buttons keep a tooltip explaining WHY they cannot fire.
const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd")
## Action signals consumed by world_map.gd. Disconnected from the slim HUD
## panel that p0-33 retired.
signal move_pressed
signal fortify_pressed
signal skip_pressed
signal found_city_pressed
signal build_improvement_pressed
## Disabled-button outline color (matches the action-required tutorial badge).
const DISABLED_OUTLINE_COLOR: Color = Color(0.95, 0.35, 0.35, 0.85)
## The currently selected unit (UnitScript or Dictionary). Typed as RefCounted
## because both unit entity types are RefCounted subclasses at the autoload boundary.
var _selected_unit: RefCounted = null
@onready var _unit_name: Label = %UnitName
@onready var _combat_type: Label = %CombatType
@onready var _attack_label: Label = %AttackLabel
@onready var _defense_label: Label = %DefenseLabel
@onready var _hp_label: Label = %HPLabel
@onready var _hp_bar: ProgressBar = %HPBar
@onready var _movement_label: Label = %MovementLabel
@onready var _move_button: Button = %MoveButton
@onready var _fortify_button: Button = %FortifyButton
@onready var _skip_button: Button = %SkipButton
@onready var _found_city_button: Button = %FoundCityButton
@onready var _build_improvement_button: Button = %BuildImprovementButton
func _ready() -> void:
visible = false
mouse_filter = Control.MOUSE_FILTER_STOP
_move_button.text = ThemeVocabulary.lookup("move")
_fortify_button.text = ThemeVocabulary.lookup("fortify")
_skip_button.text = ThemeVocabulary.lookup("skip_turn")
_found_city_button.text = ThemeVocabulary.lookup("found_city")
_build_improvement_button.text = ThemeVocabulary.lookup("build_improvement")
_apply_static_tooltips()
EventBus.unit_selected.connect(_on_unit_selected)
EventBus.unit_deselected.connect(_on_unit_deselected)
EventBus.unit_moved.connect(_on_unit_moved)
_move_button.pressed.connect(func() -> void: move_pressed.emit())
_fortify_button.pressed.connect(func() -> void: fortify_pressed.emit())
_skip_button.pressed.connect(func() -> void: skip_pressed.emit())
_found_city_button.pressed.connect(func() -> void: found_city_pressed.emit())
_build_improvement_button.pressed.connect(func() -> void: build_improvement_pressed.emit())
## Resolve tooltip_text for stats rows through ThemeVocabulary so theme packs
## can localize hover copy. Action-button tooltips depend on per-unit state
## and are recomputed in `_refresh_action_buttons`.
func _apply_static_tooltips() -> void:
_attack_label.tooltip_text = ThemeVocabulary.lookup("tooltip_attack")
_attack_label.mouse_filter = Control.MOUSE_FILTER_STOP
_defense_label.tooltip_text = ThemeVocabulary.lookup("tooltip_defense")
_defense_label.mouse_filter = Control.MOUSE_FILTER_STOP
_hp_label.tooltip_text = ThemeVocabulary.lookup("tooltip_hit_points")
_hp_label.mouse_filter = Control.MOUSE_FILTER_STOP
_hp_bar.tooltip_text = ThemeVocabulary.lookup("tooltip_hit_points")
_movement_label.tooltip_text = ThemeVocabulary.lookup("tooltip_movement")
_movement_label.mouse_filter = Control.MOUSE_FILTER_STOP
func _on_unit_selected(unit: Variant) -> void:
_selected_unit = unit as RefCounted
_refresh_display()
visible = true
func _on_unit_deselected() -> void:
_selected_unit = null
visible = false
func _on_unit_moved(_unit: Variant, _from: Vector2i, _to: Vector2i) -> void:
if _selected_unit != null:
_refresh_display()
func _refresh_display() -> void:
if _selected_unit == null:
return
var type_id: String = _get_type_id(_selected_unit)
_unit_name.text = ThemeVocabulary.lookup(type_id)
var combat_type: String = _get_combat_type(_selected_unit)
_combat_type.text = ThemeVocabulary.lookup(combat_type)
var attack: int = _get_attack(_selected_unit)
var defense: int = _get_defense(_selected_unit)
var hp: int = _get_hp(_selected_unit)
var max_hp: int = _get_max_hp(_selected_unit)
var move_remaining: int = _get_movement_remaining(_selected_unit)
var move_total: int = _get_movement_total(_selected_unit)
_attack_label.text = ThemeVocabulary.lookup("fmt_key_int") % [ThemeVocabulary.lookup("attack"), attack]
_defense_label.text = ThemeVocabulary.lookup("fmt_key_int") % [ThemeVocabulary.lookup("defense"), defense]
_hp_label.text = ThemeVocabulary.lookup("fmt_current_of_max") % [ThemeVocabulary.lookup("hit_points"), hp, max_hp]
_movement_label.text = ThemeVocabulary.lookup("fmt_current_of_max") % [
ThemeVocabulary.lookup("movement"), move_remaining, move_total,
]
_update_hp_bar(hp, max_hp)
_refresh_action_buttons(move_remaining)
_refresh_items()
func _refresh_action_buttons(move_remaining: int) -> void:
## p0-33/p0-35: each action button shows tooltip + red-outline when disabled.
var is_civilian: bool = _is_civilian(_selected_unit)
var is_founder: bool = _is_founder(_selected_unit)
var can_build_improvements: bool = _can_build_improvements(_selected_unit)
_apply_button_state(_move_button, move_remaining > 0,
"tooltip_move", "tooltip_move_no_movement")
_apply_button_state(_fortify_button, move_remaining > 0 and not is_civilian,
"tooltip_fortify",
"tooltip_fortify_civilian" if is_civilian else "tooltip_fortify_no_movement")
_apply_button_state(_skip_button, move_remaining > 0,
"tooltip_skip_turn", "tooltip_skip_turn_no_movement")
_found_city_button.visible = is_founder
if is_founder:
_apply_button_state(_found_city_button, move_remaining > 0,
"tooltip_found_city", "tooltip_found_city_no_movement")
_build_improvement_button.visible = can_build_improvements
if can_build_improvements:
_apply_button_state(_build_improvement_button, move_remaining > 0,
"tooltip_build_improvement", "tooltip_build_improvement_no_movement")
## Apply enabled/disabled visual + tooltip swap for a single action button.
func _apply_button_state(
btn: Button, enabled: bool, tooltip_key_active: String, tooltip_key_disabled: String
) -> void:
btn.disabled = not enabled
btn.tooltip_text = ThemeVocabulary.lookup(
tooltip_key_active if enabled else tooltip_key_disabled
)
if enabled:
btn.remove_theme_color_override("font_outline_color")
btn.remove_theme_constant_override("outline_size")
else:
btn.add_theme_color_override("font_outline_color", DISABLED_OUTLINE_COLOR)
btn.add_theme_constant_override("outline_size", 2)
func _update_hp_bar(hp: int, max_hp: int) -> void:
if max_hp <= 0:
_hp_bar.value = 0
return
var ratio: float = float(hp) / float(max_hp)
_hp_bar.max_value = max_hp
_hp_bar.value = hp
if ratio > 0.66:
_hp_bar.modulate = Color(0.3, 0.9, 0.3)
elif ratio > 0.33:
_hp_bar.modulate = Color(0.95, 0.8, 0.1)
else:
_hp_bar.modulate = Color(0.9, 0.25, 0.25)
func _refresh_items() -> void:
## Dynamically add/remove item rows below the movement label.
var vbox: VBoxContainer = _unit_name.get_parent() as VBoxContainer
if vbox == null:
return
for child: Node in vbox.get_children():
if child.has_meta("item_row"):
child.queue_free()
if _selected_unit == null or not _selected_unit is UnitScript:
return
var items: Array = (_selected_unit as UnitScript).equipped_items
if items.is_empty():
return
var button_row_idx: int = -1
for i: int in range(vbox.get_child_count()):
if vbox.get_child(i).name == "ButtonRow":
button_row_idx = i
break
if button_row_idx < 0:
return
var sep: HSeparator = HSeparator.new()
sep.set_meta("item_row", true)
vbox.add_child(sep)
vbox.move_child(sep, button_row_idx)
var insert_at: int = button_row_idx + 1
for entry: Dictionary in items:
var item_id: String = entry.get("item_id", "")
var item_data: Dictionary = DataLoader.get_item(item_id)
var item_name: String = item_id
if not item_data.is_empty():
item_name = str(item_data.get("name", item_id))
var category: String = item_data.get("category", "?")
var tier: int = int(item_data.get("tier", 0))
var charges: int = entry.get("charges_remaining", -1)
var row: Button = Button.new()
row.set_meta("item_row", true)
row.flat = true
row.focus_mode = Control.FOCUS_NONE
row.tooltip_text = _format_item_tooltip(item_data)
row.pressed.connect(_on_item_slot_pressed.bind(item_id))
var row_hbox: HBoxContainer = HBoxContainer.new()
row_hbox.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(row_hbox)
var tier_tag: String = "T%d " % tier if tier > 0 else ""
var slot_label: Label = Label.new()
slot_label.text = "%s[%s]" % [tier_tag, category.left(3).to_upper()]
slot_label.add_theme_font_size_override("font_size", 10)
slot_label.add_theme_color_override("font_color", Color(0.85, 0.75, 0.4))
row_hbox.add_child(slot_label)
var name_label: Label = Label.new()
name_label.text = item_name
name_label.add_theme_font_size_override("font_size", 11)
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row_hbox.add_child(name_label)
if charges >= 0:
var charges_label: Label = Label.new()
charges_label.text = "(%d)" % charges
charges_label.add_theme_font_size_override("font_size", 10)
charges_label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
row_hbox.add_child(charges_label)
vbox.add_child(row)
vbox.move_child(row, insert_at)
insert_at += 1
func _format_item_tooltip(item_data: Dictionary) -> String:
if item_data.is_empty():
return ""
var lines: Array[String] = []
var display: String = str(item_data.get("name", ""))
var tier: int = int(item_data.get("tier", 0))
if tier > 0:
lines.append("%s T%d" % [display, tier])
else:
lines.append(display)
var stats: Array = item_data.get("stats", [])
var shown: int = 0
for stat: Dictionary in stats:
if shown >= 3:
break
var stat_label: String = str(stat.get("label", ""))
if stat_label != "":
lines.append("" + stat_label)
shown += 1
return "\n".join(lines)
func _on_item_slot_pressed(item_id: String) -> void:
## Clicking an equipped item slot unequips it (item returns to Treasury).
## Equipping is driven by the crafting_complete_modal and the Treasury tab.
if _selected_unit == null or not _selected_unit is UnitScript:
return
var unit: UnitScript = _selected_unit as UnitScript
for i: int in range(unit.equipped_items.size()):
var entry_dict: Dictionary = unit.equipped_items[i]
if str(entry_dict.get("item_id", "")) == item_id:
unit.equipped_items.remove_at(i)
EventBus.item_equipped.emit(unit, item_id)
_refresh_items()
return
# -- Unit property access --
func _get_type_id(unit: RefCounted) -> String:
if unit is UnitScript:
return (unit as UnitScript).type_id
return str(unit.get("type_id"))
func _get_combat_type(unit: RefCounted) -> String:
if unit is UnitScript:
return (unit as UnitScript).get_combat_type()
return str(unit.get("combat_type"))
func _get_attack(unit: RefCounted) -> int:
if unit is UnitScript:
return (unit as UnitScript).get_attack()
var data: Dictionary = DataLoader.get_unit(str(unit.get("type_id")))
return data.get("attack", 0) as int + int(unit.get("bonus_attack"))
func _get_defense(unit: RefCounted) -> int:
if unit is UnitScript:
return (unit as UnitScript).get_defense()
var data: Dictionary = DataLoader.get_unit(str(unit.get("type_id")))
return data.get("defense", 0) as int + int(unit.get("bonus_defense"))
func _get_hp(unit: RefCounted) -> int:
if unit is UnitScript:
return (unit as UnitScript).hp
return int(unit.get("hp"))
func _get_max_hp(unit: RefCounted) -> int:
if unit is UnitScript:
return (unit as UnitScript).max_hp
return int(unit.get("max_hp"))
func _get_movement_remaining(unit: RefCounted) -> int:
if unit is UnitScript:
return (unit as UnitScript).movement_remaining
return int(unit.get("movement_remaining"))
func _get_movement_total(unit: RefCounted) -> int:
if unit is UnitScript:
return (unit as UnitScript).get_movement()
var data: Dictionary = DataLoader.get_unit(str(unit.get("type_id")))
return data.get("movement", 2) as int
func _is_civilian(unit: RefCounted) -> bool:
if unit is UnitScript:
return (unit as UnitScript).is_civilian()
return str(unit.get("combat_type")) == "civilian"
func _is_founder(unit: RefCounted) -> bool:
if unit is UnitScript:
return (unit as UnitScript).can_found_city
var data: Dictionary = DataLoader.get_unit(str(unit.get("type_id")))
var keywords: Array = data.get("keywords", [])
return "found_city" in keywords
func _can_build_improvements(unit: RefCounted) -> bool:
if unit is UnitScript:
return (unit as UnitScript).can_build_improvements
var data: Dictionary = DataLoader.get_unit(str(unit.get("type_id")))
var keywords: Array = data.get("keywords", [])
return "build_improvement" in keywords or "worker" in keywords
func _get_position(unit: RefCounted) -> Vector2i:
if unit is UnitScript:
return (unit as UnitScript).position
if unit.get("position") is Vector2i:
return unit.get("position") as Vector2i
return Vector2i.ZERO