Reduce battle HUD clutter with hover badges

This commit is contained in:
2026-06-19 21:41:43 +09:00
parent 75b5e0a617
commit 740f8b0e4f
3 changed files with 228 additions and 21 deletions

View File

@@ -61,6 +61,8 @@ const BATTLE_PRESENTATION_FEEDBACK_LAG := 0.10
const UNIT_MOVE_ANIMATION_DURATION := 0.18
const UNIT_IDLE_PHASE_SPEED := 2.4
const TARGET_PREVIEW_BADGE_SIZE := Vector2(104, 22)
const HOVER_INFO_BADGE_MAX_WIDTH := 196.0
const HOVER_INFO_BADGE_MIN_WIDTH := 126.0
const LOW_HP_WARNING_RATIO := 0.35
const UNIT_STATUS_MARKER_RADIUS := 6.0
const UNIT_STATUS_MARKER_GAP := 14.0
@@ -315,7 +317,9 @@ var hud_unit_portrait_panel: PanelContainer
var hud_unit_portrait_texture: TextureRect
var hud_unit_portrait_label: Label
var selected_label: Label
var cell_info_panel: PanelContainer
var cell_info_label: Label
var forecast_panel: PanelContainer
var forecast_label: Label
var inventory_label: Label
var briefing_panel: PanelContainer
@@ -514,6 +518,7 @@ func _draw() -> void:
_draw_target_selection_markers()
_draw_attack_effects()
_draw_target_preview_badge()
_draw_hover_info_badge()
_draw_floating_texts()
_draw_map_view_chrome()
_draw_minimap()
@@ -1580,20 +1585,20 @@ func _create_hud() -> void:
mission_detail_row.add_child(_make_edict_marker_stack(["", "", ""], 112))
mission_detail_row.add_child(_make_bamboo_gutter(12, 112))
var cell_info_panel := PanelContainer.new()
cell_info_panel.custom_minimum_size = Vector2(420, 82)
cell_info_panel = PanelContainer.new()
cell_info_panel.custom_minimum_size = Vector2(420, 58)
cell_info_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_apply_panel_style(cell_info_panel, "hud_info")
side_column.add_child(cell_info_panel)
cell_info_label = Label.new()
cell_info_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
cell_info_label.custom_minimum_size = Vector2(396, 62)
cell_info_label.custom_minimum_size = Vector2(396, 38)
cell_info_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_apply_label_style(cell_info_label, Color(0.92, 0.78, 0.54, 1.0), 13)
_apply_label_style(cell_info_label, Color(0.92, 0.78, 0.54, 1.0), 12)
cell_info_panel.add_child(cell_info_label)
var forecast_panel := PanelContainer.new()
forecast_panel = PanelContainer.new()
forecast_panel.custom_minimum_size = Vector2(420, 66)
forecast_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_apply_panel_style(forecast_panel, "hud_info")
@@ -4967,6 +4972,35 @@ func _draw_target_preview_badge() -> void:
_draw_ink_text(font, badge_rect.position + Vector2(0, 16), text, HORIZONTAL_ALIGNMENT_CENTER, badge_rect.size.x, 14, color)
func _draw_hover_info_badge() -> void:
var badge := _hover_info_badge()
if badge.is_empty():
return
var lines: Array[String] = badge.get("lines", [])
if lines.is_empty():
return
var cell: Vector2i = badge.get("cell", hover_cell)
var badge_rect := _hover_info_badge_rect(cell, lines)
var color := _hover_info_badge_color(str(badge.get("kind", "terrain")))
var background := Color(0.035, 0.022, 0.014, 0.90)
var font := _draw_ui_font(true)
draw_rect(badge_rect, background)
draw_rect(badge_rect.grow(-2.0), Color(color.r, color.g, color.b, 0.10))
draw_rect(badge_rect, color, false, 2.0)
for index in range(lines.size()):
var line_color := color if index == 0 else UI_PARCHMENT_TEXT
_draw_ink_text(
font,
badge_rect.position + Vector2(8.0, 16.0 + float(index) * 15.0),
lines[index],
HORIZONTAL_ALIGNMENT_LEFT,
badge_rect.size.x - 16.0,
12,
line_color
)
func _target_preview_badge_rect(cell: Vector2i, size: Vector2 = TARGET_PREVIEW_BADGE_SIZE) -> Rect2:
var cell_rect := _rect_for_cell(cell)
var position := cell_rect.position + Vector2((TILE_SIZE - size.x) * 0.5, 5.0)
@@ -4982,6 +5016,124 @@ func _target_preview_badge_rect(cell: Vector2i, size: Vector2 = TARGET_PREVIEW_B
return Rect2(position, size)
func _hover_info_badge() -> Dictionary:
if _is_input_locked() or not state.is_inside(hover_cell):
return {}
if not _target_preview_badge().is_empty():
return {}
var lines := _hover_info_badge_lines(hover_cell)
if lines.is_empty():
return {}
return {
"cell": hover_cell,
"kind": _hover_info_badge_kind(hover_cell),
"lines": lines
}
func _hover_info_badge_lines(cell: Vector2i) -> Array[String]:
if not state.is_inside(cell):
return []
var summary := state.get_cell_summary(cell)
var lines: Array[String] = []
var unit: Dictionary = summary.get("unit", {})
if not unit.is_empty():
lines.append("%s · %s %s" % [
str(unit.get("name", "부대")),
_unit_team_text(str(unit.get("team", ""))),
_unit_class_display_name(unit)
])
lines.append("병력 %d/%d · 기력 %d/%d" % [
int(unit.get("hp", 0)),
int(unit.get("max_hp", 1)),
int(unit.get("mp", 0)),
int(unit.get("max_mp", 0))
])
var status_effects := state.get_status_effect_summary(str(unit.get("id", "")))
if not status_effects.is_empty():
lines.append("상태: %s" % status_effects)
else:
lines.append("%d,%d · %s" % [
cell.x + 1,
cell.y + 1,
_terrain_display_name(str(summary.get("terrain", "")))
])
var terrain_line := "지형 %s · 방비 +%d · 회피 +%d%%" % [
_terrain_display_name(str(summary.get("terrain", ""))),
int(summary.get("defense", 0)),
int(summary.get("avoid", 0))
]
var heal := int(summary.get("heal", 0))
if heal > 0:
terrain_line += " · 회복 +%d" % heal
lines.append(terrain_line)
var markers: Array[String] = []
var objective_label := state.get_objective_cell_label(cell)
if not objective_label.is_empty():
markers.append("목표 %s" % objective_label)
var event_label := state.get_event_marker_label(cell)
if not event_label.is_empty():
markers.append("표식 %s" % event_label)
if show_threat_overlay:
var threat_names := state.get_threatening_unit_names(cell, BattleState.TEAM_ENEMY)
if not threat_names.is_empty():
markers.append("감시 %s" % _compact_name_list(threat_names, 2))
if not markers.is_empty():
lines.append(_join_strings(markers, " · "))
var capped_lines: Array[String] = []
for index in range(mini(lines.size(), 4)):
capped_lines.append(lines[index])
return capped_lines
func _hover_info_badge_kind(cell: Vector2i) -> String:
var unit := state.get_unit_at(cell)
if not unit.is_empty():
return "player" if str(unit.get("team", "")) == BattleState.TEAM_PLAYER else "enemy"
if not state.get_objective_cell_label(cell).is_empty():
return "objective"
if not state.get_event_marker_label(cell).is_empty():
return "event"
return "terrain"
func _hover_info_badge_rect(cell: Vector2i, lines: Array[String]) -> Rect2:
var size := _hover_info_badge_size(lines)
var cell_rect := _rect_for_cell(cell)
var position := cell_rect.position + Vector2(TILE_SIZE + 6.0, 4.0)
var visible_board := _board_rect().intersection(_map_view_rect())
if visible_board.size.x <= 0.0 or visible_board.size.y <= 0.0:
visible_board = _board_rect()
var min_x := visible_board.position.x + 2.0
var min_y := visible_board.position.y + 2.0
var max_x := maxf(min_x, visible_board.end.x - size.x - 2.0)
var max_y := maxf(min_y, visible_board.end.y - size.y - 2.0)
position.x = clampf(position.x, min_x, max_x)
position.y = clampf(position.y, min_y, max_y)
return Rect2(position, size)
func _hover_info_badge_size(lines: Array[String]) -> Vector2:
var max_chars := 8
for line in lines:
max_chars = maxi(max_chars, str(line).length())
var width := clampf(float(max_chars) * 8.2 + 18.0, HOVER_INFO_BADGE_MIN_WIDTH, HOVER_INFO_BADGE_MAX_WIDTH)
var height := 13.0 + float(maxi(1, lines.size())) * 15.0
return Vector2(width, height)
func _hover_info_badge_color(kind: String) -> Color:
if kind == "player":
return PLAYER_COLOR.lightened(0.45)
if kind == "enemy":
return ENEMY_COLOR.lightened(0.42)
if kind == "objective":
return Color(UI_SEAL_RED.r, UI_SEAL_RED.g, UI_SEAL_RED.b, 0.96)
if kind == "event":
return Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.94)
return Color(UI_PARCHMENT_TEXT.r, UI_PARCHMENT_TEXT.g, UI_PARCHMENT_TEXT.b, 0.88)
func _target_preview_badge() -> Dictionary:
if _is_input_locked() or not state.is_inside(hover_cell):
return {}
@@ -5844,6 +5996,7 @@ func _update_hud() -> void:
_update_mission_panel()
_update_cell_info()
_update_forecast()
_update_forecast_panel_visibility()
_update_result_panel()
if inventory_label != null:
inventory_label.text = _format_inventory_status_summary_text()
@@ -5854,7 +6007,7 @@ func _update_hud() -> void:
var hovered_unit := _hovered_unit_for_hud_portrait()
_update_hud_unit_portrait(hovered_unit)
if hovered_unit.is_empty():
selected_label.text = "표식 없는 빈 전장입니다."
selected_label.text = "전장 관망"
selected_label.tooltip_text = "마우스를 부대 위에 올리면 상세 군세가 표시됩니다."
else:
selected_label.text = _format_unit_focus_summary_text(hovered_unit, "Hover")
@@ -6231,6 +6384,12 @@ func _update_hud_unit_portrait(unit: Dictionary) -> void:
func _update_cell_info() -> void:
if cell_info_label == null:
return
if cell_info_panel != null:
cell_info_panel.visible = state.is_inside(hover_cell)
if not state.is_inside(hover_cell):
cell_info_label.text = ""
cell_info_label.tooltip_text = "전장 칸 위에 올리면 지형과 부대 정보를 볼 수 있습니다."
return
var detail_text := _format_cell_info_detail_text(hover_cell)
cell_info_label.text = _format_cell_info_summary_text(hover_cell)
cell_info_label.tooltip_text = detail_text
@@ -6238,11 +6397,9 @@ func _update_cell_info() -> void:
func _format_cell_info_summary_text(cell: Vector2i) -> String:
if not state.is_inside(cell):
return "지형: -"
return ""
var summary := state.get_cell_summary(cell)
var text := "지형 %d,%d %s\n행군 %d · 방비 +%d · 회피 +%d%%" % [
cell.x + 1,
cell.y + 1,
var text := "%s · 행군 %d · 방비 +%d · 회피 +%d%%" % [
_terrain_display_name(str(summary["terrain"])),
int(summary["move_cost"]),
int(summary["defense"]),
@@ -6257,16 +6414,6 @@ func _format_cell_info_summary_text(cell: Vector2i) -> String:
var event_marker_label := state.get_event_marker_label(cell)
if not event_marker_label.is_empty():
text += "\n표식: %s" % event_marker_label
var unit: Dictionary = summary["unit"]
if not unit.is_empty():
text += "\n군기: %s · 병력 %d/%d" % [
str(unit.get("name", "부대")),
int(unit.get("hp", 0)),
int(unit.get("max_hp", 1))
]
var status_effects := state.get_status_effect_summary(str(unit.get("id", "")))
if not status_effects.is_empty():
text += "\n상태: %s" % status_effects
if show_threat_overlay:
var threat_names := state.get_threatening_unit_names(cell, BattleState.TEAM_ENEMY)
if not threat_names.is_empty():
@@ -6674,6 +6821,13 @@ func _update_item_forecast(selected: Dictionary) -> void:
]
func _update_forecast_panel_visibility() -> void:
if forecast_panel == null or forecast_label == null:
return
var forecast_text := forecast_label.text.strip_edges()
forecast_panel.visible = not forecast_text.is_empty() and forecast_text != "전황: -"
func _update_result_panel() -> void:
if result_panel == null:
return

View File

@@ -54,6 +54,7 @@ func _check_hud_reuses_status_summary(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
scene._create_hud()
scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json")
scene.battle_started = true
var cao_cao: Dictionary = scene.state.get_unit("cao_cao")
cao_cao["status_effects"] = [
{
@@ -67,7 +68,11 @@ func _check_hud_reuses_status_summary(failures: Array[String]) -> void:
scene.hover_cell = Vector2i(1, 6)
scene._update_hud()
_check_contains(failures, "selected HUD status", scene.selected_label.text, "상태: 방비 -2 (2회)")
_check_contains(failures, "hover tile status", scene.cell_info_label.text, "상태: 방비 -2 (2회)")
_check_contains(failures, "hover tile tooltip status", scene.cell_info_label.tooltip_text, "상태: 방비 -2 (2회)")
scene.state.clear_selection()
scene._refresh_ranges()
var hover_badge := scene._hover_info_badge()
_check_contains(failures, "hover tile badge status", str(hover_badge.get("lines", [])), "상태: 방비 -2 (2회)")
scene.free()

View File

@@ -3630,6 +3630,8 @@ func _check_hover_intent_badges(failures: Array[String]) -> void:
var move_badge := scene._target_preview_badge()
if str(move_badge.get("text", "")) != "행군" or str(move_badge.get("kind", "")) != "move":
failures.append("reachable empty tile should show MOVE badge: %s" % str(move_badge))
if not scene._hover_info_badge().is_empty():
failures.append("hover info badge should yield to active move intent badge")
scene.hover_cell = Vector2i(1, 7)
var select_badge := scene._target_preview_badge()
@@ -3670,6 +3672,31 @@ func _check_hover_intent_badges(failures: Array[String]) -> void:
failures.append("edge target preview badge should stay inside visible board maximum: %s / %s" % [str(edge_badge_rect), str(board_rect)])
if edge_badge_rect.size.x <= BattleSceneScript.TILE_SIZE:
failures.append("target preview badge should remain wide enough for Korean tactical text")
scene.state.clear_selection()
scene.selected_skill_id = ""
scene.selected_item_id = ""
scene.basic_attack_targeting = false
scene._refresh_ranges()
scene.hover_cell = Vector2i(1, 5)
var enemy_hover_badge := scene._hover_info_badge()
if str(enemy_hover_badge.get("kind", "")) != "enemy" or not str(enemy_hover_badge.get("lines", [])).contains(str(enemy.get("name", ""))):
failures.append("enemy hover info badge should expose enemy identity: %s" % str(enemy_hover_badge))
scene.hover_cell = Vector2i(4, 2)
var event_hover_badge := scene._hover_info_badge()
if str(event_hover_badge.get("kind", "")) != "event" or not str(event_hover_badge.get("lines", [])).contains("표식 북숲 보급고"):
failures.append("event hover info badge should expose marker label: %s" % str(event_hover_badge))
scene.hover_cell = Vector2i(20, 1)
var objective_hover_badge := scene._hover_info_badge()
var objective_hover_text := str(objective_hover_badge.get("lines", []))
if str(objective_hover_badge.get("kind", "")) != "objective" or not objective_hover_text.contains("목표 성채 장악") or not objective_hover_text.contains("회복 +8"):
failures.append("objective hover info badge should expose capture and recovery: %s" % str(objective_hover_badge))
var edge_hover_lines: Array[String] = ["긴 전장 배지", "둘째 줄"]
var edge_hover_rect: Rect2 = scene._hover_info_badge_rect(Vector2i(13, 9), edge_hover_lines)
if edge_hover_rect.position.x < board_rect.position.x or edge_hover_rect.position.y < board_rect.position.y:
failures.append("edge hover info badge should stay inside visible board minimum: %s / %s" % [str(edge_hover_rect), str(board_rect)])
if edge_hover_rect.end.x > board_rect.end.x or edge_hover_rect.end.y > board_rect.end.y:
failures.append("edge hover info badge should stay inside visible board maximum: %s / %s" % [str(edge_hover_rect), str(board_rect)])
scene.free()
@@ -3744,14 +3771,35 @@ func _check_terrain_and_unit_presentation(failures: Array[String]) -> void:
if scene.state.get_terrain_heal(Vector2i(20, 1)) != 8:
failures.append("castle terrain should expose stronger healing")
scene._create_hud()
scene._update_forecast()
scene._update_forecast_panel_visibility()
if scene.forecast_panel == null or scene.forecast_panel.visible:
failures.append("idle forecast panel should stay hidden instead of showing empty tactical text")
scene.hover_cell = Vector2i(20, 1)
scene._update_cell_info()
if scene.cell_info_label == null or not scene.cell_info_label.text.contains("목표: 성채 장악") or not scene.cell_info_label.text.contains("회복 +8"):
failures.append("castle objective cell info should expose capture label and healing: %s" % scene.cell_info_label.text)
if scene.cell_info_panel == null or not scene.cell_info_panel.visible:
failures.append("cell info panel should be visible for a hovered battlefield cell")
if scene.cell_info_label.text.contains("20,2"):
failures.append("visible cell info should stay compact and leave coordinates to tooltip/badge: %s" % scene.cell_info_label.text)
scene.hover_cell = Vector2i(4, 2)
scene._update_cell_info()
if scene.cell_info_label == null or not scene.cell_info_label.text.contains("표식: 북숲 보급고"):
failures.append("side event marker cell info should expose the supply label: %s" % scene.cell_info_label.text)
scene.hover_cell = Vector2i(-1, -1)
scene._update_cell_info()
if scene.cell_info_panel != null and scene.cell_info_panel.visible:
failures.append("cell info panel should hide when the cursor is outside the board")
scene.state.select_unit("cao_cao")
var forecast_enemy: Dictionary = scene.state.get_unit("yellow_turban_1")
forecast_enemy["pos"] = Vector2i(1, 5)
scene.hover_cell = Vector2i(1, 5)
scene._refresh_ranges()
scene._update_forecast()
scene._update_forecast_panel_visibility()
if scene.forecast_panel == null or not scene.forecast_panel.visible:
failures.append("forecast panel should return when hovering a real target")
scene.cell_info_label.free()
scene.cell_info_label = null
var event_marker_rect: Rect2 = scene._event_marker_rect(scene._rect_for_cell(Vector2i(4, 2)))