Compact selected unit HUD with hover badges

This commit is contained in:
2026-06-19 22:15:37 +09:00
parent d7bf2e3de6
commit 6fe0a0fbe7
3 changed files with 283 additions and 16 deletions

View File

@@ -103,7 +103,9 @@ const SHOP_ITEM_ICON_SIZE := Vector2(48, 48)
const HUD_UNIT_PORTRAIT_SIZE := Vector2(92, 104) const HUD_UNIT_PORTRAIT_SIZE := Vector2(92, 104)
const HUD_UNIT_PORTRAIT_STACK_SIZE := Vector2(88, 100) const HUD_UNIT_PORTRAIT_STACK_SIZE := Vector2(88, 100)
const HUD_UNIT_INFO_SIZE := Vector2(320, 126) 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_UNIT_BAR_SIZE := Vector2(252, 12)
const HUD_COMMAND_GRID_SIZE := Vector2(420, 46) const HUD_COMMAND_GRID_SIZE := Vector2(420, 46)
const HUD_COMMAND_BUTTON_SIZE := Vector2(72, 42) 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_texture: TextureRect
var hud_unit_portrait_label: Label var hud_unit_portrait_label: Label
var hud_unit_info_column: VBoxContainer var hud_unit_info_column: VBoxContainer
var hud_unit_badge_row: HBoxContainer
var hud_unit_hp_bar: ProgressBar var hud_unit_hp_bar: ProgressBar
var hud_unit_mp_bar: ProgressBar var hud_unit_mp_bar: ProgressBar
var selected_label: Label 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: func _format_local_command_tooltip(label: String, hint: String) -> String:
var normalized_label := label.strip_edges() var normalized_label := label.strip_edges()
var normalized_hint := hint.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 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) var red := Color(UI_SEAL_RED.r, UI_SEAL_RED.g, UI_SEAL_RED.b, 1.0)
match kind: 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": "end_turn":
_icon_line(image, Vector2i(7, 10), Vector2i(22, 10), shadow, 4) _icon_line(image, Vector2i(7, 10), Vector2i(22, 10), shadow, 4)
_icon_line(image, Vector2i(21, 10), Vector2i(21, 22), 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) _apply_label_style(selected_label, UI_PARCHMENT_TEXT, 13)
hud_unit_info_column.add_child(selected_label) 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)) 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_hp_bar = hp_row["bar"] as ProgressBar
hud_unit_info_column.add_child(hp_row["row"] as Control) hud_unit_info_column.add_child(hp_row["row"] as Control)
@@ -6131,11 +6351,13 @@ func _update_hud() -> void:
if hovered_unit.is_empty(): if hovered_unit.is_empty():
selected_label.text = "전장 관망" selected_label.text = "전장 관망"
selected_label.tooltip_text = "마우스를 부대 위에 올리면 상세 군세가 표시됩니다." selected_label.tooltip_text = "마우스를 부대 위에 올리면 상세 군세가 표시됩니다."
_update_hud_unit_badges({}, selected_label.tooltip_text)
_update_hud_unit_resource_bars({}, selected_label.tooltip_text) _update_hud_unit_resource_bars({}, selected_label.tooltip_text)
else: else:
var hover_detail := _format_unit_focus_text(hovered_unit, "Hover") var hover_detail := _format_unit_focus_text(hovered_unit, "Hover")
selected_label.text = _format_unit_focus_summary_text(hovered_unit, "Hover") selected_label.text = _format_unit_focus_summary_text(hovered_unit, "Hover")
selected_label.tooltip_text = hover_detail selected_label.tooltip_text = hover_detail
_update_hud_unit_badges(hovered_unit, hover_detail)
_update_hud_unit_resource_bars(hovered_unit, hover_detail) _update_hud_unit_resource_bars(hovered_unit, hover_detail)
_update_wait_button({}) _update_wait_button({})
_update_tactic_button({}) _update_tactic_button({})
@@ -6149,6 +6371,7 @@ func _update_hud() -> void:
var selected_detail := _format_unit_focus_text(selected, "Selected") var selected_detail := _format_unit_focus_text(selected, "Selected")
selected_label.text = _format_unit_focus_summary_text(selected, "Selected") selected_label.text = _format_unit_focus_summary_text(selected, "Selected")
selected_label.tooltip_text = selected_detail selected_label.tooltip_text = selected_detail
_update_hud_unit_badges(selected, selected_detail)
_update_hud_unit_resource_bars(selected, selected_detail) _update_hud_unit_resource_bars(selected, selected_detail)
_update_wait_button(selected) _update_wait_button(selected)
_update_tactic_button(selected) _update_tactic_button(selected)
@@ -6264,21 +6487,13 @@ func _format_unit_focus_summary_text(unit: Dictionary, prefix: String) -> String
return "표식 없는 빈 전장입니다." return "표식 없는 빈 전장입니다."
var name := str(unit.get("name", "부대")) var name := str(unit.get("name", "부대"))
var control_label := " 호위" if not bool(unit.get("controllable", true)) else "" var control_label := " 호위" if not bool(unit.get("controllable", true)) else ""
var status_text := _unit_action_status_text(unit).replace("군령: ", "") return "%s: %s%s\n%s 품계 %d · %s" % [
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" % [
_unit_focus_prefix_text(prefix), _unit_focus_prefix_text(prefix),
name, name,
control_label, control_label,
_unit_team_text(str(unit.get("team", ""))), _unit_team_text(str(unit.get("team", ""))),
int(unit.get("level", 1)), int(unit.get("level", 1)),
_unit_class_display_name(unit), _unit_class_display_name(unit)
status_text
] ]
@@ -10013,14 +10228,14 @@ func _format_inventory_status_summary_text() -> String:
other_count += count other_count += count
var parts: Array[String] = [] var parts: Array[String] = []
if consumable_count > 0: if consumable_count > 0:
parts.append(" %d" % consumable_count) parts.append("%d" % consumable_count)
if equipment_count > 0: if equipment_count > 0:
parts.append(" %d" % equipment_count) parts.append("%d" % equipment_count)
if other_count > 0: if other_count > 0:
parts.append(" %d" % other_count) parts.append("%d" % other_count)
if parts.is_empty(): if parts.is_empty():
return "군수: 없음" return "군수 없음"
return "군수: %s" % _join_strings(parts, " · ") return _join_strings(parts, " · ")
func _format_reward_items(items: Array) -> String: func _format_reward_items(items: Array) -> String:

View File

@@ -70,6 +70,10 @@ func _check_hud_reuses_status_summary(failures: Array[String]) -> void:
if scene.selected_label.text.contains("상태:"): 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) 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회)") _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, "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회)") _check_contains(failures, "hover tile tooltip status", scene.cell_info_label.tooltip_text, "상태: 방비 -2 (2회)")
scene.state.clear_selection() scene.state.clear_selection()

View File

@@ -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) 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("기력"): 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) 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") var xiahou_dun: Dictionary = scene.state.get_unit("xiahou_dun")
xiahou_dun["pos"] = Vector2i(4, 1) xiahou_dun["pos"] = Vector2i(4, 1)
var forest_text := scene._unit_current_terrain_text(xiahou_dun) 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 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: func _check_local_command_icon_button(failures: Array[String], button: Button, expected_label: String, expected_icon: String) -> void:
if button == null: if button == null:
failures.append("local command button missing: %s" % expected_label) failures.append("local command button missing: %s" % expected_label)