Add portrait battle unit list rows

This commit is contained in:
2026-06-20 00:29:07 +09:00
parent 3971ff07e6
commit 84cdbd1ea5
3 changed files with 118 additions and 16 deletions

View File

@@ -11,6 +11,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr
- Move, attack, wait, end turn. - Move, attack, wait, end turn.
- Enemy AI with movement, damage-aware physical attacks, multiple MP tactic choices, and scenario target priorities that events can retune mid-battle. - Enemy AI with movement, damage-aware physical attacks, multiple MP tactic choices, and scenario target priorities that events can retune mid-battle.
- Hover tile and unit info with class, movement type, AGI, movement, range, and equipped gear. - Hover tile and unit info with class, movement type, AGI, movement, range, and equipped gear.
- Active battle unit list opens from the top toolbar with ally/enemy tabs, portrait or sprite rows, compact HP/status/position badges, and hover combat details.
- Enemy threat range overlay with tile-level threat source, physical damage hints, and hostile tactic hints. - Enemy threat range overlay with tile-level threat source, physical damage hints, and hostile tactic hints.
- Damage forecast for selected-unit attacks. - Damage forecast for selected-unit attacks.
- Terrain defense and avoid affect physical attack damage and hit chance. - Terrain defense and avoid affect physical attack damage and hit chance.

View File

