diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 7f91c56..a211b3c 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -103,7 +103,9 @@ const SHOP_ITEM_ICON_SIZE := Vector2(48, 48) const HUD_UNIT_PORTRAIT_SIZE := Vector2(92, 104) const HUD_UNIT_PORTRAIT_STACK_SIZE := Vector2(88, 100) const HUD_UNIT_INFO_SIZE := Vector2(320, 126) -const HUD_UNIT_SUMMARY_SIZE := Vector2(320, 58) +const HUD_UNIT_SUMMARY_SIZE := Vector2(320, 42) +const HUD_UNIT_BADGE_ROW_SIZE := Vector2(320, 22) +const HUD_UNIT_BADGE_SIZE := Vector2(26, 22) const HUD_UNIT_BAR_SIZE := Vector2(252, 12) const HUD_COMMAND_GRID_SIZE := Vector2(420, 46) const HUD_COMMAND_BUTTON_SIZE := Vector2(72, 42) @@ -323,6 +325,7 @@ var hud_unit_portrait_panel: PanelContainer var hud_unit_portrait_texture: TextureRect var hud_unit_portrait_label: Label var hud_unit_info_column: VBoxContainer +var hud_unit_badge_row: HBoxContainer var hud_unit_hp_bar: ProgressBar var hud_unit_mp_bar: ProgressBar var selected_label: Label @@ -887,6 +890,210 @@ func _make_hud_unit_resource_row(label_text: String, fill_color: Color) -> Dicti } +func _clear_hud_unit_badges() -> void: + if hud_unit_badge_row == null: + return + for child in hud_unit_badge_row.get_children(): + hud_unit_badge_row.remove_child(child) + child.queue_free() + + +func _update_hud_unit_badges(unit: Dictionary, detail_text: String) -> void: + if hud_unit_badge_row == null: + return + _clear_hud_unit_badges() + hud_unit_badge_row.tooltip_text = detail_text + hud_unit_badge_row.visible = not unit.is_empty() + if unit.is_empty(): + return + + hud_unit_badge_row.add_child(_make_hud_unit_badge( + _hud_unit_team_badge_text(unit), + _hud_unit_badge_tooltip(_unit_team_text(str(unit.get("team", ""))), detail_text), + _hud_unit_team_badge_color(unit) + )) + hud_unit_badge_row.add_child(_make_hud_unit_badge( + _hud_unit_class_badge_text(unit), + _hud_unit_badge_tooltip("%s · %s" % [_unit_class_display_name(unit), _unit_role_text(unit)], detail_text), + _hud_unit_class_badge_color(unit) + )) + hud_unit_badge_row.add_child(_make_hud_unit_badge( + "동" if _unit_pixel_facing(unit) >= 0 else "서", + _hud_unit_badge_tooltip("시선 %s\n등 뒤를 잡히면 피해가 커집니다." % _unit_facing_text(unit), detail_text), + Color(0.24, 0.20, 0.12, 0.98) + )) + + var hp_max := maxi(1, int(unit.get("max_hp", 1))) + var hp_ratio := float(clampi(int(unit.get("hp", 0)), 0, hp_max)) / float(hp_max) + if hp_ratio <= LOW_HP_WARNING_RATIO: + hud_unit_badge_row.add_child(_make_hud_unit_badge( + "위", + _hud_unit_badge_tooltip("병력이 크게 낮습니다.", detail_text), + Color(0.56, 0.055, 0.028, 0.99) + )) + + var marker_kinds := _unit_status_marker_kinds(unit) + if not marker_kinds.is_empty(): + var marker_kind := marker_kinds[0] + hud_unit_badge_row.add_child(_make_hud_unit_badge( + _unit_status_marker_glyph(marker_kind), + _hud_unit_badge_tooltip("상태 표식\n자세한 효과는 아래 상세를 확인합니다.", detail_text), + _unit_status_marker_color(marker_kind), + "", + _unit_status_marker_text_color(marker_kind) + )) + + for action_badge in _hud_unit_action_badges(unit, detail_text): + var action_color: Color = action_badge.get("color", Color(0.18, 0.10, 0.04, 0.98)) + hud_unit_badge_row.add_child(_make_hud_unit_badge( + str(action_badge.get("text", "")), + str(action_badge.get("tooltip", "")), + action_color, + str(action_badge.get("icon", "")) + )) + + +func _make_hud_unit_badge( + label_text: String, + tooltip: String, + fill_color: Color, + icon_kind: String = "", + text_color: Color = UI_PARCHMENT_TEXT +) -> PanelContainer: + var panel := PanelContainer.new() + panel.custom_minimum_size = HUD_UNIT_BADGE_SIZE + panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER + panel.tooltip_text = tooltip + panel.add_theme_stylebox_override( + "panel", + _make_panel_style(fill_color, fill_color.lightened(0.34), 1, 2, 2, 0) + ) + if not icon_kind.strip_edges().is_empty(): + var icon := TextureRect.new() + icon.custom_minimum_size = Vector2(22, 18) + icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon.texture = _make_toolbar_icon(icon_kind) + icon.tooltip_text = tooltip + panel.add_child(icon) + return panel + + var label := Label.new() + label.text = label_text + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + label.custom_minimum_size = Vector2(HUD_UNIT_BADGE_SIZE.x - 4.0, HUD_UNIT_BADGE_SIZE.y - 4.0) + label.tooltip_text = tooltip + _apply_label_style(label, text_color, 11) + panel.add_child(label) + return panel + + +func _hud_unit_badge_tooltip(primary_text: String, detail_text: String) -> String: + if detail_text.strip_edges().is_empty(): + return primary_text + return "%s\n%s" % [primary_text, detail_text] + + +func _hud_unit_team_badge_text(unit: Dictionary) -> String: + var team := str(unit.get("team", "")) + if team == BattleState.TEAM_PLAYER: + return "아" + if team == BattleState.TEAM_ENEMY: + return "적" + return "중" + + +func _hud_unit_team_badge_color(unit: Dictionary) -> Color: + var team := str(unit.get("team", "")) + if team == BattleState.TEAM_PLAYER: + return Color(0.11, 0.30, 0.22, 0.99) + if team == BattleState.TEAM_ENEMY: + return Color(0.48, 0.045, 0.028, 0.99) + return Color(0.24, 0.22, 0.16, 0.99) + + +func _hud_unit_class_badge_text(unit: Dictionary) -> String: + var class_id := str(unit.get("class_id", "")).to_lower() + if class_id.contains("cavalry"): + return "기" + if class_id.contains("archer") or class_id.contains("marksman"): + return "궁" + if class_id.contains("strategist") or class_id.contains("advisor"): + return "책" + if class_id.contains("hero") or class_id.contains("commander"): + return "장" + if class_id.contains("warrior") or class_id.contains("champion") or class_id.contains("bandit"): + return "무" + return "보" + + +func _hud_unit_class_badge_color(unit: Dictionary) -> Color: + var class_id := str(unit.get("class_id", "")).to_lower() + if class_id.contains("cavalry"): + return Color(0.52, 0.24, 0.070, 0.99) + if class_id.contains("archer") or class_id.contains("marksman"): + return Color(0.18, 0.31, 0.14, 0.99) + if class_id.contains("strategist") or class_id.contains("advisor"): + return Color(0.15, 0.22, 0.38, 0.99) + if class_id.contains("hero") or class_id.contains("commander"): + return Color(0.44, 0.055, 0.032, 0.99) + if class_id.contains("warrior") or class_id.contains("champion") or class_id.contains("bandit"): + return Color(0.37, 0.15, 0.055, 0.99) + return Color(0.24, 0.19, 0.10, 0.99) + + +func _hud_unit_action_badges(unit: Dictionary, detail_text: String) -> Array: + var result := [] + var unit_id := str(unit.get("id", "")) + if unit_id.is_empty(): + result.append(_hud_unit_action_badge("봉", "", "봉인", "명령할 수 없는 표식입니다.", detail_text, Color(0.20, 0.16, 0.11, 0.98))) + return result + var team := str(unit.get("team", "")) + if team != BattleState.TEAM_PLAYER: + result.append(_hud_unit_action_badge("적", "threat", "적군", "직접 명령할 수 없습니다.", detail_text, Color(0.48, 0.045, 0.028, 0.99))) + return result + if not bool(unit.get("controllable", true)): + result.append(_hud_unit_action_badge("호", "", "호위", "호위 대상은 직접 명령할 수 없습니다.", detail_text, Color(0.31, 0.23, 0.12, 0.98))) + return result + if not bool(unit.get("alive", true)) or not bool(unit.get("deployed", true)) or bool(unit.get("acted", false)): + result.append(_hud_unit_action_badge("봉", "", "봉인", "이 부대는 이미 행동했거나 출진하지 않았습니다.", detail_text, Color(0.20, 0.16, 0.11, 0.98))) + return result + var phase_block := _action_phase_block_reason() + if not phase_block.is_empty(): + result.append(_hud_unit_action_badge("대", "", str(phase_block.get("label", "대기")), str(phase_block.get("tooltip", "")), detail_text, Color(0.20, 0.16, 0.11, 0.98))) + return result + if not bool(unit.get("moved", false)) and not state.get_movement_range(unit_id).is_empty(): + result.append(_hud_unit_action_badge("행", "move", "행군", "이동 가능한 칸이 있습니다.", detail_text, Color(0.34, 0.23, 0.070, 0.99))) + if not state.get_attack_cells(unit_id).is_empty(): + result.append(_hud_unit_action_badge("타", "attack", "타격", "무기로 공격할 수 있습니다.", detail_text, Color(0.46, 0.055, 0.032, 0.99))) + var skill_id := state.get_first_usable_skill_id(unit_id) + if not skill_id.is_empty() and _unit_has_usable_skill_target(unit_id): + result.append(_hud_unit_action_badge("책", "tactic", "책략", "쓸 수 있는 책략 표식이 있습니다.", detail_text, Color(0.12, 0.24, 0.36, 0.99))) + if _unit_has_usable_item_target(unit_id): + result.append(_hud_unit_action_badge("도", "item", "도구", "사용 가능한 도구 대상이 있습니다.", detail_text, Color(0.18, 0.31, 0.16, 0.99))) + if not bool(unit.get("moved", false)): + result.append(_hud_unit_action_badge("병", "equip", "병장", "행군 전 병장을 바꿀 수 있습니다.", detail_text, Color(0.33, 0.18, 0.060, 0.99))) + result.append(_hud_unit_action_badge("대", "wait", "대기", "이 부대의 행동을 마칠 수 있습니다.", detail_text, Color(0.22, 0.16, 0.09, 0.99))) + return result + + +func _hud_unit_action_badge( + text: String, + icon_kind: String, + label: String, + hint: String, + detail_text: String, + color: Color +) -> Dictionary: + return { + "text": text, + "icon": icon_kind, + "color": color, + "tooltip": _hud_unit_badge_tooltip("%s\n%s" % [label, hint], detail_text) + } + + func _format_local_command_tooltip(label: String, hint: String) -> String: var normalized_label := label.strip_edges() var normalized_hint := hint.strip_edges() @@ -904,6 +1111,13 @@ func _make_toolbar_icon(kind: String) -> Texture2D: var shadow := Color(0.07, 0.028, 0.012, 0.82) var red := Color(UI_SEAL_RED.r, UI_SEAL_RED.g, UI_SEAL_RED.b, 1.0) match kind: + "move": + _icon_line(image, Vector2i(8, 22), Vector2i(16, 9), shadow, 5) + _icon_line(image, Vector2i(16, 9), Vector2i(24, 22), shadow, 5) + _icon_line(image, Vector2i(8, 22), Vector2i(16, 9), ink, 3) + _icon_line(image, Vector2i(16, 9), Vector2i(24, 22), ink, 3) + _icon_line(image, Vector2i(11, 22), Vector2i(21, 22), ink, 3) + _icon_circle(image, Vector2i(16, 9), 3, red) "end_turn": _icon_line(image, Vector2i(7, 10), Vector2i(22, 10), shadow, 4) _icon_line(image, Vector2i(21, 10), Vector2i(21, 22), shadow, 4) @@ -1631,6 +1845,12 @@ func _create_hud() -> void: _apply_label_style(selected_label, UI_PARCHMENT_TEXT, 13) hud_unit_info_column.add_child(selected_label) + hud_unit_badge_row = HBoxContainer.new() + hud_unit_badge_row.custom_minimum_size = HUD_UNIT_BADGE_ROW_SIZE + hud_unit_badge_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + hud_unit_badge_row.add_theme_constant_override("separation", 3) + hud_unit_info_column.add_child(hud_unit_badge_row) + var hp_row := _make_hud_unit_resource_row("병력", Color(0.66, 0.12, 0.075, 1.0)) hud_unit_hp_bar = hp_row["bar"] as ProgressBar hud_unit_info_column.add_child(hp_row["row"] as Control) @@ -6131,11 +6351,13 @@ func _update_hud() -> void: if hovered_unit.is_empty(): selected_label.text = "전장 관망" selected_label.tooltip_text = "마우스를 부대 위에 올리면 상세 군세가 표시됩니다." + _update_hud_unit_badges({}, selected_label.tooltip_text) _update_hud_unit_resource_bars({}, selected_label.tooltip_text) else: var hover_detail := _format_unit_focus_text(hovered_unit, "Hover") selected_label.text = _format_unit_focus_summary_text(hovered_unit, "Hover") selected_label.tooltip_text = hover_detail + _update_hud_unit_badges(hovered_unit, hover_detail) _update_hud_unit_resource_bars(hovered_unit, hover_detail) _update_wait_button({}) _update_tactic_button({}) @@ -6149,6 +6371,7 @@ func _update_hud() -> void: var selected_detail := _format_unit_focus_text(selected, "Selected") selected_label.text = _format_unit_focus_summary_text(selected, "Selected") selected_label.tooltip_text = selected_detail + _update_hud_unit_badges(selected, selected_detail) _update_hud_unit_resource_bars(selected, selected_detail) _update_wait_button(selected) _update_tactic_button(selected) @@ -6264,21 +6487,13 @@ func _format_unit_focus_summary_text(unit: Dictionary, prefix: String) -> String return "표식 없는 빈 전장입니다." var name := str(unit.get("name", "부대")) var control_label := " 호위" if not bool(unit.get("controllable", true)) else "" - var status_text := _unit_action_status_text(unit).replace("군령: ", "") - var status_parts := status_text.split(",", false) - if status_parts.size() > 3: - var visible_parts: Array[String] = [] - for index in range(3): - visible_parts.append(str(status_parts[index]).strip_edges()) - status_text = "%s 외 %d" % [_join_strings(visible_parts, ", "), status_parts.size() - 3] - return "%s: %s%s\n%s 품계 %d · %s\n군령: %s" % [ + return "%s: %s%s\n%s 품계 %d · %s" % [ _unit_focus_prefix_text(prefix), name, control_label, _unit_team_text(str(unit.get("team", ""))), int(unit.get("level", 1)), - _unit_class_display_name(unit), - status_text + _unit_class_display_name(unit) ] @@ -10013,14 +10228,14 @@ func _format_inventory_status_summary_text() -> String: other_count += count var parts: Array[String] = [] if consumable_count > 0: - parts.append("소모 %d" % consumable_count) + parts.append("소 %d" % consumable_count) if equipment_count > 0: - parts.append("병장 %d" % equipment_count) + parts.append("병 %d" % equipment_count) if other_count > 0: - parts.append("잡물 %d" % other_count) + parts.append("잡 %d" % other_count) if parts.is_empty(): - return "군수: 없음" - return "군수: %s" % _join_strings(parts, " · ") + return "군수 없음" + return _join_strings(parts, " · ") func _format_reward_items(items: Array) -> String: diff --git a/tools/smoke_status_effect_summary.gd b/tools/smoke_status_effect_summary.gd index 69a7507..6ebd202 100644 --- a/tools/smoke_status_effect_summary.gd +++ b/tools/smoke_status_effect_summary.gd @@ -70,6 +70,10 @@ func _check_hud_reuses_status_summary(failures: Array[String]) -> void: if scene.selected_label.text.contains("상태:"): failures.append("selected HUD should keep status effects in tooltip/markers, not visible text: %s" % scene.selected_label.text) _check_contains(failures, "selected HUD status tooltip", scene.selected_label.tooltip_text, "상태: 방비 -2 (2회)") + if scene.hud_unit_badge_row == null or not scene.hud_unit_badge_row.visible: + failures.append("selected HUD should keep status effects discoverable through compact badges") + else: + _check_contains(failures, "selected badge status tooltip", scene.hud_unit_badge_row.tooltip_text, "상태: 방비 -2 (2회)") _check_contains(failures, "selected HP bar status tooltip", scene.hud_unit_hp_bar.tooltip_text, "상태: 방비 -2 (2회)") _check_contains(failures, "hover tile tooltip status", scene.cell_info_label.tooltip_text, "상태: 방비 -2 (2회)") scene.state.clear_selection() diff --git a/tools/smoke_visual_assets.gd b/tools/smoke_visual_assets.gd index 21ce3e5..4a1fbcd 100644 --- a/tools/smoke_visual_assets.gd +++ b/tools/smoke_visual_assets.gd @@ -3058,6 +3058,24 @@ func _check_hud_focus_text(failures: Array[String]) -> void: failures.append("selected HP bar tooltip should preserve unit detail: %s" % scene.hud_unit_hp_bar.tooltip_text) if scene.selected_label.text.contains("병력") or scene.selected_label.text.contains("기력"): failures.append("selected label should not duplicate HP/MP bars: %s" % scene.selected_label.text) + if scene.selected_label.text.contains("군령:"): + failures.append("selected label should move command availability into visual badges: %s" % scene.selected_label.text) + if scene.inventory_label == null or scene.inventory_label.text.contains("군수:") or scene.inventory_label.text.contains("소모") or scene.inventory_label.text.contains("병장"): + failures.append("inventory HUD summary should stay compact and leave details to tooltip: %s" % ("" if scene.inventory_label == null else scene.inventory_label.text)) + elif not scene.inventory_label.tooltip_text.contains("군수:"): + failures.append("inventory HUD tooltip should preserve full inventory detail: %s" % scene.inventory_label.tooltip_text) + if scene.hud_unit_badge_row == null or not scene.hud_unit_badge_row.visible: + failures.append("selected unit HUD should expose compact hover badges") + else: + var badge_tooltips := _collect_child_tooltips(scene.hud_unit_badge_row) + if scene.hud_unit_badge_row.get_child_count() < 6: + failures.append("selected unit HUD should show role/facing/action badges, got %d" % scene.hud_unit_badge_row.get_child_count()) + if not badge_tooltips.contains("시선 동쪽") or not badge_tooltips.contains("등 뒤"): + failures.append("selected unit facing badge should explain directional defense: %s" % badge_tooltips) + if not badge_tooltips.contains("행군") or not badge_tooltips.contains("타격") or not badge_tooltips.contains("대기"): + failures.append("selected unit action badges should expose available commands through tooltips: %s" % badge_tooltips) + if not _has_descendant_texture(scene.hud_unit_badge_row): + failures.append("selected unit action badges should use icon imagery, not only text") var xiahou_dun: Dictionary = scene.state.get_unit("xiahou_dun") xiahou_dun["pos"] = Vector2i(4, 1) var forest_text := scene._unit_current_terrain_text(xiahou_dun) @@ -3468,6 +3486,36 @@ func _find_descendant_by_name(root: Node, node_name: String) -> Node: return null +func _collect_child_tooltips(root: Node) -> String: + if root == null: + return "" + var parts: Array[String] = [] + _collect_child_tooltips_into(root, parts) + return "\n".join(parts) + + +func _collect_child_tooltips_into(root: Node, parts: Array[String]) -> void: + if root == null: + return + if root is Control: + var tooltip := str((root as Control).tooltip_text).strip_edges() + if not tooltip.is_empty(): + parts.append(tooltip) + for child in root.get_children(): + _collect_child_tooltips_into(child, parts) + + +func _has_descendant_texture(root: Node) -> bool: + if root == null: + return false + if root is TextureRect and (root as TextureRect).texture != null: + return true + for child in root.get_children(): + if _has_descendant_texture(child): + return true + return false + + func _check_local_command_icon_button(failures: Array[String], button: Button, expected_label: String, expected_icon: String) -> void: if button == null: failures.append("local command button missing: %s" % expected_label)