diff --git a/data/scenarios/001_yellow_turbans.json b/data/scenarios/001_yellow_turbans.json index e61c042..a0f61fb 100644 --- a/data/scenarios/001_yellow_turbans.json +++ b/data/scenarios/001_yellow_turbans.json @@ -2,14 +2,14 @@ "id": "001_yellow_turbans", "name": "영천 소전", "objectives": { - "victory": "황건적 본대를 격파하고 성채를 장악하라.", - "defeat": "조조가 퇴각하거나 제16턴이 시작되면 패한다." + "victory": "성채를 장악하고 마지막 황건대까지 격파하라.", + "defeat": "조조가 퇴각하거나 제18턴이 시작되면 패한다." }, "conditions": { "victory": { "type": "all", "conditions": [ - { "type": "all_units_defeated", "team": "enemy" }, + { "type": "all_units_defeated", "team": "enemy", "after_event": "turn_9_western_reserve" }, { "type": "unit_reaches_tile", "label": "성채 장악", @@ -21,7 +21,7 @@ }, "defeat": [ { "type": "any_officer_defeated", "team": "player", "officer_ids": ["cao_cao"] }, - { "type": "turn_reached", "turn": 16, "team": "player" } + { "type": "turn_reached", "turn": 18, "team": "player" } ] }, "briefing": { @@ -30,7 +30,7 @@ "lines": [ "황건의 무리가 영천 근방에 깃발을 세웠다.", "조조와 하후돈은 난세의 불길이 번지기 전에 그 진형을 꺾어야 한다.", - "마을과 성채를 점거한 부대는 차례가 시작될 때 병력을 회복한다." + "적은 마을과 성채에서 병력을 고치며, 늦게 닿는 원군까지 기다리고 있다." ], "camp_dialogue": [ { "speaker": "Cao Cao", "display_speaker": "조조", "side": "left", "text": "영천은 마른 풀밭의 불씨와 같다. 결단이 빠르면 조정도 아직 질서가 돌아올 수 있음을 믿을 것이다." }, @@ -196,6 +196,42 @@ "level": 1, "pos": [10, 6], "base": { "hp": 32, "atk": 11, "def": 5 } + }, + { + "unit_id": "yellow_turban_9", + "name": "황건 보병", + "class_id": "infantry", + "team": "enemy", + "level": 1, + "pos": [5, 4], + "base": { "hp": 34, "atk": 9, "def": 6 } + }, + { + "unit_id": "yellow_turban_10", + "name": "황건 궁수", + "class_id": "archer", + "team": "enemy", + "level": 1, + "pos": [8, 2], + "base": { "hp": 26, "atk": 9, "def": 4 } + }, + { + "unit_id": "yellow_turban_11", + "name": "황건 유격병", + "class_id": "bandit", + "team": "enemy", + "level": 1, + "pos": [6, 6], + "base": { "hp": 32, "atk": 11, "def": 5 } + }, + { + "unit_id": "yellow_turban_12", + "name": "북숲 초병", + "class_id": "infantry", + "team": "enemy", + "level": 1, + "pos": [4, 2], + "base": { "hp": 34, "atk": 9, "def": 6 } } ], "events": [ @@ -312,6 +348,52 @@ "text": "남쪽 약탈대가 조조의 본진을 노린다." } ] + }, + { + "id": "turn_9_western_reserve", + "once": true, + "when": { "type": "turn_start", "team": "enemy", "turn": 9 }, + "actions": [ + { "type": "log", "text": "서쪽 숲길에서 마지막 황건대가 전장에 닿았다." }, + { + "type": "spawn_deployments", + "deployments": [ + { + "unit_id": "yellow_turban_reinforcement_5", + "name": "황건 보병", + "class_id": "infantry", + "team": "enemy", + "level": 1, + "pos": [5, 8], + "base": { "hp": 34, "atk": 10, "def": 6 } + }, + { + "unit_id": "yellow_turban_reinforcement_6", + "name": "황건 궁수", + "class_id": "archer", + "team": "enemy", + "level": 1, + "pos": [6, 8], + "base": { "hp": 26, "atk": 9, "def": 4 } + }, + { + "unit_id": "yellow_turban_reinforcement_7", + "name": "황건 유격병", + "class_id": "bandit", + "team": "enemy", + "level": 1, + "pos": [3, 6], + "base": { "hp": 32, "atk": 11, "def": 5 } + } + ] + }, + { + "type": "set_ai_target_priority", + "unit_id": "cao_cao", + "priority": 12, + "text": "서쪽 황건대가 조조의 깃발을 노린다." + } + ] } ], "rewards": { diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index df6e0ea..f576050 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -48,7 +48,7 @@ const FLOATING_TEXT_LIFETIME := 1.0 const FLOATING_TEXT_RISE := 42.0 const OBJECTIVE_NOTICE_DURATION := 2.6 const UNIT_MOVE_ANIMATION_DURATION := 0.18 -const TARGET_PREVIEW_BADGE_SIZE := Vector2(92, 22) +const TARGET_PREVIEW_BADGE_SIZE := Vector2(104, 22) const LOW_HP_WARNING_RATIO := 0.35 const UNIT_STATUS_MARKER_RADIUS := 6.0 const UNIT_STATUS_MARKER_GAP := 14.0 @@ -204,6 +204,8 @@ var pending_move_unit_id := "" var pending_move_from_cell := Vector2i(-1, -1) var pending_move_to_cell := Vector2i(-1, -1) var show_threat_overlay := false +var mission_detail_collapsed := false +var briefing_objective_collapsed := false var active_scenario_id := "" var battle_started := false var battle_result_applied := false @@ -215,6 +217,7 @@ var status_label: Label var objective_label: Label var campaign_status_label: Label var mission_title_label: Label +var mission_toggle_button: Button var mission_detail_panel: PanelContainer var mission_detail_label: Label var objective_notice_panel: PanelContainer @@ -234,6 +237,7 @@ var briefing_location_label: Label var briefing_seal_ribbon: HBoxContainer var briefing_objective_panel: PanelContainer var briefing_objective_label: Label +var briefing_objective_toggle_button: Button var briefing_camp_overview_panel: PanelContainer var briefing_camp_overview_row: HBoxContainer var briefing_camp_overview_texture: TextureRect @@ -878,10 +882,10 @@ func _make_map_lattice_overlay(size: Vector2) -> Control: overlay.add_child(horizontal) var corner_specs := [ - {"text": "위", "pos": Vector2(4, 4)}, - {"text": "명", "pos": Vector2(size.x - 24, 4)}, - {"text": "도", "pos": Vector2(4, size.y - 24)}, - {"text": "인", "pos": Vector2(size.x - 24, size.y - 24)} + {"text": "", "pos": Vector2(4, 4)}, + {"text": "", "pos": Vector2(size.x - 24, 4)}, + {"text": "", "pos": Vector2(4, size.y - 24)}, + {"text": "", "pos": Vector2(size.x - 24, size.y - 24)} ] for spec in corner_specs: var seal := _make_seal_tile(str(spec["text"]), 20) @@ -1161,11 +1165,24 @@ func _create_hud() -> void: _apply_label_style(selected_label, UI_PARCHMENT_TEXT) selected_row.add_child(selected_label) + var mission_title_row := HBoxContainer.new() + mission_title_row.add_theme_constant_override("separation", 8) + side_column.add_child(mission_title_row) + mission_title_label = Label.new() mission_title_label.text = "전투 목표" + mission_title_label.custom_minimum_size = Vector2(296, 28) + mission_title_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL mission_title_label.add_theme_font_size_override("font_size", 14) _apply_label_style(mission_title_label, UI_OLD_BRONZE) - side_column.add_child(mission_title_label) + mission_title_row.add_child(mission_title_label) + + mission_toggle_button = Button.new() + mission_toggle_button.text = "접기" + mission_toggle_button.tooltip_text = "전투 목표 판을 접거나 펼칩니다." + mission_toggle_button.custom_minimum_size = Vector2(78, 28) + mission_toggle_button.pressed.connect(_on_mission_toggle_pressed) + mission_title_row.add_child(mission_toggle_button) mission_detail_panel = PanelContainer.new() mission_detail_panel.custom_minimum_size = Vector2(420, 134) @@ -1178,7 +1195,7 @@ func _create_hud() -> void: mission_detail_panel.add_child(mission_detail_row) mission_detail_row.add_child(_make_bamboo_gutter(12, 112)) - mission_detail_row.add_child(_make_edict_marker_stack(["명", "승", "패"], 112)) + mission_detail_row.add_child(_make_edict_marker_stack(["", "", ""], 112)) mission_detail_label = Label.new() mission_detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART @@ -1186,7 +1203,7 @@ func _create_hud() -> void: mission_detail_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL _apply_label_style(mission_detail_label, UI_PARCHMENT_TEXT) mission_detail_row.add_child(mission_detail_label) - mission_detail_row.add_child(_make_edict_marker_stack(["공", "징", "봉"], 112)) + mission_detail_row.add_child(_make_edict_marker_stack(["", "", ""], 112)) mission_detail_row.add_child(_make_bamboo_gutter(12, 112)) cell_info_label = Label.new() @@ -1459,7 +1476,7 @@ func _create_hud() -> void: briefing_shell.add_child(briefing_column) briefing_shell.add_child(_make_bamboo_gutter(22, 632)) briefing_column.add_child(_make_scroll_rod(680, 6)) - briefing_column.add_child(_make_edict_title_strip(["위", "군", "명"], 680, 24)) + briefing_column.add_child(_make_edict_title_strip(["", "", ""], 680, 24)) briefing_column.add_child(_make_tablet_binding(680, 16)) var briefing_title_panel := PanelContainer.new() @@ -1486,12 +1503,23 @@ func _create_hud() -> void: briefing_column.add_child(briefing_location_label) briefing_start_button = Button.new() - briefing_start_button.text = "닫고 전투 시작" + briefing_start_button.text = "전투 시작" + briefing_start_button.tooltip_text = "브리핑을 닫고 전투를 시작합니다." briefing_start_button.pressed.connect(_on_begin_battle_pressed) briefing_column.add_child(briefing_start_button) briefing_column.add_child(_make_scroll_rod(680, 5)) - briefing_column.add_child(_make_section_caption("승리 조건과 패배 조건", 680)) + var briefing_objective_header := HBoxContainer.new() + briefing_objective_header.add_theme_constant_override("separation", 8) + briefing_column.add_child(briefing_objective_header) + briefing_objective_header.add_child(_make_section_caption("승리와 패배", 548)) + + briefing_objective_toggle_button = Button.new() + briefing_objective_toggle_button.text = "조건 접기" + briefing_objective_toggle_button.tooltip_text = "승리와 패배 조건을 접거나 펼칩니다." + briefing_objective_toggle_button.custom_minimum_size = Vector2(124, 28) + briefing_objective_toggle_button.pressed.connect(_on_briefing_objective_toggle_pressed) + briefing_objective_header.add_child(briefing_objective_toggle_button) briefing_objective_panel = PanelContainer.new() briefing_objective_panel.custom_minimum_size = Vector2(680, 104) @@ -1503,7 +1531,7 @@ func _create_hud() -> void: briefing_objective_panel.add_child(briefing_objective_row) briefing_objective_row.add_child(_make_bamboo_gutter(18, 84)) - briefing_objective_row.add_child(_make_edict_marker_stack(["명", "승", "패"], 84)) + briefing_objective_row.add_child(_make_edict_marker_stack(["", "", ""], 84)) briefing_objective_label = Label.new() briefing_objective_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART @@ -1511,7 +1539,7 @@ func _create_hud() -> void: briefing_objective_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL _apply_label_style(briefing_objective_label, UI_AGED_INK, 15) briefing_objective_row.add_child(briefing_objective_label) - briefing_objective_row.add_child(_make_edict_marker_stack(["공", "보", "상"], 84)) + briefing_objective_row.add_child(_make_edict_marker_stack(["", "", ""], 84)) briefing_objective_row.add_child(_make_bamboo_gutter(18, 84)) briefing_column.add_child(_make_ink_wash_rule(680, 8)) @@ -1525,7 +1553,7 @@ func _create_hud() -> void: var briefing_camp_overview_shell := HBoxContainer.new() briefing_camp_overview_shell.add_theme_constant_override("separation", 10) briefing_camp_overview_panel.add_child(briefing_camp_overview_shell) - briefing_camp_overview_shell.add_child(_make_edict_marker_stack(["도", "지"], 86)) + briefing_camp_overview_shell.add_child(_make_edict_marker_stack(["", ""], 86)) briefing_camp_overview_row = HBoxContainer.new() briefing_camp_overview_row.custom_minimum_size = Vector2(580, 92) @@ -1579,7 +1607,7 @@ func _create_hud() -> void: briefing_scroll.add_child(briefing_label) briefing_column.add_child(_make_tablet_binding(680, 14)) - briefing_column.add_child(_make_edict_title_strip(["인", "출", "진"], 680, 22)) + briefing_column.add_child(_make_edict_title_strip(["", "", ""], 680, 22)) var prep_button_row := HBoxContainer.new() prep_button_row.add_theme_constant_override("separation", 8) @@ -1827,7 +1855,7 @@ func _create_hud() -> void: dialogue_row.add_child(dialogue_right_tassel) dialogue_column.add_child(_make_tablet_binding(DIALOGUE_TEXT_SIZE.x, 13)) - dialogue_column.add_child(_make_edict_title_strip(["군", "담", "록"], DIALOGUE_TEXT_SIZE.x, 22)) + dialogue_column.add_child(_make_edict_title_strip(["", "", ""], DIALOGUE_TEXT_SIZE.x, 22)) dialogue_seal_ribbon = _make_seal_ribbon(DIALOGUE_TEXT_SIZE.x, 18) dialogue_column.add_child(dialogue_seal_ribbon) @@ -1840,7 +1868,7 @@ func _create_hud() -> void: var dialogue_speaker_row := HBoxContainer.new() dialogue_speaker_row.add_theme_constant_override("separation", 8) dialogue_speaker_panel.add_child(dialogue_speaker_row) - dialogue_speaker_row.add_child(_make_seal_tile("명", 24)) + dialogue_speaker_row.add_child(_make_seal_tile("", 24)) dialogue_speaker_label = Label.new() dialogue_speaker_label.custom_minimum_size = Vector2(DIALOGUE_TEXT_SIZE.x - 88, 24) @@ -1848,7 +1876,7 @@ func _create_hud() -> void: dialogue_speaker_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _apply_label_style(dialogue_speaker_label, UI_PARCHMENT_TEXT, 18) dialogue_speaker_row.add_child(dialogue_speaker_label) - dialogue_speaker_row.add_child(_make_seal_tile("말", 24)) + dialogue_speaker_row.add_child(_make_seal_tile("", 24)) dialogue_text_panel = PanelContainer.new() dialogue_text_panel.custom_minimum_size = Vector2(DIALOGUE_TEXT_SIZE.x, 104) @@ -1860,7 +1888,7 @@ func _create_hud() -> void: dialogue_text_panel.add_child(dialogue_text_row) dialogue_text_row.add_child(_make_bamboo_gutter(18, DIALOGUE_TEXT_SIZE.y - 20)) - dialogue_text_row.add_child(_make_edict_marker_stack(["말", "문", "답"], DIALOGUE_TEXT_SIZE.y - 20)) + dialogue_text_row.add_child(_make_edict_marker_stack(["", "", ""], DIALOGUE_TEXT_SIZE.y - 20)) dialogue_text_label = Label.new() dialogue_text_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART @@ -1869,7 +1897,7 @@ func _create_hud() -> void: dialogue_text_label.size_flags_vertical = Control.SIZE_EXPAND_FILL _apply_label_style(dialogue_text_label, UI_AGED_INK, 17) dialogue_text_row.add_child(dialogue_text_label) - dialogue_text_row.add_child(_make_edict_marker_stack(["록", "군", "인"], DIALOGUE_TEXT_SIZE.y - 20)) + dialogue_text_row.add_child(_make_edict_marker_stack(["", "", ""], DIALOGUE_TEXT_SIZE.y - 20)) dialogue_text_row.add_child(_make_bamboo_gutter(18, DIALOGUE_TEXT_SIZE.y - 20)) dialogue_column.add_child(_make_scroll_rod(DIALOGUE_TEXT_SIZE.x, 3)) @@ -1925,7 +1953,7 @@ func _create_hud() -> void: var result_body_row := HBoxContainer.new() result_body_row.add_theme_constant_override("separation", 8) result_body_panel.add_child(result_body_row) - result_body_row.add_child(_make_edict_marker_stack(["공", "상", "명"], 198)) + result_body_row.add_child(_make_edict_marker_stack(["", "", ""], 198)) result_label = Label.new() result_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT @@ -1936,7 +1964,7 @@ func _create_hud() -> void: result_label.size_flags_vertical = Control.SIZE_EXPAND_FILL _apply_label_style(result_label, UI_AGED_INK, 16) result_body_row.add_child(result_label) - result_body_row.add_child(_make_edict_marker_stack(["기", "록", "인"], 198)) + result_body_row.add_child(_make_edict_marker_stack(["", "", ""], 198)) result_column.add_child(_make_scroll_rod(520, 3)) @@ -1961,6 +1989,7 @@ func _create_hud() -> void: tactic_button, item_button, equip_button, + mission_toggle_button, threat_button, end_turn_button, post_move_attack_button, @@ -1980,6 +2009,7 @@ func _create_hud() -> void: formation_button, save_button, briefing_start_button, + briefing_objective_toggle_button, shop_buy_button, shop_sell_button, manual_save_button, @@ -2358,6 +2388,9 @@ func _handle_key(event: InputEventKey) -> void: elif save_menu != null and save_menu.visible: _hide_save_menu() elif briefing_panel != null and briefing_panel.visible: + if _set_briefing_objective_collapsed(true): + _play_ui_cancel() + return _on_begin_battle_pressed() elif post_move_picker_panel != null and post_move_picker_panel.visible: _on_post_move_picker_back_pressed() @@ -3041,7 +3074,7 @@ func _attack_target_marker_entries() -> Array[Dictionary]: "cell": cell, "target_id": str(target.get("id", "")), "text": "%s %d%%" % [result_text, hit_chance], - "kind": "damage" + "kind": _attack_preview_badge_kind(preview) }) return result @@ -3718,7 +3751,24 @@ func _attack_target_preview_badge(selected: Dictionary, target: Dictionary) -> D var hit_chance := int(preview.get("hit_chance", 100)) var result_text := "퇴각" if bool(preview.get("would_defeat", false)) else "-%d" % int(preview.get("damage", 0)) - return _make_target_preview_badge("%s %d%%" % [result_text, hit_chance], "damage") + var counter_text := _format_attack_counter_badge_suffix(preview) + return _make_target_preview_badge("%s %d%%%s" % [result_text, hit_chance, counter_text], _attack_preview_badge_kind(preview)) + + +func _format_attack_counter_badge_suffix(preview: Dictionary) -> String: + if not bool(preview.get("counter_in_range", false)): + return "" + if bool(preview.get("counter_would_defeat", false)): + return " 반격!" + return " 반%d" % int(preview.get("counter_damage", 0)) + + +func _attack_preview_badge_kind(preview: Dictionary) -> String: + if bool(preview.get("counter_would_defeat", false)): + return "danger" + if bool(preview.get("counter_in_range", false)): + return "counter" + return "damage" func _skill_target_preview_badge(selected: Dictionary, target: Dictionary) -> Dictionary: @@ -3882,6 +3932,10 @@ func _support_preview_badge_kind(effect_text: String) -> String: func _target_preview_badge_color(kind: String) -> Color: if kind == "damage": return Color(1.0, 0.38, 0.26) + if kind == "counter": + return Color(1.0, 0.64, 0.22) + if kind == "danger": + return Color(1.0, 0.18, 0.14) if kind == "move": return Color(0.38, 0.70, 1.0) if kind == "select": @@ -4015,7 +4069,7 @@ func _format_objective_hud_text() -> String: if not defeat.is_empty(): lines.append("패배: %s" % defeat) if lines.is_empty(): - return "승리 조건 대기" + return "목표 대기" return _join_strings(lines, "\n") @@ -4206,7 +4260,11 @@ func _unit_current_terrain_text(unit: Dictionary) -> String: func _update_mission_panel() -> void: - if mission_detail_label == null: + if mission_detail_panel != null: + mission_detail_panel.visible = not mission_detail_collapsed + if mission_toggle_button != null: + mission_toggle_button.text = "보기" if mission_detail_collapsed else "접기" + if mission_detail_label == null or mission_detail_collapsed: return mission_detail_label.text = _format_mission_panel_text() @@ -4215,18 +4273,18 @@ func _format_mission_panel_text() -> String: var lines := [] var victory := str(state.objectives.get("victory", "")) if not victory.is_empty(): - _append_edict_clause(lines, "승리 조건", "목표", victory) + lines.append("승리: %s" % victory) var defeat := str(state.objectives.get("defeat", "")) if not defeat.is_empty(): - _append_edict_clause(lines, "패배 조건", "주의", defeat) + lines.append("패배: %s" % defeat) var progress_lines := state.get_objective_progress_lines(false, false) if not progress_lines.is_empty(): - _append_edict_clause(lines, "진행", "현황", _join_strings(progress_lines, " · ")) + lines.append("진행: %s" % _join_strings(progress_lines, " · ")) var risk_lines := state.get_defeat_progress_lines() if not risk_lines.is_empty(): - _append_edict_clause(lines, "위험", "경고", _join_strings(risk_lines, " · ")) + lines.append("주의: %s" % _join_strings(risk_lines, " · ")) if lines.is_empty(): - return "군령\n 대기|아직 열린 군령이 없습니다." + return "열린 목표 없음" return _join_strings(lines, "\n") @@ -4699,9 +4757,9 @@ func _format_hanja_number(value: int) -> String: func _on_objective_updated(victory: String, defeat: String) -> void: var notice_lines := ["군령 갱신"] if not victory.is_empty(): - _append_edict_clause(notice_lines, "승리 조건", "목표", victory) + notice_lines.append("목표: %s" % victory) if not defeat.is_empty(): - _append_edict_clause(notice_lines, "패배 조건", "주의", defeat) + notice_lines.append("주의: %s" % defeat) if objective_notice_label != null: objective_notice_label.text = "\n".join(notice_lines) if objective_notice_panel != null: @@ -5323,6 +5381,12 @@ func _on_threat_pressed() -> void: queue_redraw() +func _on_mission_toggle_pressed() -> void: + _play_ui_click() + mission_detail_collapsed = not mission_detail_collapsed + _update_mission_panel() + + func _on_restart_pressed() -> void: if campaign_complete_screen: return @@ -5465,16 +5529,13 @@ func _format_briefing_objectives() -> String: var lines := [] var victory := str(state.objectives.get("victory", "")) if not victory.is_empty(): - lines.append("승리 조건") - lines.append(" %s" % victory) + lines.append("승리: %s" % victory) var defeat := str(state.objectives.get("defeat", "")) if not defeat.is_empty(): - lines.append("패배 조건") - lines.append(" %s" % defeat) + lines.append("패배: %s" % defeat) var rewards_text := _format_briefing_rewards() if not rewards_text.is_empty(): - lines.append("보상") - lines.append(" %s" % rewards_text) + lines.append("보상: %s" % rewards_text) return _join_strings(lines, "\n") @@ -5682,8 +5743,10 @@ func _show_briefing() -> void: briefing_title_label.text = _format_briefing_title(briefing) briefing_location_label.text = _format_briefing_location(briefing) briefing_location_label.visible = not briefing_location_label.text.is_empty() + briefing_objective_collapsed = false briefing_objective_label.text = _format_briefing_objectives() briefing_objective_label.visible = not briefing_objective_label.text.is_empty() + _update_briefing_objective_visibility() briefing_label.text = body briefing_panel.visible = true _refresh_screen_backdrop_visibility() @@ -5714,6 +5777,30 @@ func _show_briefing() -> void: save_button.disabled = prep_locked +func _on_briefing_objective_toggle_pressed() -> void: + _play_ui_click() + _set_briefing_objective_collapsed(not briefing_objective_collapsed) + + +func _set_briefing_objective_collapsed(collapsed: bool) -> bool: + if briefing_objective_label == null or briefing_objective_label.text.strip_edges().is_empty(): + return false + if briefing_objective_collapsed == collapsed: + return false + briefing_objective_collapsed = collapsed + _update_briefing_objective_visibility() + return true + + +func _update_briefing_objective_visibility() -> void: + var has_text := briefing_objective_label != null and not briefing_objective_label.text.strip_edges().is_empty() + if briefing_objective_panel != null: + briefing_objective_panel.visible = has_text and not briefing_objective_collapsed + if briefing_objective_toggle_button != null: + briefing_objective_toggle_button.visible = has_text + briefing_objective_toggle_button.text = "조건 보기" if briefing_objective_collapsed else "조건 접기" + + func _on_begin_battle_pressed() -> void: _play_ui_confirm() _start_battle_from_briefing() diff --git a/tools/smoke_objective_progress.gd b/tools/smoke_objective_progress.gd index d0eac1b..8805bf4 100644 --- a/tools/smoke_objective_progress.gd +++ b/tools/smoke_objective_progress.gd @@ -29,7 +29,13 @@ func _init() -> void: failures, "001 turn pressure risk", _defeat_text("res://data/scenarios/001_yellow_turbans.json"), - "잔여 군령 15" + "잔여 군령 17" + ) + _check_contains( + failures, + "001 late reserve gate", + _progress_text("res://data/scenarios/001_yellow_turbans.json", false), + "제9군령에 전공 갱신" ) _check_contains( failures, @@ -69,6 +75,7 @@ func _init() -> void: _check_objective_notice(failures) _check_terrain_recovery(failures) _check_briefing_battlefield_overview(failures) + _check_opening_battle_extended_pressure(failures) _check_opening_board_avoids_side_panel(failures) if failures.is_empty(): @@ -227,8 +234,14 @@ func _check_opening_battle_requires_castle_capture(failures: Array[String]) -> v var cao_cao: Dictionary = state.get_unit("cao_cao") cao_cao["pos"] = Vector2i(12, 1) state._check_battle_status() + if str(state.battle_status) != BattleStateScript.STATUS_ACTIVE: + failures.append("opening battle should wait for late reserve before victory, got %s" % str(state.battle_status)) + state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9) + for enemy in state.get_living_units(BattleStateScript.TEAM_ENEMY): + enemy["alive"] = false + state._check_battle_status() if str(state.battle_status) != BattleStateScript.STATUS_VICTORY: - failures.append("opening battle should end after enemies fall and castle is captured, got %s" % str(state.battle_status)) + failures.append("opening battle should end after late reserve falls and castle is captured, got %s" % str(state.battle_status)) func _check_log_contains(failures: Array[String], logs: Array, expected: String) -> void: @@ -264,13 +277,33 @@ func _check_briefing_battlefield_overview(failures: Array[String]) -> void: return var overview := scene._format_briefing_battlefield_overview_text() _check_contains(failures, "001 briefing map size", overview, "14칸 x 10칸") - _check_contains(failures, "001 briefing enemy count", overview, "적세 8명") + _check_contains(failures, "001 briefing enemy count", overview, "적세 12명") _check_contains(failures, "001 briefing enemy classes", overview, "보병") _check_contains(failures, "001 briefing village recovery", overview, "마을 4칸 +6") _check_contains(failures, "001 briefing castle recovery", overview, "성채 4칸 +8") scene.free() +func _check_opening_battle_extended_pressure(failures: Array[String]) -> void: + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("opening battle pressure smoke could not load scenario") + return + if state.get_turn_limit() != 17: + failures.append("opening battle should allow play through turn 17, got limit %d" % state.get_turn_limit()) + if state.get_living_units(BattleStateScript.TEAM_ENEMY).size() != 12: + failures.append("opening battle should start with 12 enemies, got %d" % state.get_living_units(BattleStateScript.TEAM_ENEMY).size()) + if state._condition_gate_open({"after_event": "turn_9_western_reserve"}): + failures.append("opening battle final defeat-all condition should be gated until turn 9 reserve") + state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 4) + state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 7) + state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9) + if not state._condition_gate_open({"after_event": "turn_9_western_reserve"}): + failures.append("opening battle final gate should open after turn 9 reserve event") + if state.get_living_units(BattleStateScript.TEAM_ENEMY).size() != 19: + failures.append("opening battle should field 19 enemies after late reserve, got %d" % state.get_living_units(BattleStateScript.TEAM_ENEMY).size()) + + func _check_opening_board_avoids_side_panel(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): @@ -293,12 +326,12 @@ func _check_objective_notice(failures: Array[String]) -> void: scene._on_objective_updated("Defeat the new vanguard.", "") if scene.objective_notice_panel == null or not scene.objective_notice_panel.visible: failures.append("objective notice panel should be visible after objective update") - elif not scene.objective_notice_label.text.contains("군령 갱신") or not scene.objective_notice_label.text.contains("목표|Defeat the new vanguard."): + elif not scene.objective_notice_label.text.contains("군령 갱신") or not scene.objective_notice_label.text.contains("목표: Defeat the new vanguard."): failures.append("objective notice label should use Korean order wording: %s" % scene.objective_notice_label.text) scene._on_objective_updated("", "Supply train is lost.") if scene.objective_notice_panel == null or not scene.objective_notice_panel.visible: failures.append("objective notice panel should be visible after defeat update") - elif not scene.objective_notice_label.text.contains("군령 갱신") or not scene.objective_notice_label.text.contains("주의|Supply train is lost."): + elif not scene.objective_notice_label.text.contains("군령 갱신") or not scene.objective_notice_label.text.contains("주의: Supply train is lost."): failures.append("objective notice label should use Korean risk wording: %s" % scene.objective_notice_label.text) if scene._format_log_entry_text("Objective updated: Defeat the new vanguard.") != "군령 갱신: Defeat the new vanguard.": failures.append("objective log display should use Korean order wording") diff --git a/tools/smoke_visual_assets.gd b/tools/smoke_visual_assets.gd index c14d372..9ffdc85 100644 --- a/tools/smoke_visual_assets.gd +++ b/tools/smoke_visual_assets.gd @@ -95,6 +95,7 @@ func _init() -> void: _check_hud_focus_text(failures) _check_ancient_ui_theme(failures) _check_hover_intent_badges(failures) + _check_counter_attack_badge_variants(failures) _check_action_button_disabled_reasons(failures) _check_terrain_and_unit_presentation(failures) _check_objective_and_status_marker_helpers(failures) @@ -3085,19 +3086,24 @@ func _check_ancient_ui_theme(failures: Array[String]) -> void: scene.free() return var briefing_text := scene._format_briefing_objectives() - for expected in ["승리 조건", "패배 조건", "보상"]: + scene.briefing_objective_label.text = briefing_text + scene._update_briefing_objective_visibility() + for expected in ["승리:", "패배:", "보상:"]: if not briefing_text.contains(expected): failures.append("briefing objective parchment should use Korean heading `%s`: %s" % [expected, briefing_text]) - for clutter in ["진행", "위험"]: + for clutter in ["승리 조건", "패배 조건", "진행", "위험"]: if briefing_text.contains(clutter): failures.append("briefing objective parchment should stay concise without `%s`: %s" % [clutter, briefing_text]) var hud_objective := scene._format_objective_hud_text() if not hud_objective.contains("승리:") or not hud_objective.contains("패배:"): failures.append("HUD objective should use concise Korean labels: %s" % hud_objective) var mission_text := scene._format_mission_panel_text() - for expected in ["승리 조건", "패배 조건", "진행", "위험", "목표|", "주의|", "현황|", "경고|"]: + for expected in ["승리:", "패배:", "진행:", "주의:"]: if not mission_text.contains(expected): failures.append("mission panel should use Korean campaign wording `%s`: %s" % [expected, mission_text]) + for clutter in ["목표|", "주의|", "현황|", "경고|"]: + if mission_text.contains(clutter): + failures.append("mission panel should avoid old verbose marker `%s`: %s" % [clutter, mission_text]) _check_panel_style_fill(failures, scene.briefing_panel, "briefing panel", Color(0.54, 0.36, 0.14, 0.997)) _check_panel_style_frame(failures, scene.briefing_panel, "briefing panel", Color(0.035, 0.014, 0.008, 1.0), 16) _check_panel_style_fill(failures, scene.briefing_objective_panel, "briefing objective edict panel", Color(0.67, 0.49, 0.24, 0.998)) @@ -3182,8 +3188,34 @@ func _check_ancient_ui_theme(failures: Array[String]) -> void: failures.append("dialogue continue button should use the project serif font override") if scene.mission_title_label == null or scene.mission_title_label.text != "전투 목표": failures.append("mission panel title should use clear Korean wording") - if scene.briefing_start_button == null or scene.briefing_start_button.text != "닫고 전투 시작": - failures.append("briefing start button should clearly close the briefing") + if scene.mission_toggle_button == null or scene.mission_toggle_button.text != "접기": + failures.append("mission objective panel should expose a close control") + else: + scene._on_mission_toggle_pressed() + if scene.mission_detail_panel == null or scene.mission_detail_panel.visible or scene.mission_toggle_button.text != "보기": + failures.append("mission objective panel should collapse cleanly") + scene._on_mission_toggle_pressed() + if scene.mission_detail_panel == null or not scene.mission_detail_panel.visible or scene.mission_toggle_button.text != "접기": + failures.append("mission objective panel should reopen cleanly") + if scene.briefing_start_button == null or scene.briefing_start_button.text != "전투 시작": + failures.append("briefing start button should clearly start after briefing") + if scene.briefing_objective_toggle_button == null or scene.briefing_objective_toggle_button.text != "조건 접기": + failures.append("briefing objective panel should expose a collapse button") + else: + scene._set_briefing_objective_collapsed(true) + if scene.briefing_objective_panel == null or scene.briefing_objective_panel.visible or scene.briefing_objective_toggle_button.text != "조건 보기": + failures.append("briefing objective panel should collapse cleanly") + scene._set_briefing_objective_collapsed(false) + if scene.briefing_objective_panel == null or not scene.briefing_objective_panel.visible or scene.briefing_objective_toggle_button.text != "조건 접기": + failures.append("briefing objective panel should reopen cleanly") + scene.briefing_panel.visible = true + scene.battle_started = false + var escape_event := InputEventKey.new() + escape_event.keycode = KEY_ESCAPE + scene._handle_key(escape_event) + if not scene.briefing_objective_collapsed or scene.battle_started: + failures.append("briefing Escape should collapse conditions before starting battle") + scene.briefing_panel.visible = false if scene.shop_button == null or scene.shop_button.text != "군상": failures.append("briefing shop button should use sutler wording") if scene.talk_button == null or scene.talk_button.text != "군막 회의": @@ -3212,8 +3244,8 @@ func _check_ancient_ui_theme(failures: Array[String]) -> void: failures.append("targeting hint title should use mark foe wording") if scene.targeting_hint_back_button == null or scene.targeting_hint_back_button.text != "죽간 거두기": failures.append("targeting hint back button should use bamboo-slip wording") - scene._on_objective_updated("동문에 조서를 꽂아라.", "조조가 퇴각하면 패전이다.") - if scene.objective_notice_label == null or not scene.objective_notice_label.text.contains("목표|") or not scene.objective_notice_label.text.contains("주의|"): + scene._on_objective_updated("동문에 깃발을 세워라.", "조조가 퇴각하면 패전이다.") + if scene.objective_notice_label == null or not scene.objective_notice_label.text.contains("목표:") or not scene.objective_notice_label.text.contains("주의:"): failures.append("objective update notice should use Korean annotation wording") scene._update_briefing_camp_overview(scene.state.get_briefing(), false) if scene.briefing_camp_overview_fallback_label == null or scene.briefing_camp_overview_fallback_label.text != "비단 전장도": @@ -3492,6 +3524,10 @@ func _check_hover_intent_badges(failures: Array[String]) -> void: var attack_badge := scene._target_preview_badge() if attack_badge.is_empty() or str(attack_badge.get("kind", "")) == "move" or str(attack_badge.get("text", "")) == "행군": failures.append("enemy hover should keep attack badge priority: %s" % str(attack_badge)) + if not str(attack_badge.get("text", "")).contains("반"): + failures.append("adjacent attack badge should expose counterattack risk: %s" % str(attack_badge)) + if str(attack_badge.get("kind", "")) != "counter" and str(attack_badge.get("kind", "")) != "danger": + failures.append("counterable attack badge should use a counter danger color kind: %s" % str(attack_badge)) var cao_cao: Dictionary = scene.state.get_unit("cao_cao") cao_cao["moved"] = true @@ -3506,6 +3542,10 @@ func _check_hover_intent_badges(failures: Array[String]) -> void: failures.append("MOVE badge should use a distinct color") if scene._target_preview_badge_color("select") == default_color: failures.append("SELECT badge should use a distinct color") + if scene._target_preview_badge_color("counter") == default_color: + failures.append("COUNTER badge should use a distinct color") + if scene._target_preview_badge_color("danger") == default_color: + failures.append("DANGER badge should use a distinct color") var board_rect: Rect2 = scene._board_rect() var edge_badge_rect: Rect2 = scene._target_preview_badge_rect(Vector2i(13, 9)) if edge_badge_rect.position.x < board_rect.position.x or edge_badge_rect.position.y < board_rect.position.y: @@ -3517,6 +3557,60 @@ func _check_hover_intent_badges(failures: Array[String]) -> void: scene.free() +func _check_counter_attack_badge_variants(failures: Array[String]) -> void: + var scene = BattleSceneScript.new() + if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("could not load battle scene state for counter badge variants") + scene.free() + return + scene.battle_started = true + scene.state.select_unit("cao_cao") + + var cao_cao: Dictionary = scene.state.get_unit("cao_cao") + var infantry: Dictionary = scene.state.get_unit("yellow_turban_2") + var archer: Dictionary = scene.state.get_unit("yellow_turban_3") + if cao_cao.is_empty() or infantry.is_empty() or archer.is_empty(): + failures.append("counter badge variant smoke missing units") + scene.free() + return + + infantry["pos"] = Vector2i(2, 3) + infantry["hp"] = 99 + infantry["max_hp"] = 99 + archer["pos"] = Vector2i(10, 8) + scene.hover_cell = Vector2i(2, 3) + var counter_badge := scene._target_preview_badge() + if str(counter_badge.get("kind", "")) != "counter" or not str(counter_badge.get("text", "")).contains("반"): + failures.append("melee target should expose counter badge: %s" % str(counter_badge)) + scene.basic_attack_targeting = true + scene._refresh_ranges() + var marker_entries := scene._attack_target_marker_entries() + if marker_entries.is_empty() or str(marker_entries[0].get("kind", "")) != "counter": + failures.append("attack target marker should carry counter kind: %s" % str(marker_entries)) + scene.basic_attack_targeting = false + + infantry["pos"] = Vector2i(10, 8) + archer["pos"] = Vector2i(2, 3) + archer["hp"] = 99 + archer["max_hp"] = 99 + scene.hover_cell = Vector2i(2, 3) + var no_counter_badge := scene._target_preview_badge() + if str(no_counter_badge.get("kind", "")) != "damage" or str(no_counter_badge.get("text", "")).contains("반"): + failures.append("adjacent archer target should not expose counter badge: %s" % str(no_counter_badge)) + + archer["pos"] = Vector2i(10, 8) + infantry["pos"] = Vector2i(2, 3) + infantry["hp"] = 99 + infantry["max_hp"] = 99 + infantry["atk"] = 999 + cao_cao["hp"] = 1 + scene.hover_cell = Vector2i(2, 3) + var danger_badge := scene._target_preview_badge() + if str(danger_badge.get("kind", "")) != "danger" or not str(danger_badge.get("text", "")).contains("반격!"): + failures.append("lethal counter should expose danger badge: %s" % str(danger_badge)) + scene.free() + + func _check_terrain_and_unit_presentation(failures: Array[String]) -> void: var scene = BattleSceneScript.new() if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"):