@@ -134,7 +134,9 @@ const HUD_COMMAND_BUTTON_SIZE := Vector2(72, 42)
const HUD_COMMAND_GRID_COLUMNS := 4 const HUD_COMMAND_GRID_COLUMNS := 4
const TOP_TOOL_BUTTON_SIZE := Vector2(42, 42) const TOP_TOOL_BUTTON_SIZE := Vector2(42, 42)
const BATTLE_UNIT_LIST_PANEL_SIZE := Vector2(420, 360) const BATTLE_UNIT_LIST_PANEL_SIZE := Vector2(420, 360)
const BATTLE_UNIT_LIST_ROW_SIZE := Vector2(372, 42) const BATTLE_UNIT_LIST_ROW_SIZE := Vector2(372, 58)
const BATTLE_UNIT_LIST_ICON_SIZE := Vector2(46, 46)
const BATTLE_UNIT_LIST_BADGE_SIZE := Vector2(72, 22)
const SETTINGS_PANEL_SIZE := Vector2(420, 286) const SETTINGS_PANEL_SIZE := Vector2(420, 286)
const LOCAL_COMMAND_BUTTON_SIZE := Vector2(92, 38) const LOCAL_COMMAND_BUTTON_SIZE := Vector2(92, 38)
const LOCAL_COMMAND_CANCEL_BUTTON_SIZE := Vector2(196, 30) const LOCAL_COMMAND_CANCEL_BUTTON_SIZE := Vector2(196, 30)
@@ -10600,7 +10602,7 @@ func _rebuild_battle_unit_list_rows() -> void:
battle_unit_list_rows.add_child(empty_label) battle_unit_list_rows.add_child(empty_label)
return return
for unit in units: for unit in units:
battle_unit_list_rows.add_child(_make_battle_unit_list_row_button(unit)) battle_unit_list_rows.add_child(_make_battle_unit_list_row(unit))
func _clear_battle_unit_list_rows() -> void: func _clear_battle_unit_list_rows() -> void:
@@ -10634,32 +10636,116 @@ func _format_battle_unit_list_status(units: Array[Dictionary]) -> String:
return "행동 %d · 완료 %d" % [ready_count, done_count] return "행동 %d · 완료 %d" % [ready_count, done_count]
func _make_battle_unit_list_row_button(unit: Dictionary) -> Button: func _make_battle_unit_list_row(unit: Dictionary) -> HBoxContainer:
var unit_id := str(unit.get("id", "")) var unit_id := str(unit.get("id", ""))
var tooltip_text := _format_battle_unit_list_row_tooltip(unit)
var row := HBoxContainer.new()
row.custom_minimum_size = BATTLE_UNIT_LIST_ROW_SIZE
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_theme_constant_override("separation", 8)
row.tooltip_text = tooltip_text
row.add_child(_make_battle_unit_list_icon(unit, tooltip_text))
var column := VBoxContainer.new()
column.custom_minimum_size = Vector2(318, 0)
column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
column.add_theme_constant_override("separation", 4)
column.tooltip_text = tooltip_text
row.add_child(column)
var row_button := Button.new() var row_button := Button.new()
row_button.text = _format_battle_unit_list_row_text(unit) row_button.text = _format_battle_unit_list_row_text(unit)
row_button.tooltip_text = _format_battle_unit_list_row_tooltip(unit) row_button.tooltip_text = tooltip_text
row_button.custom_minimum_size = BATTLE_UNIT_LIST_ROW_SIZE row_button.custom_minimum_size = Vector2(318, 28)
row_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL row_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_apply_button_style(row_button) _apply_button_style(row_button)
row_button.add_theme_font_size_override("font_size", 12) row_button.add_theme_font_size_override("font_size", 12)
row_button.pressed.connect(_on_battle_unit_list_row_pressed.bind(unit_id)) row_button.pressed.connect(_on_battle_unit_list_row_pressed.bind(unit_id))
return row_button column.add_child(row_button)
var badge_row := HBoxContainer.new()
badge_row.custom_minimum_size = Vector2(318, 22)
badge_row.add_theme_constant_override("separation", 5)
badge_row.tooltip_text = tooltip_text
column.add_child(badge_row)
for badge_text in _format_battle_unit_list_row_badges(unit):
var text := str(badge_text).strip_edges()
if text.is_empty():
continue
badge_row.add_child(_make_battle_unit_list_badge(text, tooltip_text))
return row
func _make_battle_unit_list_icon(unit: Dictionary, tooltip_text: String) -> PanelContainer:
var panel := PanelContainer.new()
panel.custom_minimum_size = BATTLE_UNIT_LIST_ICON_SIZE
panel.tooltip_text = tooltip_text
_apply_panel_style(panel, "portrait")
var stack := Control.new()
stack.custom_minimum_size = BATTLE_UNIT_LIST_ICON_SIZE
stack.tooltip_text = tooltip_text
panel.add_child(stack)
var texture_rect := TextureRect.new()
texture_rect.set_anchors_preset(Control.PRESET_FULL_RECT)
texture_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
texture_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
texture_rect.tooltip_text = tooltip_text
var texture := _load_portrait_texture(str(unit.get("portrait", "")))
if texture == null:
texture = _load_art_texture(str(unit.get("sprite", "")))
texture_rect.texture = texture
texture_rect.visible = texture != null
stack.add_child(texture_rect)
var initials_label := Label.new()
initials_label.set_anchors_preset(Control.PRESET_FULL_RECT)
initials_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
initials_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
initials_label.text = _dialogue_portrait_initials(str(unit.get("name", "")))
initials_label.visible = texture == null
initials_label.tooltip_text = tooltip_text
_apply_label_style(initials_label, UI_PARCHMENT_TEXT, 14)
stack.add_child(initials_label)
return panel
func _format_battle_unit_list_row_text(unit: Dictionary) -> String: func _format_battle_unit_list_row_text(unit: Dictionary) -> String:
return str(unit.get("name", "부대"))
func _format_battle_unit_list_row_badges(unit: Dictionary) -> Array:
var hp := int(unit.get("hp", 0)) var hp := int(unit.get("hp", 0))
var max_hp := maxi(1, int(unit.get("max_hp", 1))) var max_hp := maxi(1, int(unit.get("max_hp", 1)))
var hp_percent := int(round(float(hp) * 100.0 / float(max_hp))) var hp_percent := int(round(float(hp) * 100.0 / float(max_hp)))
return "%s %s%d%% %s %s" % [ return [
str(unit.get("name", "부대")),
_unit_class_display_name(unit), _unit_class_display_name(unit),
hp_percent, "%d%%" % hp_percent,
_battle_unit_action_state_text(unit), _battle_unit_action_state_text(unit),
_format_cell_label(unit.get("pos", Vector2i(-1, -1))) "%s" % _format_cell_label(unit.get("pos", Vector2i(-1, -1)))
] ]
func _make_battle_unit_list_badge(text: String, tooltip_text: String) -> PanelContainer:
var panel := PanelContainer.new()
panel.custom_minimum_size = BATTLE_UNIT_LIST_BADGE_SIZE
panel.tooltip_text = tooltip_text
_apply_panel_style(panel, "caption")
var label := Label.new()
label.set_anchors_preset(Control.PRESET_FULL_RECT)
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.text = text
label.tooltip_text = tooltip_text
_apply_label_style(label, UI_PARCHMENT_TEXT, 10)
panel.add_child(label)
return panel
func _format_battle_unit_list_row_tooltip(unit: Dictionary) -> String: func _format_battle_unit_list_row_tooltip(unit: Dictionary) -> String:
var hint := "클릭하면 위치로 이동합니다." var hint := "클릭하면 위치로 이동합니다."
if str(unit.get("team", "")) == BattleState.TEAM_PLAYER and _can_select(unit) and _can_select_from_battle_unit_list(): if str(unit.get("team", "")) == BattleState.TEAM_PLAYER and _can_select(unit) and _can_select_from_battle_unit_list():

