From 84cdbd1ea5c2105bfb1cbcdf990c429c57b3c72b Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sat, 20 Jun 2026 00:29:07 +0900 Subject: [PATCH] Add portrait battle unit list rows --- README.md | 1 + scripts/scenes/battle_scene.gd | 106 +++++++++++++++++++++++++++++---- tools/smoke_visual_assets.gd | 27 +++++++-- 3 files changed, 118 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 8a33290..d851609 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - 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. - 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. - Damage forecast for selected-unit attacks. - Terrain defense and avoid affect physical attack damage and hit chance. diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 42f4dcf..c808a4f 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -134,7 +134,9 @@ const HUD_COMMAND_BUTTON_SIZE := Vector2(72, 42) const HUD_COMMAND_GRID_COLUMNS := 4 const TOP_TOOL_BUTTON_SIZE := Vector2(42, 42) 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 LOCAL_COMMAND_BUTTON_SIZE := Vector2(92, 38) 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) return 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: @@ -10634,32 +10636,116 @@ func _format_battle_unit_list_status(units: Array[Dictionary]) -> String: 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 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() row_button.text = _format_battle_unit_list_row_text(unit) - row_button.tooltip_text = _format_battle_unit_list_row_tooltip(unit) - row_button.custom_minimum_size = BATTLE_UNIT_LIST_ROW_SIZE + row_button.tooltip_text = tooltip_text + row_button.custom_minimum_size = Vector2(318, 28) row_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL _apply_button_style(row_button) row_button.add_theme_font_size_override("font_size", 12) 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: + return str(unit.get("name", "부대")) + + +func _format_battle_unit_list_row_badges(unit: Dictionary) -> Array: var hp := int(unit.get("hp", 0)) var max_hp := maxi(1, int(unit.get("max_hp", 1))) var hp_percent := int(round(float(hp) * 100.0 / float(max_hp))) - return "%s %s 병%d%% %s %s" % [ - str(unit.get("name", "부대")), + return [ _unit_class_display_name(unit), - hp_percent, + "병 %d%%" % hp_percent, _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: var hint := "클릭하면 위치로 이동합니다." if str(unit.get("team", "")) == BattleState.TEAM_PLAYER and _can_select(unit) and _can_select_from_battle_unit_list(): diff --git a/tools/smoke_visual_assets.gd b/tools/smoke_visual_assets.gd index 96be224..62aff0c 100644 --- a/tools/smoke_visual_assets.gd +++ b/tools/smoke_visual_assets.gd @@ -3744,6 +3744,16 @@ func _check_battle_unit_list_panel(failures: Array[String]) -> void: 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, "하후돈"): 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") 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") elif not _row_texts_contain(scene.battle_unit_list_rows, "장만성"): 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") selected = scene.state.get_selected_unit() 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: if container == null: return false - for child in container.get_children(): - 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 + return _collect_child_text(container).contains(needle) func _check_action_button_disabled_reasons(failures: Array[String]) -> void: