From 740f8b0e4fb49d9b94d0be9a76256ec1932d43c2 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 19 Jun 2026 21:41:43 +0900 Subject: [PATCH] Reduce battle HUD clutter with hover badges --- scripts/scenes/battle_scene.gd | 194 ++++++++++++++++++++++++--- tools/smoke_status_effect_summary.gd | 7 +- tools/smoke_visual_assets.gd | 48 +++++++ 3 files changed, 228 insertions(+), 21 deletions(-) diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 41ff37a..97d105a 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -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 diff --git a/tools/smoke_status_effect_summary.gd b/tools/smoke_status_effect_summary.gd index 2df5aa3..24b4230 100644 --- a/tools/smoke_status_effect_summary.gd +++ b/tools/smoke_status_effect_summary.gd @@ -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() diff --git a/tools/smoke_visual_assets.gd b/tools/smoke_visual_assets.gd index ae9cfca..7cc08c2 100644 --- a/tools/smoke_visual_assets.gd +++ b/tools/smoke_visual_assets.gd @@ -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)))