From 5e470d05444f5be7871b050c4c7c65bad5ea9f42 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 19 Jun 2026 22:55:26 +0900 Subject: [PATCH] Add battle unit list panel --- scripts/scenes/battle_scene.gd | 306 ++++++++++++++++++++++++++++++++- tools/smoke_visual_assets.gd | 74 ++++++++ 2 files changed, 379 insertions(+), 1 deletion(-) diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 2c72600..c3d3f5a 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -113,6 +113,8 @@ const HUD_COMMAND_GRID_SIZE := Vector2(420, 46) 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 LOCAL_COMMAND_BUTTON_SIZE := Vector2(92, 38) const LOCAL_COMMAND_CANCEL_BUTTON_SIZE := Vector2(196, 30) const POST_MOVE_MENU_SIZE := Vector2(240, 146) @@ -447,6 +449,14 @@ var targeting_hint_detail_label: Label var targeting_hint_back_button: Button var targeting_hint_cancel_move_button: Button var threat_button: Button +var battle_unit_list_button: Button +var battle_unit_list_panel: PanelContainer +var battle_unit_list_title_label: Label +var battle_unit_list_ally_button: Button +var battle_unit_list_enemy_button: Button +var battle_unit_list_status_label: Label +var battle_unit_list_rows: VBoxContainer +var battle_unit_list_mode := "ally" var top_save_button: Button var top_load_button: Button var restart_button: Button @@ -1202,6 +1212,18 @@ func _make_toolbar_icon(kind: String) -> Texture2D: _icon_rect(image, Rect2i(10, 15, 10, 2), Color(0.16, 0.07, 0.03, 1.0)) _icon_rect(image, Rect2i(10, 20, 8, 2), Color(0.16, 0.07, 0.03, 1.0)) _icon_circle(image, Vector2i(24, 9), 3, red) + "unit_list": + _icon_circle(image, Vector2i(11, 10), 4, shadow) + _icon_circle(image, Vector2i(21, 10), 4, shadow) + _icon_circle(image, Vector2i(11, 9), 3, ink) + _icon_circle(image, Vector2i(21, 9), 3, ink) + _icon_rect(image, Rect2i(7, 15, 8, 9), shadow) + _icon_rect(image, Rect2i(17, 15, 8, 9), shadow) + _icon_rect(image, Rect2i(8, 15, 6, 8), ink) + _icon_rect(image, Rect2i(18, 15, 6, 8), ink) + _icon_line(image, Vector2i(5, 25), Vector2i(27, 25), red, 3) + _icon_line(image, Vector2i(8, 18), Vector2i(14, 23), red, 2) + _icon_line(image, Vector2i(24, 18), Vector2i(18, 23), red, 2) "terrain": _icon_line(image, Vector2i(5, 22), Vector2i(13, 12), shadow, 5) _icon_line(image, Vector2i(13, 12), Vector2i(18, 18), shadow, 5) @@ -1855,7 +1877,7 @@ func _create_hud() -> void: var top_tool_row := HBoxContainer.new() top_tool_row.name = "TopToolBar" - top_tool_row.custom_minimum_size = Vector2(284, 48) + top_tool_row.custom_minimum_size = Vector2(332, 48) top_tool_row.add_theme_constant_override("separation", 5) top_row.add_child(top_tool_row) @@ -1870,6 +1892,11 @@ func _create_hud() -> void: threat_button.pressed.connect(_on_threat_pressed) top_tool_row.add_child(threat_button) + battle_unit_list_button = Button.new() + _configure_top_tool_button(battle_unit_list_button, "TopBattleUnitListButton", "unit_list", "군세\n전장에 있는 아군과 적군 목록을 엽니다.") + battle_unit_list_button.pressed.connect(_on_battle_unit_list_pressed) + top_tool_row.add_child(battle_unit_list_button) + top_save_button = Button.new() _configure_top_tool_button(top_save_button, "TopSaveButton", "save", "저장\n전투 전 군막에서 수동 전기를 봉인합니다.") top_save_button.pressed.connect(_on_top_manual_save_pressed) @@ -1890,6 +1917,78 @@ func _create_hud() -> void: new_campaign_button.pressed.connect(_on_new_campaign_pressed) top_tool_row.add_child(new_campaign_button) + battle_unit_list_panel = PanelContainer.new() + battle_unit_list_panel.name = "BattleUnitListPanel" + battle_unit_list_panel.visible = false + battle_unit_list_panel.mouse_filter = Control.MOUSE_FILTER_STOP + battle_unit_list_panel.position = Vector2(824, 88) + battle_unit_list_panel.size = BATTLE_UNIT_LIST_PANEL_SIZE + battle_unit_list_panel.custom_minimum_size = BATTLE_UNIT_LIST_PANEL_SIZE + _apply_panel_style(battle_unit_list_panel, "compact") + root.add_child(battle_unit_list_panel) + + var battle_unit_column := VBoxContainer.new() + battle_unit_column.add_theme_constant_override("separation", 7) + battle_unit_list_panel.add_child(battle_unit_column) + + var battle_unit_header_row := HBoxContainer.new() + battle_unit_header_row.add_theme_constant_override("separation", 8) + battle_unit_column.add_child(battle_unit_header_row) + + battle_unit_list_title_label = Label.new() + battle_unit_list_title_label.text = "군세" + battle_unit_list_title_label.custom_minimum_size = Vector2(232, 28) + battle_unit_list_title_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _apply_label_style(battle_unit_list_title_label, UI_OLD_BRONZE, 16) + battle_unit_header_row.add_child(battle_unit_list_title_label) + + var battle_unit_close_button := Button.new() + battle_unit_close_button.text = "닫기" + battle_unit_close_button.tooltip_text = "군세 목록을 닫습니다." + battle_unit_close_button.custom_minimum_size = Vector2(76, 28) + _apply_button_style(battle_unit_close_button) + battle_unit_close_button.pressed.connect(_hide_battle_unit_list_panel) + battle_unit_header_row.add_child(battle_unit_close_button) + + var battle_unit_tab_row := HBoxContainer.new() + battle_unit_tab_row.add_theme_constant_override("separation", 6) + battle_unit_column.add_child(battle_unit_tab_row) + + battle_unit_list_ally_button = Button.new() + battle_unit_list_ally_button.text = "아군" + battle_unit_list_ally_button.tooltip_text = "아군 장수와 행동 상태를 봅니다." + battle_unit_list_ally_button.custom_minimum_size = Vector2(94, 32) + _apply_button_style(battle_unit_list_ally_button) + battle_unit_list_ally_button.pressed.connect(_on_battle_unit_list_tab_pressed.bind("ally")) + battle_unit_tab_row.add_child(battle_unit_list_ally_button) + + battle_unit_list_enemy_button = Button.new() + battle_unit_list_enemy_button.text = "적군" + battle_unit_list_enemy_button.tooltip_text = "전장에 남은 적 부대를 봅니다." + battle_unit_list_enemy_button.custom_minimum_size = Vector2(94, 32) + _apply_button_style(battle_unit_list_enemy_button) + battle_unit_list_enemy_button.pressed.connect(_on_battle_unit_list_tab_pressed.bind("enemy")) + battle_unit_tab_row.add_child(battle_unit_list_enemy_button) + + battle_unit_list_status_label = Label.new() + battle_unit_list_status_label.custom_minimum_size = Vector2(164, 30) + battle_unit_list_status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + battle_unit_list_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + _apply_label_style(battle_unit_list_status_label, UI_PARCHMENT_TEXT, 12) + battle_unit_tab_row.add_child(battle_unit_list_status_label) + + var battle_unit_scroll := ScrollContainer.new() + battle_unit_scroll.custom_minimum_size = Vector2(380, 250) + battle_unit_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + battle_unit_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + battle_unit_column.add_child(battle_unit_scroll) + + battle_unit_list_rows = VBoxContainer.new() + battle_unit_list_rows.custom_minimum_size = Vector2(372, 0) + battle_unit_list_rows.size_flags_horizontal = Control.SIZE_EXPAND_FILL + battle_unit_list_rows.add_theme_constant_override("separation", 5) + battle_unit_scroll.add_child(battle_unit_list_rows) + objective_notice_panel = PanelContainer.new() objective_notice_panel.visible = false objective_notice_panel.position = Vector2(338, 88) @@ -3232,6 +3331,12 @@ func _handle_cancel_input() -> bool: _update_hud() queue_redraw() return true + if battle_unit_list_panel != null and battle_unit_list_panel.visible: + _play_ui_cancel() + _hide_battle_unit_list_panel() + _update_hud() + queue_redraw() + return true if not selected_skill_id.is_empty() or not selected_item_id.is_empty() or basic_attack_targeting: _play_ui_cancel() selected_skill_id = "" @@ -3276,6 +3381,10 @@ func _handle_key(event: InputEventKey) -> void: _play_ui_cancel() return _on_begin_battle_pressed() + elif battle_unit_list_panel != null and battle_unit_list_panel.visible: + _hide_battle_unit_list_panel() + _update_hud() + queue_redraw() elif post_move_picker_panel != null and post_move_picker_panel.visible: _on_post_move_picker_back_pressed() elif tactic_menu != null and tactic_menu.visible: @@ -6575,10 +6684,12 @@ func _update_hud() -> void: _update_end_turn_button() restart_button.disabled = campaign_complete_screen _update_threat_button() + _update_battle_unit_list_button() _update_top_archive_buttons() _update_post_move_menu() _update_post_move_picker() _update_targeting_hint_panel() + _update_battle_unit_list_panel() _update_auto_end_turn_prompt() @@ -9358,6 +9469,198 @@ func _on_top_manual_load_pressed() -> void: _on_manual_load_pressed() +func _on_battle_unit_list_pressed() -> void: + if battle_unit_list_button == null or battle_unit_list_button.disabled: + return + _play_ui_click() + if battle_unit_list_panel != null and battle_unit_list_panel.visible: + _hide_battle_unit_list_panel() + else: + _rebuild_battle_unit_list_rows() + if battle_unit_list_panel != null: + battle_unit_list_panel.visible = true + _update_hud() + + +func _hide_battle_unit_list_panel() -> void: + if battle_unit_list_panel == null: + return + battle_unit_list_panel.visible = false + + +func _on_battle_unit_list_tab_pressed(mode: String) -> void: + var normalized_mode := "enemy" if mode == "enemy" else "ally" + if battle_unit_list_mode == normalized_mode and battle_unit_list_panel != null and battle_unit_list_panel.visible: + return + battle_unit_list_mode = normalized_mode + _play_ui_click() + _rebuild_battle_unit_list_rows() + + +func _update_battle_unit_list_button() -> void: + if battle_unit_list_button == null: + return + var can_show := ( + battle_started + and not campaign_complete_screen + and state.battle_status == BattleState.STATUS_ACTIVE + ) + _set_action_button_state( + battle_unit_list_button, + "군세", + not can_show, + "전장에 있는 아군과 적군 목록을 엽니다." if can_show else "전투 중에만 군세를 볼 수 있습니다." + ) + if not can_show: + _hide_battle_unit_list_panel() + + +func _update_battle_unit_list_panel() -> void: + if battle_unit_list_panel == null or not battle_unit_list_panel.visible: + return + if not battle_started or campaign_complete_screen or state.battle_status != BattleState.STATUS_ACTIVE: + _hide_battle_unit_list_panel() + return + _rebuild_battle_unit_list_rows() + + +func _rebuild_battle_unit_list_rows() -> void: + if battle_unit_list_rows == null: + return + _clear_battle_unit_list_rows() + var units := _battle_unit_list_units() + if battle_unit_list_title_label != null: + battle_unit_list_title_label.text = "군세 · %s" % _battle_unit_list_mode_label() + if battle_unit_list_ally_button != null: + battle_unit_list_ally_button.disabled = battle_unit_list_mode == "ally" + if battle_unit_list_enemy_button != null: + battle_unit_list_enemy_button.disabled = battle_unit_list_mode == "enemy" + if battle_unit_list_status_label != null: + battle_unit_list_status_label.text = _format_battle_unit_list_status(units) + if units.is_empty(): + var empty_label := Label.new() + empty_label.text = "남은 부대 없음" + empty_label.custom_minimum_size = BATTLE_UNIT_LIST_ROW_SIZE + empty_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _apply_label_style(empty_label, UI_AGED_INK, 13) + 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)) + + +func _clear_battle_unit_list_rows() -> void: + for child in battle_unit_list_rows.get_children(): + battle_unit_list_rows.remove_child(child) + child.queue_free() + + +func _battle_unit_list_units() -> Array[Dictionary]: + if battle_unit_list_mode == "enemy": + return state.get_living_units(BattleState.TEAM_ENEMY) + return state.get_player_units(false) + + +func _battle_unit_list_mode_label() -> String: + return "적군" if battle_unit_list_mode == "enemy" else "아군" + + +func _format_battle_unit_list_status(units: Array[Dictionary]) -> String: + if battle_unit_list_mode == "enemy": + return "잔적 %d" % units.size() + var ready_count := 0 + var done_count := 0 + for unit in units: + if not bool(unit.get("alive", true)) or not bool(unit.get("controllable", true)): + continue + if bool(unit.get("acted", false)): + done_count += 1 + else: + ready_count += 1 + return "행동 %d · 완료 %d" % [ready_count, done_count] + + +func _make_battle_unit_list_row_button(unit: Dictionary) -> Button: + var unit_id := str(unit.get("id", "")) + 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.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 + + +func _format_battle_unit_list_row_text(unit: Dictionary) -> String: + 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", "부대")), + _unit_class_display_name(unit), + hp_percent, + _battle_unit_action_state_text(unit), + _format_cell_label(unit.get("pos", Vector2i(-1, -1))) + ] + + +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(): + hint = "클릭하면 이 장수를 선택하고 위치로 이동합니다." + return "%s\n%s" % [_format_unit_focus_text(unit, "군세"), hint] + + +func _battle_unit_action_state_text(unit: Dictionary) -> String: + if not bool(unit.get("alive", true)): + return "퇴각" + if str(unit.get("team", "")) == BattleState.TEAM_ENEMY: + return "적" + if not bool(unit.get("controllable", true)): + return "호위" + if bool(unit.get("acted", false)): + return "완료" + return "행동" + + +func _can_select_from_battle_unit_list() -> bool: + return ( + battle_started + and state.can_player_act() + and not _is_input_locked() + and not _has_pending_move() + and selected_skill_id.is_empty() + and selected_item_id.is_empty() + and not basic_attack_targeting + ) + + +func _on_battle_unit_list_row_pressed(unit_id: String) -> void: + var unit := state.get_unit(unit_id) + if unit.is_empty(): + return + var unit_cell: Vector2i = unit.get("pos", Vector2i(-1, -1)) + if state.is_inside(unit_cell): + _focus_board_on_cell(unit_cell, true, true) + if str(unit.get("team", "")) == BattleState.TEAM_PLAYER and _can_select(unit) and _can_select_from_battle_unit_list(): + selected_skill_id = "" + selected_item_id = "" + basic_attack_targeting = false + _hide_tactic_menu() + _hide_item_menu() + _hide_equip_menu() + _hide_post_move_menu() + state.select_unit(unit_id) + _play_ui_confirm() + else: + _play_ui_click() + _rebuild_battle_unit_list_rows() + _update_hud() + queue_redraw() + + func _update_top_archive_buttons() -> void: var prep_available := ( not battle_started @@ -9498,6 +9801,7 @@ func _reload_from_campaign_state() -> void: _hide_roster_menu() _hide_formation_menu() _hide_save_menu() + _hide_battle_unit_list_panel() if result_panel != null: result_panel.visible = false if campaign_state.has_pending_post_battle_choice(): diff --git a/tools/smoke_visual_assets.gd b/tools/smoke_visual_assets.gd index 8d7d7a6..9dfb40b 100644 --- a/tools/smoke_visual_assets.gd +++ b/tools/smoke_visual_assets.gd @@ -97,6 +97,7 @@ func _init() -> void: _check_hover_intent_badges(failures) _check_counter_attack_badge_variants(failures) _check_hud_command_grid_layout(failures) + _check_battle_unit_list_panel(failures) _check_action_button_disabled_reasons(failures) _check_terrain_and_unit_presentation(failures) _check_objective_and_status_marker_helpers(failures) @@ -3631,6 +3632,7 @@ func _check_hud_command_grid_layout(failures: Array[String]) -> void: for button in [ scene.end_turn_button, scene.threat_button, + scene.battle_unit_list_button, scene.top_save_button, scene.top_load_button, scene.restart_button, @@ -3648,6 +3650,78 @@ func _check_hud_command_grid_layout(failures: Array[String]) -> void: scene.free() +func _check_battle_unit_list_panel(failures: Array[String]) -> void: + var scene = BattleSceneScript.new() + scene._create_hud() + if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("could not load opening battle for battle unit list") + scene.free() + return + scene.battle_started = true + scene.campaign_complete_screen = false + scene.briefing_panel.visible = false + scene.result_panel.visible = false + scene._update_hud() + + if scene.battle_unit_list_button == null: + failures.append("top toolbar should expose a battle unit list button") + scene.free() + return + if scene.battle_unit_list_button.disabled: + failures.append("battle unit list button should be enabled during active battle") + if scene.battle_unit_list_button.icon == null or not bool(scene.battle_unit_list_button.get_meta("icon_only", false)): + failures.append("battle unit list button should be an icon-only top command") + + scene._on_battle_unit_list_pressed() + if scene.battle_unit_list_panel == null or not scene.battle_unit_list_panel.visible: + failures.append("battle unit list panel should open from the top toolbar") + else: + _check_panel_style_fill(failures, scene.battle_unit_list_panel, "battle unit list panel", Color(0.10, 0.055, 0.030, 0.93)) + if scene.battle_unit_list_title_label == null or not scene.battle_unit_list_title_label.text.contains("아군"): + failures.append("battle unit list should open on the ally tab") + if scene.battle_unit_list_rows == null or scene.battle_unit_list_rows.get_child_count() < 2: + 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") + + scene._on_battle_unit_list_row_pressed("cao_cao") + var selected: Dictionary = scene.state.get_selected_unit() + if selected.is_empty() or str(selected.get("id", "")) != "cao_cao": + failures.append("clicking an available ally in the battle unit list should select that officer") + + scene._on_battle_unit_list_tab_pressed("enemy") + if scene.battle_unit_list_title_label == null or not scene.battle_unit_list_title_label.text.contains("적군"): + failures.append("battle unit list should switch to the enemy tab") + if scene.battle_unit_list_status_label == null or not scene.battle_unit_list_status_label.text.contains("잔적"): + failures.append("enemy battle unit list should summarize remaining enemies") + if scene.battle_unit_list_rows == null or scene.battle_unit_list_rows.get_child_count() < 1: + 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") + 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": + failures.append("clicking an enemy in the battle unit list should focus without stealing ally selection") + + var escape_event := InputEventKey.new() + escape_event.keycode = KEY_ESCAPE + scene._handle_key(escape_event) + if scene.battle_unit_list_panel != null and scene.battle_unit_list_panel.visible: + failures.append("Escape should close the battle unit list panel") + scene.free() + + +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 + + func _check_action_button_disabled_reasons(failures: Array[String]) -> void: var scene = BattleSceneScript.new() scene.wait_button = Button.new()