374 lines
13 KiB
GDScript
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
|