From 3b91344b6ff6a2560d724c7fe4c391d47a3204a1 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 19 Jun 2026 22:47:32 +0900 Subject: [PATCH] Refine battle flow prompts and opening --- data/scenarios/001_yellow_turbans.json | 32 ++- scripts/scenes/battle_scene.gd | 301 +++++++++++++++++++++---- tools/smoke_chapter_one_polish.gd | 22 ++ tools/smoke_post_move_action_flow.gd | 63 +++++- tools/smoke_visual_assets.gd | 38 ++-- 5 files changed, 396 insertions(+), 60 deletions(-) diff --git a/data/scenarios/001_yellow_turbans.json b/data/scenarios/001_yellow_turbans.json index b243c77..2ba6fc6 100644 --- a/data/scenarios/001_yellow_turbans.json +++ b/data/scenarios/001_yellow_turbans.json @@ -556,7 +556,7 @@ "actions": [ { "type": "log", - "text": "조조군이 황건적과 맞붙었다." + "text": "조조와 하후돈이 첫 군령을 열었다." }, { "type": "log", @@ -574,6 +574,36 @@ { "type": "dialogue", "lines": [ + { + "speaker": "Narrator", + "display_speaker": "이야기", + "side": "left", + "text": "천하가 어지러워지던 때, 젊은 조조는 벼슬길보다 먼저 무너지는 고을의 소식을 들었다." + }, + { + "speaker": "Cao Cao", + "display_speaker": "조조", + "side": "left", + "text": "법과 말만으로는 백성을 지킬 수 없구나. 길이 막히기 전에 직접 군사를 모아야겠다." + }, + { + "speaker": "Xiahou Dun", + "display_speaker": "하후돈", + "side": "right", + "text": "하후돈이 왔다. 맹덕, 혼자 나설 생각이었나? 같은 집안의 난세를 보고도 내가 뒤에 남을 리 없다." + }, + { + "speaker": "Cao Cao", + "display_speaker": "조조", + "side": "left", + "text": "원양이 함께라면 길이 열린다. 오늘은 이름을 세우기보다 먼저 마을 사람들을 살린다." + }, + { + "speaker": "Narrator", + "display_speaker": "이야기", + "side": "left", + "text": "두 사람은 작은 병력을 이끌고 황건의 깃발이 걸린 들판으로 향했다. 첫 군령은 여기서 시작된다." + }, { "speaker": "Cao Cao", "display_speaker": "조조", diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 839b4f0..2c72600 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -108,6 +108,7 @@ 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 TOP_HUD_CHIP_ICON_SIZE := Vector2(28, 28) +const SIDE_INFO_CHIP_ICON_SIZE := Vector2(26, 26) const HUD_COMMAND_GRID_SIZE := Vector2(420, 46) const HUD_COMMAND_BUTTON_SIZE := Vector2(72, 42) const HUD_COMMAND_GRID_COLUMNS := 4 @@ -334,8 +335,10 @@ var hud_unit_hp_bar: ProgressBar var hud_unit_mp_bar: ProgressBar var selected_label: Label var cell_info_panel: PanelContainer +var cell_info_icon: TextureRect var cell_info_label: Label var forecast_panel: PanelContainer +var forecast_icon: TextureRect var forecast_label: Label var inventory_label: Label var briefing_panel: PanelContainer @@ -408,6 +411,11 @@ var result_seal_ribbon: HBoxContainer var result_choice_list: VBoxContainer var result_restart_button: Button var next_battle_button: Button +var auto_end_turn_prompt_panel: PanelContainer +var auto_end_turn_prompt_label: Label +var auto_end_turn_yes_button: Button +var auto_end_turn_no_button: Button +var auto_end_turn_prompt_declined_turn := -1 var end_turn_button: Button var wait_button: Button var tactic_button: Button @@ -1137,6 +1145,17 @@ func _make_top_hud_chip_icon(icon_kind: String) -> TextureRect: return icon +func _make_side_info_chip_icon(icon_kind: String) -> TextureRect: + var icon := TextureRect.new() + icon.name = "SideInfoChipIcon" + icon.custom_minimum_size = SIDE_INFO_CHIP_ICON_SIZE + icon.size_flags_vertical = Control.SIZE_SHRINK_CENTER + icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon.texture = _make_toolbar_icon(icon_kind) + return icon + + func _set_top_hud_chip_tooltip(chip: PanelContainer, tooltip: String) -> void: if chip == null: return @@ -1183,6 +1202,21 @@ 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) + "terrain": + _icon_line(image, Vector2i(5, 22), Vector2i(13, 12), shadow, 5) + _icon_line(image, Vector2i(13, 12), Vector2i(18, 18), shadow, 5) + _icon_line(image, Vector2i(18, 18), Vector2i(25, 8), shadow, 5) + _icon_line(image, Vector2i(5, 22), Vector2i(13, 12), ink, 3) + _icon_line(image, Vector2i(13, 12), Vector2i(18, 18), ink, 3) + _icon_line(image, Vector2i(18, 18), Vector2i(25, 8), ink, 3) + _icon_line(image, Vector2i(6, 24), Vector2i(26, 24), red, 3) + "forecast": + _icon_circle_outline(image, Vector2i(16, 16), 10, shadow, 4) + _icon_circle_outline(image, Vector2i(16, 16), 10, ink, 2) + _icon_line(image, Vector2i(6, 16), Vector2i(26, 16), red, 2) + _icon_line(image, Vector2i(16, 6), Vector2i(16, 26), red, 2) + _icon_circle(image, Vector2i(16, 16), 4, red) + _icon_circle(image, Vector2i(16, 16), 2, ink) "move": _icon_line(image, Vector2i(8, 22), Vector2i(16, 9), shadow, 5) _icon_line(image, Vector2i(16, 9), Vector2i(24, 22), shadow, 5) @@ -1879,6 +1913,49 @@ func _create_hud() -> void: objective_notice_column.add_child(objective_notice_label) objective_notice_column.add_child(_make_scroll_rod(548, 3)) + auto_end_turn_prompt_panel = PanelContainer.new() + auto_end_turn_prompt_panel.name = "AutoEndTurnPrompt" + auto_end_turn_prompt_panel.visible = false + auto_end_turn_prompt_panel.mouse_filter = Control.MOUSE_FILTER_STOP + auto_end_turn_prompt_panel.position = Vector2(408, 112) + auto_end_turn_prompt_panel.size = Vector2(464, 154) + auto_end_turn_prompt_panel.custom_minimum_size = Vector2(464, 154) + _apply_panel_style(auto_end_turn_prompt_panel, "command_seal") + root.add_child(auto_end_turn_prompt_panel) + + var auto_end_column := VBoxContainer.new() + auto_end_column.add_theme_constant_override("separation", 8) + auto_end_turn_prompt_panel.add_child(auto_end_column) + auto_end_column.add_child(_make_seal_ribbon(420, 10)) + + auto_end_turn_prompt_label = Label.new() + auto_end_turn_prompt_label.text = "모든 아군이 군령을 마쳤습니다.\n차례를 넘기시겠습니까?" + auto_end_turn_prompt_label.custom_minimum_size = Vector2(420, 56) + auto_end_turn_prompt_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + auto_end_turn_prompt_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + auto_end_turn_prompt_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _apply_label_style(auto_end_turn_prompt_label, UI_PARCHMENT_TEXT, 17) + auto_end_column.add_child(auto_end_turn_prompt_label) + + var auto_end_button_row := HBoxContainer.new() + auto_end_button_row.alignment = BoxContainer.ALIGNMENT_CENTER + auto_end_button_row.add_theme_constant_override("separation", 12) + auto_end_column.add_child(auto_end_button_row) + + auto_end_turn_yes_button = Button.new() + auto_end_turn_yes_button.text = "예" + auto_end_turn_yes_button.tooltip_text = "아군 차례를 마치고 적군 차례로 넘깁니다." + auto_end_turn_yes_button.custom_minimum_size = Vector2(122, 38) + auto_end_turn_yes_button.pressed.connect(_on_auto_end_turn_yes_pressed) + auto_end_button_row.add_child(auto_end_turn_yes_button) + + auto_end_turn_no_button = Button.new() + auto_end_turn_no_button.text = "아니오" + auto_end_turn_no_button.tooltip_text = "이번 차례에는 자동 확인을 닫습니다." + auto_end_turn_no_button.custom_minimum_size = Vector2(122, 38) + auto_end_turn_no_button.pressed.connect(_on_auto_end_turn_no_pressed) + auto_end_button_row.add_child(auto_end_turn_no_button) + var side_panel := PanelContainer.new() side_panel.position = SIDE_PANEL_POSITION side_panel.size = SIDE_PANEL_SIZE @@ -1988,30 +2065,46 @@ func _create_hud() -> void: mission_detail_row.add_child(_make_bamboo_gutter(12, 112)) cell_info_panel = PanelContainer.new() + cell_info_panel.name = "CellInfoChip" 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) + var cell_info_row := HBoxContainer.new() + cell_info_row.add_theme_constant_override("separation", 8) + cell_info_panel.add_child(cell_info_row) + + cell_info_icon = _make_side_info_chip_icon("terrain") + cell_info_row.add_child(cell_info_icon) + cell_info_label = Label.new() cell_info_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - cell_info_label.custom_minimum_size = Vector2(396, 38) + cell_info_label.custom_minimum_size = Vector2(354, 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), 12) - cell_info_panel.add_child(cell_info_label) + cell_info_row.add_child(cell_info_label) forecast_panel = PanelContainer.new() + forecast_panel.name = "ForecastChip" forecast_panel.custom_minimum_size = Vector2(420, 66) forecast_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL _apply_panel_style(forecast_panel, "hud_info") side_column.add_child(forecast_panel) + var forecast_row := HBoxContainer.new() + forecast_row.add_theme_constant_override("separation", 8) + forecast_panel.add_child(forecast_row) + + forecast_icon = _make_side_info_chip_icon("forecast") + forecast_row.add_child(forecast_icon) + forecast_label = Label.new() forecast_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - forecast_label.custom_minimum_size = Vector2(396, 46) + forecast_label.custom_minimum_size = Vector2(354, 46) forecast_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL _apply_label_style(forecast_label, Color(0.95, 0.80, 0.56, 1.0), 13) - forecast_panel.add_child(forecast_label) + forecast_row.add_child(forecast_label) inventory_label = Label.new() inventory_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART @@ -3113,6 +3206,9 @@ func _cancel_pending_move() -> bool: func _handle_cancel_input() -> bool: + if _is_auto_end_turn_prompt_visible(): + _on_auto_end_turn_no_pressed() + return true if _has_pending_move(): return _cancel_pending_move() if post_move_picker_panel != null and post_move_picker_panel.visible: @@ -6483,6 +6579,63 @@ func _update_hud() -> void: _update_post_move_menu() _update_post_move_picker() _update_targeting_hint_panel() + _update_auto_end_turn_prompt() + + +func _update_auto_end_turn_prompt() -> void: + if auto_end_turn_prompt_panel == null: + return + if _should_show_auto_end_turn_prompt(): + auto_end_turn_prompt_panel.visible = true + return + auto_end_turn_prompt_panel.visible = false + + +func _should_show_auto_end_turn_prompt() -> bool: + if campaign_complete_screen or not battle_started: + return false + if state.battle_status != BattleState.STATUS_ACTIVE: + return false + if state.current_team != BattleState.TEAM_PLAYER: + return false + if auto_end_turn_prompt_declined_turn == state.turn_number: + return false + if _is_dialogue_visible() or _has_active_battle_presentation_sequence(): + return false + if briefing_panel != null and briefing_panel.visible: + return false + if result_panel != null and result_panel.visible: + return false + if _has_pending_move(): + return false + if not selected_skill_id.is_empty() or not selected_item_id.is_empty() or basic_attack_targeting: + return false + if post_move_menu != null and post_move_menu.visible: + return false + if post_move_picker_panel != null and post_move_picker_panel.visible: + return false + if targeting_hint_panel != null and targeting_hint_panel.visible: + return false + return _all_controllable_player_units_acted() + + +func _all_controllable_player_units_acted() -> bool: + var has_active_unit := false + for unit in state.units: + if str(unit.get("team", "")) != BattleState.TEAM_PLAYER: + continue + if not bool(unit.get("alive", true)) or not bool(unit.get("deployed", true)): + continue + if not bool(unit.get("controllable", true)): + continue + has_active_unit = true + if not bool(unit.get("acted", false)): + return false + return has_active_unit + + +func _is_auto_end_turn_prompt_visible() -> bool: + return auto_end_turn_prompt_panel != null and auto_end_turn_prompt_panel.visible func _format_status_hud_text() -> String: @@ -6874,17 +7027,21 @@ func _update_cell_info() -> void: if not state.is_inside(hover_cell): cell_info_label.text = "" cell_info_label.tooltip_text = "전장 칸 위에 올리면 지형과 부대 정보를 볼 수 있습니다." + if cell_info_panel != null: + _set_control_tree_tooltip(cell_info_panel, 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 + if cell_info_panel != null: + _set_control_tree_tooltip(cell_info_panel, detail_text) func _format_cell_info_summary_text(cell: Vector2i) -> String: if not state.is_inside(cell): return "" var summary := state.get_cell_summary(cell) - var text := "%s · 행군 %d · 방비 +%d · 회피 +%d%%" % [ + var text := "%s 행%d 방+%d 회+%d%%" % [ _terrain_display_name(str(summary["terrain"])), int(summary["move_cost"]), int(summary["defense"]), @@ -6895,14 +7052,14 @@ func _format_cell_info_summary_text(cell: Vector2i) -> String: text += " · 회복 +%d" % heal var objective_cell_label := state.get_objective_cell_label(cell) if not objective_cell_label.is_empty(): - text += "\n목표: %s" % objective_cell_label + text += "\n목표 %s" % objective_cell_label var event_marker_label := state.get_event_marker_label(cell) if not event_marker_label.is_empty(): - text += "\n표식: %s" % event_marker_label + text += "\n표식 %s" % event_marker_label if show_threat_overlay: var threat_names := state.get_threatening_unit_names(cell, BattleState.TEAM_ENEMY) if not threat_names.is_empty(): - text += "\n감시: %s" % _compact_name_list(threat_names, 3) + text += "\n감시 %s" % _compact_name_list(threat_names, 3) return text @@ -7043,7 +7200,7 @@ func _compact_name_list(names: Array[String], limit: int) -> String: func _update_forecast() -> void: var selected := state.get_selected_unit() if selected.is_empty() or not state.is_inside(hover_cell): - forecast_label.text = "전황: -" + _clear_forecast() return if not selected_skill_id.is_empty(): @@ -7055,22 +7212,22 @@ func _update_forecast() -> void: var target := state.get_unit_at(hover_cell) if target.is_empty() or target.get("team", "") == selected.get("team", ""): - forecast_label.text = "전황: -" + _clear_forecast() return var preview := state.get_damage_preview(selected["id"], target["id"]) if preview.is_empty(): - forecast_label.text = "전황: -" + _clear_forecast() return if bool(preview.get("attack_locked", false)): - forecast_label.text = "전황: 병장 봉인\n무기로 공격할 수 없습니다." + _set_forecast_text("전황: 병장 봉인\n무기로 공격할 수 없습니다.") return if not bool(preview.get("in_range", false)): - var auto_attack_text := _format_auto_attack_forecast_text(selected, target) - if not auto_attack_text.is_empty(): - forecast_label.text = auto_attack_text + var auto_attack_detail := _format_auto_attack_forecast_text(selected, target) + if not auto_attack_detail.is_empty(): + _set_forecast_text(auto_attack_detail) return var range_text := "사거리 안" if preview["in_range"] else "사거리 밖" @@ -7091,7 +7248,7 @@ func _update_forecast() -> void: selected["max_hp"], counter_defeat ] - forecast_label.text = "전황: %s\n피해 %d%s, 명중 %d%%, 적 병력 %d/%d.%s%s" % [ + var detail_text := "전황: %s\n피해 %d%s, 명중 %d%%, 적 병력 %d/%d.%s%s" % [ range_text, preview["damage"], effective_text + directional_text, @@ -7101,6 +7258,54 @@ func _update_forecast() -> void: defeat_text, counter_text ] + _set_forecast_text(detail_text) + + +func _clear_forecast() -> void: + if forecast_label == null: + return + forecast_label.text = "전황: -" + forecast_label.tooltip_text = "공격, 책략, 도구 대상을 가리키면 전황 예측이 표시됩니다." + if forecast_panel != null: + _set_control_tree_tooltip(forecast_panel, forecast_label.tooltip_text) + + +func _set_forecast_text(detail_text: String) -> void: + if forecast_label == null: + return + var compact_text := _format_forecast_summary_text(detail_text) + forecast_label.text = compact_text + forecast_label.tooltip_text = detail_text + if forecast_panel != null: + _set_control_tree_tooltip(forecast_panel, detail_text) + + +func _format_forecast_summary_text(detail_text: String) -> String: + var lines := detail_text.split("\n", false) + if lines.is_empty(): + return "전황: -" + var first_line := str(lines[0]).strip_edges() + var second_line := "" + if lines.size() > 1: + second_line = str(lines[1]).strip_edges() + if first_line == "전황: -": + return first_line + if second_line.is_empty(): + return first_line + var compact_second := second_line + compact_second = compact_second.replace("피해 ", "피") + compact_second = compact_second.replace("명중 ", "명") + compact_second = compact_second.replace("기력 ", "기") + compact_second = compact_second.replace("적 병력 ", "적") + compact_second = compact_second.replace("아군 병력 ", "아") + compact_second = compact_second.replace("지목 병력 ", "지목") + compact_second = compact_second.replace("군기 병력 ", "군") + compact_second = compact_second.replace("병력 ", "병") + compact_second = compact_second.replace("회복", "회") + compact_second = compact_second.replace(", ", " ") + compact_second = compact_second.replace("; ", " ") + compact_second = compact_second.replace(".", "") + return "%s\n%s" % [first_line, _truncate_hud_text(compact_second, 34)] func _format_auto_attack_forecast_text(selected: Dictionary, target: Dictionary) -> String: @@ -7165,24 +7370,24 @@ func _format_directional_forecast_text(preview: Dictionary, bonus_key: String, l func _update_skill_forecast(selected: Dictionary) -> void: var preview := state.get_skill_preview(selected["id"], selected_skill_id, hover_cell) if preview.is_empty(): - forecast_label.text = "전황: -" + _clear_forecast() return var range_text := "사거리 안" if preview["in_range"] else "사거리 밖" var mp_text := "" if preview["has_mp"] else " 기력이 부족합니다." if bool(preview.get("skill_locked", false)): - forecast_label.text = "%s: 봉인, 기력 %d.\n책략을 펼칠 수 없습니다." % [ + _set_forecast_text("%s: 봉인, 기력 %d.\n책략을 펼칠 수 없습니다." % [ preview["skill_name"], preview["mp_cost"] - ] + ]) return if not preview["valid_target"]: - forecast_label.text = "%s: %s, 기력 %d.%s\n지목할 대상이 없습니다." % [ + _set_forecast_text("%s: %s, 기력 %d.%s\n지목할 대상이 없습니다." % [ preview["skill_name"], range_text, preview["mp_cost"], mp_text - ] + ]) return var target := state.get_unit(str(preview.get("target_id", ""))) @@ -7193,7 +7398,7 @@ func _update_skill_forecast(selected: Dictionary) -> void: if preview["kind"] == "heal": var total_heal := int(preview.get("total_heal", preview.get("heal", 0))) if not has_area: - forecast_label.text = "%s: %s, 기력 %d.%s\n병력 %d 회복, 군기 병력 %d/%d." % [ + _set_forecast_text("%s: %s, 기력 %d.%s\n병력 %d 회복, 군기 병력 %d/%d." % [ preview["skill_name"], range_text, preview["mp_cost"], @@ -7201,9 +7406,9 @@ func _update_skill_forecast(selected: Dictionary) -> void: preview["heal"], preview["target_hp_after"], target_max_hp - ] + ]) return - forecast_label.text = "%s: %s, 기력 %d.%s%s\n총 병력 %d 회복, 지목 병력 %d/%d." % [ + _set_forecast_text("%s: %s, 기력 %d.%s%s\n총 병력 %d 회복, 지목 병력 %d/%d." % [ preview["skill_name"], range_text, preview["mp_cost"], @@ -7212,10 +7417,10 @@ func _update_skill_forecast(selected: Dictionary) -> void: total_heal, preview["target_hp_after"], target_max_hp - ] + ]) elif preview["kind"] == "support": var refresh_text := " 갱신." if bool(preview.get("already_active", false)) else "" - forecast_label.text = "%s: %s, 기력 %d.%s%s\n%s.%s" % [ + _set_forecast_text("%s: %s, 기력 %d.%s%s\n%s.%s" % [ preview["skill_name"], range_text, preview["mp_cost"], @@ -7223,14 +7428,14 @@ func _update_skill_forecast(selected: Dictionary) -> void: area_text, preview.get("effect_text", "지원"), refresh_text - ] + ]) else: var defeat_count := int(preview.get("defeat_count", 0)) var defeat_text := " 퇴각 %d군" % defeat_count if defeat_count > 0 else "" var total_damage := int(preview.get("total_damage", preview.get("damage", 0))) if not has_area: var single_defeat_text := " 퇴각" if bool(preview.get("would_defeat", false)) else "" - forecast_label.text = "%s: %s, 기력 %d.%s\n피해 %d, 적 병력 %d/%d.%s" % [ + _set_forecast_text("%s: %s, 기력 %d.%s\n피해 %d, 적 병력 %d/%d.%s" % [ preview["skill_name"], range_text, preview["mp_cost"], @@ -7239,9 +7444,9 @@ func _update_skill_forecast(selected: Dictionary) -> void: preview["target_hp_after"], target_max_hp, single_defeat_text - ] + ]) return - forecast_label.text = "%s: %s, 기력 %d.%s%s\n총 피해 %d, 지목 병력 %d/%d.%s" % [ + _set_forecast_text("%s: %s, 기력 %d.%s%s\n총 피해 %d, 지목 병력 %d/%d.%s" % [ preview["skill_name"], range_text, preview["mp_cost"], @@ -7251,35 +7456,35 @@ func _update_skill_forecast(selected: Dictionary) -> void: preview["target_hp_after"], target_max_hp, defeat_text - ] + ]) func _update_item_forecast(selected: Dictionary) -> void: var preview := state.get_item_preview(selected["id"], selected_item_id, hover_cell) if preview.is_empty(): - forecast_label.text = "전황: -" + _clear_forecast() return var target := state.get_unit_at(hover_cell) var range_text := "사거리 안" if preview["in_range"] else "사거리 밖" var count_text := "잔량 %d" % int(preview.get("count", 0)) if target.is_empty() or not preview["valid_target"]: - forecast_label.text = "%s: %s, %s\n지목할 대상이 없습니다." % [ + _set_forecast_text("%s: %s, %s\n지목할 대상이 없습니다." % [ preview["item_name"], range_text, count_text - ] + ]) return var hp_heal := int(preview.get("heal", 0)) var mp_heal := int(preview.get("mp_heal", 0)) var cure_statuses: Array = preview.get("cure_statuses", []) if hp_heal <= 0 and mp_heal <= 0 and cure_statuses.is_empty(): - forecast_label.text = "%s: %s, %s\n보급 효과가 필요하지 않습니다." % [ + _set_forecast_text("%s: %s, %s\n보급 효과가 필요하지 않습니다." % [ preview["item_name"], range_text, count_text - ] + ]) return var recovery_parts := [] @@ -7298,12 +7503,12 @@ func _update_item_forecast(selected: Dictionary) -> void: if not cure_statuses.is_empty(): recovery_parts.append("치료 %s" % _join_strings(cure_statuses, ", ")) - forecast_label.text = "%s: %s, %s\n%s." % [ + _set_forecast_text("%s: %s, %s\n%s." % [ preview["item_name"], range_text, count_text, _join_strings(recovery_parts, "; ") - ] + ]) func _update_forecast_panel_visibility() -> void: @@ -7844,14 +8049,12 @@ func _update_dialogue_controls() -> void: dialogue_progress_label.text = "대화 %d/%d" % [active_dialogue_index + 1, active_dialogue_lines.size()] if dialogue_previous_button != null: dialogue_previous_button.disabled = active_dialogue_index <= 0 - dialogue_continue_button.text = "닫기" if active_dialogue_index >= active_dialogue_lines.size() - 1 else "다음" + dialogue_continue_button.text = "다음" func _apply_dialogue_side(side: String) -> void: if dialogue_row == null or dialogue_portrait_panel == null or dialogue_column == null: return - var portrait_index: int = maxi(0, dialogue_row.get_child_count() - 2) if side == "right" else 1 - dialogue_row.move_child(dialogue_portrait_panel, portrait_index) dialogue_speaker_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT if side == "right" else HORIZONTAL_ALIGNMENT_LEFT dialogue_text_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT if side == "right" else HORIZONTAL_ALIGNMENT_LEFT @@ -7987,6 +8190,21 @@ func _on_wait_pressed() -> void: _play_ui_confirm() +func _on_auto_end_turn_yes_pressed() -> void: + if auto_end_turn_prompt_panel != null: + auto_end_turn_prompt_panel.visible = false + _on_end_turn_pressed() + + +func _on_auto_end_turn_no_pressed() -> void: + auto_end_turn_prompt_declined_turn = state.turn_number + if auto_end_turn_prompt_panel != null: + auto_end_turn_prompt_panel.visible = false + _play_ui_cancel() + _update_hud() + queue_redraw() + + func _on_end_turn_pressed() -> void: if state.can_player_act(): _play_ui_confirm() @@ -10023,6 +10241,7 @@ func _is_input_locked() -> bool: return ( campaign_complete_screen or _is_dialogue_visible() + or _is_auto_end_turn_prompt_visible() or _has_active_battle_presentation_sequence() or not battle_started or state.battle_status != BattleState.STATUS_ACTIVE diff --git a/tools/smoke_chapter_one_polish.gd b/tools/smoke_chapter_one_polish.gd index 885a499..cf71b18 100644 --- a/tools/smoke_chapter_one_polish.gd +++ b/tools/smoke_chapter_one_polish.gd @@ -638,6 +638,13 @@ func _check_opening_battle_event_dialogue_structure(failures: Array[String]) -> continue if not _event_has_dialogue_action(event): failures.append("opening battle event should include contextual dialogue: %s" % event_id) + var opening_event := _event_by_id(state, "opening_dialogue") + if not opening_event.is_empty(): + var opening_text := _event_dialogue_text(opening_event) + if not opening_text.contains("젊은 조조") or not opening_text.contains("하후돈") or not opening_text.contains("첫 군령"): + failures.append("opening battle should first explain Cao Cao, Xiahou Dun, and why the first battle begins") + if opening_text.find("젊은 조조") > opening_text.find("황건의 깃발"): + failures.append("opening battle prologue should come before the tactical battlefield order") var boss_event := _event_by_id(state, "zhang_mancheng_falls") if not boss_event.is_empty(): var when: Dictionary = boss_event.get("when", {}) @@ -1520,6 +1527,21 @@ func _event_has_dialogue_action(event: Dictionary) -> bool: return false +func _event_dialogue_text(event: Dictionary) -> String: + var parts: Array[String] = [] + var actions: Array = event.get("actions", []) + for action in actions: + if typeof(action) != TYPE_DICTIONARY or str(action.get("type", "")) != "dialogue": + continue + var lines: Array = (action as Dictionary).get("lines", []) + for line in lines: + if typeof(line) == TYPE_DICTIONARY: + parts.append(str((line as Dictionary).get("text", ""))) + elif typeof(line) == TYPE_STRING: + parts.append(str(line)) + return "\n".join(parts) + + func _event_has_set_objective_action(event: Dictionary) -> bool: var actions: Array = event.get("actions", []) for action in actions: diff --git a/tools/smoke_post_move_action_flow.gd b/tools/smoke_post_move_action_flow.gd index f639567..02f3a4e 100644 --- a/tools/smoke_post_move_action_flow.gd +++ b/tools/smoke_post_move_action_flow.gd @@ -7,6 +7,7 @@ const BattleSceneScript := preload("res://scripts/scenes/battle_scene.gd") func _init() -> void: var failures: Array[String] = [] _check_deferred_move_events(failures) + _check_auto_end_turn_prompt_flow(failures) _check_scene_post_move_menu_flow(failures) _check_scene_post_move_edge_positioning(failures) _check_scene_post_move_scrolled_view_positioning(failures) @@ -79,6 +80,63 @@ func _check_deferred_move_events(failures: Array[String]) -> void: failures.append("pending move commit should fire deferred movement event") +func _check_auto_end_turn_prompt_flow(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 auto end turn prompt") + scene.free() + return + scene.battle_started = true + scene.campaign_complete_screen = false + scene.briefing_panel.visible = false + scene.result_panel.visible = false + scene._clear_pending_move_state() + scene.state.selected_unit_id = "" + + if scene.auto_end_turn_prompt_panel == null: + failures.append("auto end turn prompt should be created with the HUD") + scene.free() + return + + for unit in scene.state.units: + if str(unit.get("team", "")) == BattleStateScript.TEAM_PLAYER and bool(unit.get("controllable", true)): + unit["acted"] = false + scene._update_hud() + if scene.auto_end_turn_prompt_panel.visible: + failures.append("auto end turn prompt should stay hidden while a controllable ally can still act") + + for unit in scene.state.units: + if str(unit.get("team", "")) == BattleStateScript.TEAM_PLAYER and bool(unit.get("alive", true)) and bool(unit.get("deployed", true)) and bool(unit.get("controllable", true)): + unit["acted"] = true + unit["moved"] = true + scene._update_hud() + if not scene.auto_end_turn_prompt_panel.visible: + failures.append("auto end turn prompt should appear once every controllable ally has acted") + if not scene._is_input_locked(): + failures.append("auto end turn prompt should lock board input while visible") + + if not scene._handle_cancel_input(): + failures.append("right-click cancel should dismiss the auto end turn prompt") + if scene.auto_end_turn_prompt_panel.visible: + failures.append("auto end turn prompt should hide after cancel") + scene._update_hud() + if scene.auto_end_turn_prompt_panel.visible: + failures.append("declined auto end turn prompt should not reopen in the same turn") + + scene.auto_end_turn_prompt_declined_turn = -1 + scene._update_hud() + if not scene.auto_end_turn_prompt_panel.visible: + failures.append("auto end turn prompt should reopen after the declined-turn guard is cleared") + var turn_before: int = scene.state.turn_number + scene._on_auto_end_turn_yes_pressed() + if scene.auto_end_turn_prompt_panel.visible: + failures.append("auto end turn prompt should hide after confirming turn end") + if scene.state.turn_number <= turn_before and scene.state.battle_status == BattleStateScript.STATUS_ACTIVE: + failures.append("confirming auto end turn should advance through the enemy phase") + scene.free() + + func _check_scene_post_move_menu_flow(failures: Array[String]) -> void: var scene = BattleSceneScript.new() scene._create_hud() @@ -710,8 +768,9 @@ func _check_scene_auto_attack_safe_origin_and_counter_preview(failures: Array[St risky_scene.hover_cell = Vector2i(5, 3) risky_scene._update_forecast() var risky_forecast: String = risky_scene.forecast_label.text - if not risky_forecast.contains("행공") or not risky_forecast.contains("반격"): - failures.append("risky auto attack forecast should show counter risk: %s" % risky_forecast) + var risky_detail := str(risky_scene.forecast_label.tooltip_text) + if not risky_forecast.contains("행공") or not risky_detail.contains("반격"): + failures.append("risky auto attack forecast should summarize on the label and keep counter risk in the tooltip: %s / %s" % [risky_forecast, risky_detail]) risky_enemy["controllable"] = false var escort_preview := risky_scene._auto_attack_preview_for_origin(risky_attacker, risky_enemy, Vector2i(4, 3)) if bool(escort_preview.get("counter_in_range", false)): diff --git a/tools/smoke_visual_assets.gd b/tools/smoke_visual_assets.gd index 77bb04e..8d7d7a6 100644 --- a/tools/smoke_visual_assets.gd +++ b/tools/smoke_visual_assets.gd @@ -3222,16 +3222,14 @@ func _check_ancient_ui_theme(failures: Array[String]) -> void: _check_bamboo_gutter_row(failures, scene.mission_detail_panel, "mission detail bamboo gutters") _check_edict_marker_stack(failures, scene.briefing_objective_panel, "briefing objective edict markers") _check_edict_marker_stack(failures, scene.mission_detail_panel, "mission detail edict markers") - var cell_info_panel: PanelContainer = null - if scene.cell_info_label != null: - cell_info_panel = scene.cell_info_label.get_parent() as PanelContainer - _check_panel_style_fill(failures, cell_info_panel, "HUD terrain info panel", Color(0.075, 0.050, 0.034, 0.88)) - _check_panel_style_frame(failures, cell_info_panel, "HUD terrain info panel", Color(0.31, 0.22, 0.12, 0.96), 3) - var forecast_panel: PanelContainer = null - if scene.forecast_label != null: - forecast_panel = scene.forecast_label.get_parent() as PanelContainer - _check_panel_style_fill(failures, forecast_panel, "HUD forecast info panel", Color(0.075, 0.050, 0.034, 0.88)) - _check_panel_style_frame(failures, forecast_panel, "HUD forecast info panel", Color(0.31, 0.22, 0.12, 0.96), 3) + _check_panel_style_fill(failures, scene.cell_info_panel, "HUD terrain info panel", Color(0.075, 0.050, 0.034, 0.88)) + _check_panel_style_frame(failures, scene.cell_info_panel, "HUD terrain info panel", Color(0.31, 0.22, 0.12, 0.96), 3) + _check_panel_style_fill(failures, scene.forecast_panel, "HUD forecast info panel", Color(0.075, 0.050, 0.034, 0.88)) + _check_panel_style_frame(failures, scene.forecast_panel, "HUD forecast info panel", Color(0.31, 0.22, 0.12, 0.96), 3) + if scene.cell_info_icon == null: + failures.append("HUD terrain info panel should expose an icon chip") + if scene.forecast_icon == null: + failures.append("HUD forecast info panel should expose an icon chip") _check_hanging_tassel(failures, scene.dialogue_left_tassel, "dialogue left tassel") _check_hanging_tassel(failures, scene.dialogue_right_tassel, "dialogue right tassel") _check_dialogue_column_budget(failures, scene) @@ -3258,10 +3256,16 @@ func _check_ancient_ui_theme(failures: Array[String]) -> void: if right_lines.is_empty(): failures.append("dialogue normalization should keep right-side lines") else: + var portrait_index_before: int = scene.dialogue_row.get_children().find(scene.dialogue_portrait_panel) scene.active_dialogue_lines = right_lines scene.active_dialogue_index = 0 scene._render_dialogue_line() _check_right_side_dialogue_layout(failures, scene) + var portrait_index_after: int = scene.dialogue_row.get_children().find(scene.dialogue_portrait_panel) + if portrait_index_after != portrait_index_before: + failures.append("right-side dialogue should not move the portrait panel between lines") + if scene.dialogue_continue_button == null or scene.dialogue_continue_button.text != "다음": + failures.append("dialogue continue button should stay stable even on the final line") scene._hide_dialogue_panel() if scene._ui_font(false) == null: failures.append("ancient UI regular font should load from project assets") @@ -3426,10 +3430,10 @@ func _check_right_side_dialogue_layout(failures: Array[String], scene) -> void: return if scene.dialogue_row.get_child(0) != scene.dialogue_left_tassel: failures.append("right-side dialogue should keep the left tassel at the outer edge") - if scene.dialogue_row.get_child(1) != scene.dialogue_column: - failures.append("right-side dialogue should place speech scroll before the portrait") - if scene.dialogue_row.get_child(2) != scene.dialogue_portrait_panel: - failures.append("right-side dialogue should place portrait near the right tassel") + if scene.dialogue_row.get_child(1) != scene.dialogue_portrait_panel: + failures.append("right-side dialogue should keep the portrait fixed beside the left tassel") + if scene.dialogue_row.get_child(2) != scene.dialogue_column: + failures.append("right-side dialogue should keep the speech scroll in its fixed column") if scene.dialogue_row.get_child(3) != scene.dialogue_right_tassel: failures.append("right-side dialogue should keep the right tassel at the outer edge") if scene.dialogue_speaker_label == null or scene.dialogue_speaker_label.horizontal_alignment != HORIZONTAL_ALIGNMENT_RIGHT: @@ -3895,15 +3899,17 @@ func _check_terrain_and_unit_presentation(failures: Array[String]) -> void: 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"): + 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_label != null and not scene.cell_info_label.tooltip_text.contains("21,2"): + failures.append("castle objective cell info should keep coordinates in the hover tooltip: %s" % scene.cell_info_label.tooltip_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("표식: 북숲 보급고"): + 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()