From 46f8b10d53f493c7493e37b2a90039e7cb4c4304 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 19 Jun 2026 23:31:17 +0900 Subject: [PATCH] Add new campaign opening prologue --- README.md | 2 +- docs/ARCHITECTURE.md | 29 +++-- scripts/scenes/battle_scene.gd | 225 +++++++++++++++++++++++++++++++++ tools/smoke_title_menu.gd | 24 +++- 4 files changed, 264 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 39ec57d..cae4916 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Campaign completion screen. - New campaign save reset. - Manual campaign checkpoint save/load. -- Cinematic title screen uses the battlefield backdrop and Cao Cao portrait art, offers start/load/settings, and persists BGM/SFX volume, sound toggles, and edge-scroll preference in `user://heros_settings.json`. +- Cinematic title screen uses the battlefield backdrop and Cao Cao portrait art, offers start/load/settings, leads new campaigns through a short Cao Cao and Xiahou Dun opening prologue, and persists BGM/SFX volume, sound toggles, and edge-scroll preference in `user://heros_settings.json`. - Victory and defeat result overlay. - Dialogue portrait slot with officer default image paths, optional per-line overrides, and speaker-initial fallback. - Initial AI-generated photorealistic officer portraits for Cao Cao, Xiahou Dun, Xiahou Yuan, Cao Ren, Dian Wei, Guo Jia, and Zhang He. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d38b747..1508a46 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -36,20 +36,21 @@ 1. Load battle data. 2. Hydrate deployments through `DataCatalog`. 3. Dispatch battle-start scenario events. -4. Show scenario briefing with the campaign chapter header, battle header, objective summary, chapter overview, selectable camp Talk conversations, manual checkpoint menu, and optional pre-battle shop, Armory, Roster, and Formation setup. -5. Player selects a unit. -6. Briefing completion dispatches battle-begin events, including opening dialogue. -7. Unit moves, attacks, chooses a tactic or consumable item from the side menu, changes equipment, counters, gains EXP, levels up, promotes at class thresholds, or waits. Clicking an enemy that can be reached by moving first performs the combined move-and-attack action directly, while the map marks those targets with compact move-attack badges plus the chosen advance origin and attack line before the click; the selected attack origin favors defeat chances and low counter risk, and hover badges plus the forecast panel show possible counter danger even when a miss could cause it. Tactic buttons show MP cost, kind, range, area radius, and power, support, debuff, movement modifier, accuracy/evasion modifier, damage-over-time, or action-lock effect. Physical attacks roll hit chance from unit agility and target terrain avoid, add facing-based side/rear damage bonuses, and equipped weapons can add small damage bonuses against matching target move types; misses give reduced EXP. The scene plays placeholder SFX, a short movement slide, class-specific physical attack motion profiles, low-HP warning rings, hover target preview badges, area tactic overlays, an enemy threat overlay with physical damage and hostile tactic hints, selected/hover unit role, movement, facing, terrain, status, and equipment details, and floating combat text for core action and UI feedback. -8. Player ends turn. -9. Enemy AI moves, picks physical attacks from damage, defeat, priority, and weapon-effectiveness scores, and can cast single-target or area damage, healing, support, debuff, movement-modifier, accuracy/evasion-modifier, damage-over-time, or action-lock tactics through the same combat and skill resolvers. -10. Battle checks scenario-defined defeat conditions first, including commander loss and turn limits, then victory conditions such as enemy defeat or reaching a marked destination tile. -11. Turn-start, movement-triggered, and unit-defeat scenario events run as phases change, units enter marked cells, or units fall, can wait for prerequisite events, and may update objectives with HUD notices, grant item or gold pickups, deploy reinforcements, withdraw units from the battlefield, or change AI target priorities. -12. On first-time victory, `CampaignState` snapshots player progression, battle inventory, and battle-only gold pickups, applies rewards once, advances `current_scenario_id`, and writes `user://campaign_save.json`. -13. If the scenario defines post-battle dialogue, it plays once before the victory result panel opens. -14. The victory result panel splits rewards, roster changes, saved player growth, campaign status, and pending choices into sections. -15. If the scenario defines post-battle choices, the result panel waits for the player to select one, previews choice rewards including officer join/leave effects, then saves the selected flags and optional choice rewards before enabling the next battle. -16. The result panel can load the next scenario, passing saved roster and inventory overlays back into `BattleState`. -17. Once all campaign scenarios are complete, the battle scene shows a campaign completion panel instead of loading another map. +4. New campaigns first show a short title-to-briefing prologue using the battlefield backdrop and Cao Cao portrait, then continue into the first scenario briefing. +5. Show scenario briefing with the campaign chapter header, battle header, objective summary, chapter overview, selectable camp Talk conversations, manual checkpoint menu, and optional pre-battle shop, Armory, Roster, and Formation setup. +6. Player selects a unit. +7. Briefing completion dispatches battle-begin events, including opening dialogue. +8. Unit moves, attacks, chooses a tactic or consumable item from the side menu, changes equipment, counters, gains EXP, levels up, promotes at class thresholds, or waits. Clicking an enemy that can be reached by moving first performs the combined move-and-attack action directly, while the map marks those targets with compact move-attack badges plus the chosen advance origin and attack line before the click; the selected attack origin favors defeat chances and low counter risk, and hover badges plus the forecast panel show possible counter danger even when a miss could cause it. Tactic buttons show MP cost, kind, range, area radius, and power, support, debuff, movement modifier, accuracy/evasion modifier, damage-over-time, or action-lock effect. Physical attacks roll hit chance from unit agility and target terrain avoid, add facing-based side/rear damage bonuses, and equipped weapons can add small damage bonuses against matching target move types; misses give reduced EXP. The scene plays placeholder SFX, a short movement slide, class-specific physical attack motion profiles, low-HP warning rings, hover target preview badges, area tactic overlays, an enemy threat overlay with physical damage and hostile tactic hints, selected/hover unit role, movement, facing, terrain, status, and equipment details, and floating combat text for core action and UI feedback. +9. Player ends turn. +10. Enemy AI moves, picks physical attacks from damage, defeat, priority, and weapon-effectiveness scores, and can cast single-target or area damage, healing, support, debuff, movement-modifier, accuracy/evasion-modifier, damage-over-time, or action-lock tactics through the same combat and skill resolvers. +11. Battle checks scenario-defined defeat conditions first, including commander loss and turn limits, then victory conditions such as enemy defeat or reaching a marked destination tile. +12. Turn-start, movement-triggered, and unit-defeat scenario events run as phases change, units enter marked cells, or units fall, can wait for prerequisite events, and may update objectives with HUD notices, grant item or gold pickups, deploy reinforcements, withdraw units from the battlefield, or change AI target priorities. +13. On first-time victory, `CampaignState` snapshots player progression, battle inventory, and battle-only gold pickups, applies rewards once, advances `current_scenario_id`, and writes `user://campaign_save.json`. +14. If the scenario defines post-battle dialogue, it plays once before the victory result panel opens. +15. The victory result panel splits rewards, roster changes, saved player growth, campaign status, and pending choices into sections. +16. If the scenario defines post-battle choices, the result panel waits for the player to select one, previews choice rewards including officer join/leave effects, then saves the selected flags and optional choice rewards before enabling the next battle. +17. The result panel can load the next scenario, passing saved roster and inventory overlays back into `BattleState`. +18. Once all campaign scenarios are complete, the battle scene shows a campaign completion panel instead of loading another map. ## Save Boundary diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index d29015e..cbd4543 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -30,6 +30,20 @@ const TITLE_BACKGROUND_PATH := "res://art/backgrounds/battlefield_frontier.png" const TITLE_PORTRAIT_PATH := "res://art/portraits/cao_cao.png" const SETTINGS_PATH := "user://heros_settings.json" const SETTINGS_SAVE_VERSION := 1 +const OPENING_PROLOGUE_PAGES := [ + { + "title": "낙양의 그늘", + "body": "젊은 조조는 재주와 결단으로 이름을 얻었지만, 조정 안팎의 권세와 부패를 가까이 보며 법만으로는 세상을 붙들기 어렵다는 것을 알았다." + }, + { + "title": "하후돈과 첫 군세", + "body": "가문의 친척 하후돈은 먼저 창을 들고 조조의 길에 섰다. 두 사람은 큰 명성보다 무너지는 고을을 구하는 첫 군령을 택했다." + }, + { + "title": "영천으로", + "body": "황건의 깃발이 영천 들판에 섰다. 마을과 성채를 기대어 버티는 잔당을 꺾고, 백성이 빠져나갈 길을 열어야 한다." + } +] const SFX_BOW_RELEASE_PATH := "res://audio/sfx/bow_release_01.wav" const SFX_DEFEAT_STING_PATH := "res://audio/sfx/defeat_sting_01.wav" const SFX_FIRE_SKILL_PATH := "res://audio/sfx/fire_skill_01.wav" @@ -466,6 +480,13 @@ var top_save_button: Button var top_load_button: Button var restart_button: Button var new_campaign_button: Button +var opening_prologue_root: Control +var opening_prologue_title_label: Label +var opening_prologue_body_label: Label +var opening_prologue_step_label: Label +var opening_prologue_next_button: Button +var opening_prologue_skip_button: Button +var opening_prologue_index := 0 var title_screen_root: Control var title_background_texture_rect: TextureRect var title_portrait_texture_rect: TextureRect @@ -549,6 +570,16 @@ func _unhandled_input(event: InputEvent) -> void: _on_title_settings_back_pressed() return + if _is_opening_prologue_visible(): + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: + _on_opening_prologue_next_pressed() + elif event is InputEventKey and event.pressed and not event.echo: + if event.keycode == KEY_ESCAPE: + _on_opening_prologue_skip_pressed() + elif event.keycode == KEY_ENTER or event.keycode == KEY_SPACE or event.keycode == KEY_RIGHT: + _on_opening_prologue_next_pressed() + return + if _is_dialogue_visible(): if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: _advance_dialogue() @@ -2478,6 +2509,116 @@ func _create_hud() -> void: screen_backdrop = _make_screen_backdrop() root.add_child(screen_backdrop) + opening_prologue_root = Control.new() + opening_prologue_root.name = "OpeningPrologue" + opening_prologue_root.visible = false + opening_prologue_root.mouse_filter = Control.MOUSE_FILTER_STOP + opening_prologue_root.set_anchors_preset(Control.PRESET_FULL_RECT) + root.add_child(opening_prologue_root) + + var opening_background := TextureRect.new() + opening_background.name = "OpeningBattlefieldBackdrop" + opening_background.mouse_filter = Control.MOUSE_FILTER_IGNORE + opening_background.set_anchors_preset(Control.PRESET_FULL_RECT) + opening_background.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + opening_background.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED + opening_background.texture = _load_art_texture(TITLE_BACKGROUND_PATH) + opening_background.modulate = Color(0.76, 0.62, 0.43, 0.98) + opening_prologue_root.add_child(opening_background) + + var opening_dim := ColorRect.new() + opening_dim.name = "OpeningInkWash" + opening_dim.color = Color(0.030, 0.016, 0.008, 0.62) + opening_dim.mouse_filter = Control.MOUSE_FILTER_IGNORE + opening_dim.set_anchors_preset(Control.PRESET_FULL_RECT) + opening_prologue_root.add_child(opening_dim) + + var opening_panel := PanelContainer.new() + opening_panel.name = "OpeningProloguePanel" + opening_panel.position = Vector2(104, 58) + opening_panel.size = Vector2(1072, 604) + opening_panel.custom_minimum_size = Vector2(1072, 604) + _apply_panel_style(opening_panel, "paper") + opening_prologue_root.add_child(opening_panel) + + var opening_shell := HBoxContainer.new() + opening_shell.custom_minimum_size = Vector2(1016, 548) + opening_shell.add_theme_constant_override("separation", 18) + opening_panel.add_child(opening_shell) + opening_shell.add_child(_make_bamboo_gutter(22, 548)) + + var opening_portrait := TextureRect.new() + opening_portrait.name = "OpeningCaoCaoPortrait" + opening_portrait.custom_minimum_size = Vector2(356, 548) + opening_portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + opening_portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED + opening_portrait.texture = _load_art_texture(TITLE_PORTRAIT_PATH) + opening_portrait.modulate = Color(1.0, 0.92, 0.80, 0.96) + opening_portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE + opening_shell.add_child(opening_portrait) + + var opening_story_column := VBoxContainer.new() + opening_story_column.custom_minimum_size = Vector2(586, 548) + opening_story_column.add_theme_constant_override("separation", 14) + opening_shell.add_child(opening_story_column) + opening_shell.add_child(_make_bamboo_gutter(22, 548)) + + opening_story_column.add_child(_make_scroll_rod(586, 6)) + opening_story_column.add_child(_make_seal_ribbon(586, 18)) + + opening_prologue_title_label = Label.new() + opening_prologue_title_label.custom_minimum_size = Vector2(586, 52) + opening_prologue_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + opening_prologue_title_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _apply_label_style(opening_prologue_title_label, UI_PARCHMENT_TEXT, 26) + opening_story_column.add_child(opening_prologue_title_label) + + var opening_body_panel := PanelContainer.new() + opening_body_panel.custom_minimum_size = Vector2(586, 250) + _apply_panel_style(opening_body_panel, "edict") + opening_story_column.add_child(opening_body_panel) + + var opening_body_row := HBoxContainer.new() + opening_body_row.add_theme_constant_override("separation", 10) + opening_body_panel.add_child(opening_body_row) + opening_body_row.add_child(_make_edict_marker_stack(["", "", ""], 206)) + + opening_prologue_body_label = Label.new() + opening_prologue_body_label.custom_minimum_size = Vector2(486, 206) + opening_prologue_body_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + opening_prologue_body_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _apply_label_style(opening_prologue_body_label, UI_AGED_INK, 20) + opening_body_row.add_child(opening_prologue_body_label) + opening_body_row.add_child(_make_edict_marker_stack(["", "", ""], 206)) + + opening_prologue_step_label = Label.new() + opening_prologue_step_label.custom_minimum_size = Vector2(586, 26) + opening_prologue_step_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + opening_prologue_step_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _apply_label_style(opening_prologue_step_label, UI_OLD_BRONZE, 14) + opening_story_column.add_child(opening_prologue_step_label) + + var opening_button_row := HBoxContainer.new() + opening_button_row.custom_minimum_size = Vector2(586, 44) + opening_button_row.add_theme_constant_override("separation", 10) + opening_story_column.add_child(opening_button_row) + + opening_prologue_skip_button = Button.new() + opening_prologue_skip_button.text = "건너뛰기" + opening_prologue_skip_button.custom_minimum_size = Vector2(188, 42) + opening_prologue_skip_button.tooltip_text = "오프닝을 닫고 군막으로 이동합니다." + opening_prologue_skip_button.pressed.connect(_on_opening_prologue_skip_pressed) + opening_button_row.add_child(opening_prologue_skip_button) + + opening_prologue_next_button = Button.new() + opening_prologue_next_button.text = "다음" + opening_prologue_next_button.custom_minimum_size = Vector2(388, 42) + opening_prologue_next_button.tooltip_text = "다음 이야기로 넘깁니다." + opening_prologue_next_button.pressed.connect(_on_opening_prologue_next_pressed) + opening_button_row.add_child(opening_prologue_next_button) + + opening_story_column.add_child(_make_scroll_rod(586, 5)) + briefing_panel = PanelContainer.new() briefing_panel.visible = false briefing_panel.position = Vector2(212, 16) @@ -3281,6 +3422,8 @@ func _create_hud() -> void: dialogue_continue_button, result_restart_button, next_battle_button, + opening_prologue_skip_button, + opening_prologue_next_button, title_start_button, title_load_button, title_settings_button, @@ -10039,6 +10182,7 @@ func _on_battle_unit_list_row_pressed(unit_id: String) -> void: func _show_title_menu() -> void: title_screen_visible = true _play_bgm("menu") + _hide_opening_prologue() _clear_dialogue() _hide_tactic_menu() _hide_item_menu() @@ -10076,6 +10220,78 @@ func _is_title_screen_visible() -> bool: return title_screen_visible and title_screen_root != null and title_screen_root.visible +func _show_opening_prologue() -> void: + _hide_title_menu() + if briefing_panel != null: + briefing_panel.visible = false + if result_panel != null: + result_panel.visible = false + opening_prologue_index = 0 + _update_opening_prologue_page() + if opening_prologue_root != null: + opening_prologue_root.visible = true + opening_prologue_root.move_to_front() + _play_bgm("menu") + _refresh_screen_backdrop_visibility() + queue_redraw() + + +func _hide_opening_prologue() -> void: + if opening_prologue_root != null: + opening_prologue_root.visible = false + + +func _is_opening_prologue_visible() -> bool: + return opening_prologue_root != null and opening_prologue_root.visible + + +func _should_show_opening_prologue() -> bool: + return ( + campaign_state.current_scenario_id == campaign_state.get_start_scenario_id() + and campaign_state.completed_scenarios.is_empty() + ) + + +func _update_opening_prologue_page() -> void: + var page_count := OPENING_PROLOGUE_PAGES.size() + if page_count <= 0: + return + opening_prologue_index = mini(maxi(opening_prologue_index, 0), page_count - 1) + var page: Dictionary = OPENING_PROLOGUE_PAGES[opening_prologue_index] + if opening_prologue_title_label != null: + opening_prologue_title_label.text = str(page.get("title", "")) + if opening_prologue_body_label != null: + opening_prologue_body_label.text = str(page.get("body", "")) + if opening_prologue_step_label != null: + opening_prologue_step_label.text = "이야기 %d/%d" % [opening_prologue_index + 1, page_count] + if opening_prologue_next_button != null: + opening_prologue_next_button.text = "군막으로" if opening_prologue_index >= page_count - 1 else "다음" + opening_prologue_next_button.tooltip_text = "군막으로 이동합니다." if opening_prologue_index >= page_count - 1 else "다음 이야기로 넘깁니다." + + +func _on_opening_prologue_next_pressed() -> void: + if not _is_opening_prologue_visible(): + return + _play_ui_confirm() + if opening_prologue_index >= OPENING_PROLOGUE_PAGES.size() - 1: + _finish_opening_prologue() + return + opening_prologue_index += 1 + _update_opening_prologue_page() + + +func _on_opening_prologue_skip_pressed() -> void: + if not _is_opening_prologue_visible(): + return + _play_ui_cancel() + _finish_opening_prologue() + + +func _finish_opening_prologue() -> void: + _hide_opening_prologue() + _enter_campaign_from_title() + + func _update_title_menu_state() -> void: var has_load := campaign_state.has_save() or campaign_state.has_manual_save() if title_load_button != null: @@ -10124,6 +10340,9 @@ func _on_title_start_pressed() -> void: if title_status_label != null: title_status_label.text = "새 전기를 열지 못했습니다. 저장 권한을 확인해 주세요." return + if _should_show_opening_prologue(): + _show_opening_prologue() + return _enter_campaign_from_title() @@ -10145,6 +10364,7 @@ func _on_title_load_pressed() -> void: func _enter_campaign_from_title() -> void: + _hide_opening_prologue() _hide_title_menu() if log_box != null: log_box.clear() @@ -11113,6 +11333,7 @@ func _is_input_locked() -> bool: return ( campaign_complete_screen or _is_title_screen_visible() + or _is_opening_prologue_visible() or _is_dialogue_visible() or _is_auto_end_turn_prompt_visible() or _has_active_battle_presentation_sequence() @@ -11688,6 +11909,10 @@ func _on_new_campaign_pressed() -> void: _hide_roster_menu() _hide_formation_menu() _hide_save_menu() + if _should_show_opening_prologue(): + _show_opening_prologue() + _update_hud() + return _load_current_battle() _show_briefing() _update_hud() diff --git a/tools/smoke_title_menu.gd b/tools/smoke_title_menu.gd index 063cfeb..b5ef59f 100644 --- a/tools/smoke_title_menu.gd +++ b/tools/smoke_title_menu.gd @@ -100,8 +100,22 @@ func _check_title_menu_start_load_and_settings(failures: Array[String]) -> void: scene._on_title_start_pressed() if scene.title_screen_root != null and scene.title_screen_root.visible: failures.append("starting a new game should hide the title screen") + if not scene._is_opening_prologue_visible(): + failures.append("starting a new campaign should show the opening prologue before briefing") + if scene.briefing_panel != null and scene.briefing_panel.visible: + failures.append("opening prologue should hold back the first briefing until advanced") + if scene.opening_prologue_title_label == null or scene.opening_prologue_body_label == null: + failures.append("opening prologue should expose title and story text") + elif not scene.opening_prologue_body_label.text.contains("조조"): + failures.append("opening prologue should first explain Cao Cao before the Yellow Turban battle") + var prologue_guard := 0 + while scene._is_opening_prologue_visible() and prologue_guard < 8: + scene._on_opening_prologue_next_pressed() + prologue_guard += 1 + if scene._is_opening_prologue_visible(): + failures.append("opening prologue should close after advancing through its pages") if scene.briefing_panel == null or not scene.briefing_panel.visible: - failures.append("starting a new game should enter the first briefing") + failures.append("finishing the opening prologue should enter the first briefing") if not scene.campaign_state.has_save(): failures.append("starting a new game should create the main campaign save") @@ -112,8 +126,16 @@ func _check_title_menu_start_load_and_settings(failures: Array[String]) -> void: scene._on_title_load_pressed() if scene.title_screen_root != null and scene.title_screen_root.visible: failures.append("loading a saved game should hide the title screen") + if scene._is_opening_prologue_visible(): + failures.append("loading a saved game should skip the new-campaign opening prologue") if scene.briefing_panel == null or not scene.briefing_panel.visible: failures.append("loading a saved game should enter the current campaign briefing") + scene._on_new_campaign_pressed() + if not scene._is_opening_prologue_visible(): + failures.append("top New Campaign should also show the opening prologue") + scene._on_opening_prologue_skip_pressed() + if scene.briefing_panel == null or not scene.briefing_panel.visible: + failures.append("skipping the top New Campaign prologue should enter the first briefing") scene.free()