View File

@@ -3744,6 +3744,16 @@ func _check_battle_unit_list_panel(failures: Array[String]) -> void:
failures.append("ally battle unit list should include deployed officers") failures.append("ally battle unit list should include deployed officers")
elif not _row_texts_contain(scene.battle_unit_list_rows, "조조") or not _row_texts_contain(scene.battle_unit_list_rows, "하후돈"): elif not _row_texts_contain(scene.battle_unit_list_rows, "조조") or not _row_texts_contain(scene.battle_unit_list_rows, "하후돈"):
failures.append("ally battle unit list should show Cao Cao and Xiahou Dun") failures.append("ally battle unit list should show Cao Cao and Xiahou Dun")
var ally_visible_text := _collect_child_text(scene.battle_unit_list_rows)
var ally_tooltips := _collect_child_tooltips(scene.battle_unit_list_rows)
if not ally_visible_text.contains("지휘관") or not ally_visible_text.contains("") or not ally_visible_text.contains(""):
failures.append("ally battle unit list should show compact class, HP, and position badges: %s" % ally_visible_text)
if ally_visible_text.contains("군령:") or ally_visible_text.contains("행군 4") or ally_visible_text.contains("타격 1-1"):
failures.append("ally battle unit list rows should keep dense combat detail in hover text: %s" % ally_visible_text)
if not ally_tooltips.contains("군세") or not ally_tooltips.contains("클릭하면 이 장수를 선택"):
failures.append("ally battle unit list tooltips should retain selection detail: %s" % ally_tooltips)
if not _has_descendant_texture(scene.battle_unit_list_rows):
failures.append("ally battle unit list should show officer portrait artwork")
scene._on_battle_unit_list_row_pressed("cao_cao") scene._on_battle_unit_list_row_pressed("cao_cao")
var selected: Dictionary = scene.state.get_selected_unit() var selected: Dictionary = scene.state.get_selected_unit()
@@ -3759,6 +3769,16 @@ func _check_battle_unit_list_panel(failures: Array[String]) -> void:
failures.append("enemy battle unit list should show living enemies") failures.append("enemy battle unit list should show living enemies")
elif not _row_texts_contain(scene.battle_unit_list_rows, "장만성"): elif not _row_texts_contain(scene.battle_unit_list_rows, "장만성"):
failures.append("enemy battle unit list should include the enemy commander") failures.append("enemy battle unit list should include the enemy commander")
var enemy_visible_text := _collect_child_text(scene.battle_unit_list_rows)
var enemy_tooltips := _collect_child_tooltips(scene.battle_unit_list_rows)
if not enemy_visible_text.contains("") or not enemy_visible_text.contains("") or not enemy_visible_text.contains(""):
failures.append("enemy battle unit list should show compact status badges: %s" % enemy_visible_text)
if enemy_visible_text.contains("군령:") or enemy_visible_text.contains("직접 명령"):
failures.append("enemy battle unit list rows should keep dense enemy detail in hover text: %s" % enemy_visible_text)
if not enemy_tooltips.contains("군령: 적군 군기") or not enemy_tooltips.contains("클릭하면 위치로 이동"):
failures.append("enemy battle unit list tooltips should retain enemy detail: %s" % enemy_tooltips)
if not _has_descendant_texture(scene.battle_unit_list_rows):
failures.append("enemy battle unit list should show unit sprite artwork")
scene._on_battle_unit_list_row_pressed("yellow_turban_1") scene._on_battle_unit_list_row_pressed("yellow_turban_1")
selected = scene.state.get_selected_unit() selected = scene.state.get_selected_unit()
if selected.is_empty() or str(selected.get("id", "")) != "cao_cao": if selected.is_empty() or str(selected.get("id", "")) != "cao_cao":
@@ -3775,12 +3795,7 @@ func _check_battle_unit_list_panel(failures: Array[String]) -> void:
func _row_texts_contain(container: VBoxContainer, needle: String) -> bool: func _row_texts_contain(container: VBoxContainer, needle: String) -> bool:
if container == null: if container == null:
return false return false
for child in container.get_children(): return _collect_child_text(container).contains(needle)
if child is Button and str((child as Button).text).contains(needle):
return true
if child is Label and str((child as Label).text).contains(needle):
return true
return false
func _check_action_button_disabled_reasons(failures: Array[String]) -> void: func _check_action_button_disabled_reasons(failures: Array[String]) -> void: