diff --git a/README.md b/README.md index 9b34840..8904d9b 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Pre-battle briefing shows the campaign chapter, chapter battle number, location, victory/defeat summary, and first-clear reward preview. - Pre-battle Chapters overview shows story-arc progress and can open completed or current battles. - Pre-battle Save menu can write and load one manual campaign checkpoint separate from the automatic save. +- The title screen exposes Start, Load, and Settings, and Load now lets the player choose between the automatic record and the manual checkpoint. - Escort scenarios can include required, non-controllable protected units. - Campaign saves joined officers, future deployments can require prior recruitment, and choices can make officers leave. - Completed saves reconcile officer join/leave rewards on load without replaying gold or items. diff --git a/scripts/core/campaign_state.gd b/scripts/core/campaign_state.gd index 1acfe02..440c7c8 100644 --- a/scripts/core/campaign_state.gd +++ b/scripts/core/campaign_state.gd @@ -191,10 +191,18 @@ func load_manual_game() -> bool: return false +func get_save_summary() -> Dictionary: + return _get_save_summary_from_path(SAVE_PATH) + + func get_manual_save_summary() -> Dictionary: - if not has_manual_save(): + return _get_save_summary_from_path(MANUAL_SAVE_PATH) + + +func _get_save_summary_from_path(path: String) -> Dictionary: + if not FileAccess.file_exists(path): return {} - var parsed = JSON.parse_string(FileAccess.get_file_as_string(MANUAL_SAVE_PATH)) + var parsed = JSON.parse_string(FileAccess.get_file_as_string(path)) if typeof(parsed) != TYPE_DICTIONARY: return {} var completed_count := 0 diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 3c6d1c5..8ddc394 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -507,6 +507,10 @@ var title_start_button: Button var title_load_button: Button var title_settings_button: Button var title_status_label: Label +var title_load_panel: PanelContainer +var title_load_auto_button: Button +var title_load_manual_button: Button +var title_load_back_button: Button var title_settings_panel: PanelContainer var title_bgm_toggle: CheckButton var title_sfx_toggle: CheckButton @@ -574,10 +578,13 @@ func _ready() -> void: func _unhandled_input(event: InputEvent) -> void: if _is_title_screen_visible(): if event is InputEventKey and event.pressed and not event.echo: - if event.keycode == KEY_ENTER: + if event.keycode == KEY_ESCAPE: + if _is_title_load_panel_visible(): + _on_title_load_back_pressed() + elif title_settings_panel != null and title_settings_panel.visible: + _on_title_settings_back_pressed() + elif event.keycode == KEY_ENTER and not _is_title_load_panel_visible() and not _is_title_settings_panel_visible(): _on_title_start_pressed() - elif event.keycode == KEY_ESCAPE and title_settings_panel != null and title_settings_panel.visible: - _on_title_settings_back_pressed() return if _is_opening_prologue_visible(): @@ -3416,6 +3423,43 @@ func _create_hud() -> void: title_settings_button.pressed.connect(_on_title_settings_pressed) title_menu_column.add_child(title_settings_button) + title_load_panel = PanelContainer.new() + title_load_panel.visible = false + title_load_panel.custom_minimum_size = Vector2(432, 188) + _apply_panel_style(title_load_panel, "hud_info") + title_menu_column.add_child(title_load_panel) + + var title_load_column := VBoxContainer.new() + title_load_column.add_theme_constant_override("separation", 8) + title_load_panel.add_child(title_load_column) + + var title_load_caption := Label.new() + title_load_caption.text = "불러올 기록" + title_load_caption.custom_minimum_size = Vector2(392, 24) + title_load_caption.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title_load_caption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _apply_label_style(title_load_caption, UI_OLD_BRONZE, 15) + title_load_column.add_child(title_load_caption) + + title_load_auto_button = Button.new() + title_load_auto_button.custom_minimum_size = Vector2(392, 38) + title_load_auto_button.tooltip_text = "자동으로 봉인된 진행 기록을 엽니다." + title_load_auto_button.pressed.connect(_on_title_load_auto_pressed) + title_load_column.add_child(title_load_auto_button) + + title_load_manual_button = Button.new() + title_load_manual_button.custom_minimum_size = Vector2(392, 38) + title_load_manual_button.tooltip_text = "군막에서 직접 남긴 수동 봉인을 엽니다." + title_load_manual_button.pressed.connect(_on_title_load_manual_pressed) + title_load_column.add_child(title_load_manual_button) + + title_load_back_button = Button.new() + title_load_back_button.text = "뒤로" + title_load_back_button.custom_minimum_size = Vector2(392, 34) + title_load_back_button.tooltip_text = "처음 화면 선택으로 돌아갑니다." + title_load_back_button.pressed.connect(_on_title_load_back_pressed) + title_load_column.add_child(title_load_back_button) + title_settings_panel = PanelContainer.new() title_settings_panel.visible = false title_settings_panel.custom_minimum_size = Vector2(432, 236) @@ -3572,6 +3616,9 @@ func _create_hud() -> void: title_start_button, title_load_button, title_settings_button, + title_load_auto_button, + title_load_manual_button, + title_load_back_button, title_settings_back_button ]: _apply_button_style(hud_button) @@ -10451,6 +10498,7 @@ func _show_title_menu() -> void: if title_screen_root != null: title_screen_root.visible = true title_screen_root.move_to_front() + _set_title_load_panel_visible(false) _set_title_settings_visible(false) _update_title_menu_state() _refresh_screen_backdrop_visibility() @@ -10544,9 +10592,10 @@ func _update_title_menu_state() -> void: var has_load := campaign_state.has_save() or campaign_state.has_manual_save() if title_load_button != null: title_load_button.disabled = not has_load - title_load_button.tooltip_text = "저장된 전기를 불러옵니다." if has_load else "저장된 전기가 없습니다." + title_load_button.tooltip_text = "불러올 기록을 선택합니다." if has_load else "저장된 전기가 없습니다." if title_status_label != null: title_status_label.text = _format_title_status_text(has_load) + _update_title_load_panel_state() if title_settings_back_button != null: title_settings_back_button.visible = title_settings_panel != null and title_settings_panel.visible if title_bgm_toggle != null: @@ -10558,20 +10607,55 @@ func _update_title_menu_state() -> void: _refresh_title_volume_controls() +func _update_title_load_panel_state() -> void: + if title_load_auto_button != null: + var auto_summary := campaign_state.get_save_summary() + var has_auto := not auto_summary.is_empty() + title_load_auto_button.disabled = not has_auto + title_load_auto_button.text = _format_title_load_button_text(auto_summary, "자동 기록", "자동 기록 없음") + title_load_auto_button.tooltip_text = _format_title_load_button_tooltip(auto_summary, "자동으로 봉인된 진행 기록입니다.") + if title_load_manual_button != null: + var manual_summary := campaign_state.get_manual_save_summary() + var has_manual := not manual_summary.is_empty() + title_load_manual_button.disabled = not has_manual + title_load_manual_button.text = _format_title_load_button_text(manual_summary, "수동 봉인", "수동 봉인 없음") + title_load_manual_button.tooltip_text = _format_title_load_button_tooltip(manual_summary, "군막에서 직접 남긴 기록입니다.") + + +func _format_title_load_button_text(summary: Dictionary, label: String, empty_text: String) -> String: + if summary.is_empty(): + return empty_text + var title := str(summary.get("current_scenario_title", "전기")) + if title.is_empty(): + title = "전기" + return "%s: %s" % [label, title] + + +func _format_title_load_button_tooltip(summary: Dictionary, header: String) -> String: + if summary.is_empty(): + return "%s\n저장된 기록이 없습니다." % header + var state_label := "전후 선택 대기" if bool(summary.get("pending_choice", false)) else "군막 또는 전장 진입 전" + return "%s\n진행 %d/%d · 군자금 %d냥 · %s" % [ + header, + int(summary.get("completed_count", 0)), + int(summary.get("total_count", 0)), + int(summary.get("gold", 0)), + state_label + ] + + func _format_title_status_text(has_load: bool) -> String: if not has_load: return "새 전기를 열어 조조와 하후돈의 첫 군령을 시작합니다." - var summary := campaign_state.get_manual_save_summary() - if campaign_state.has_save(): - var title := campaign_state.get_scenario_title(campaign_state.current_scenario_id) - if title.is_empty(): - title = campaign_state.get_scenario_title(campaign_state.get_start_scenario_id()) + var auto_summary := campaign_state.get_save_summary() + if not auto_summary.is_empty(): return "이어 할 전기: %s · %d/%d 봉인 · %d냥" % [ - title, - campaign_state.completed_scenarios.size(), - campaign_state.get_scenario_count(), - campaign_state.gold + str(auto_summary.get("current_scenario_title", "전기")), + int(auto_summary.get("completed_count", 0)), + int(auto_summary.get("total_count", 0)), + int(auto_summary.get("gold", 0)) ] + var summary := campaign_state.get_manual_save_summary() if not summary.is_empty(): return "수동 봉인: %s · %d/%d 봉인 · %d냥" % [ str(summary.get("current_scenario_title", "전기")), @@ -10598,15 +10682,36 @@ func _on_title_load_pressed() -> void: if title_load_button != null and title_load_button.disabled: return _play_ui_confirm() - var loaded := false - if campaign_state.has_save(): - loaded = campaign_state.load_game() - elif campaign_state.has_manual_save(): - loaded = campaign_state.load_manual_game() + _set_title_load_panel_visible(true) + _update_title_menu_state() + + +func _on_title_load_auto_pressed() -> void: + _load_title_slot(false) + + +func _on_title_load_manual_pressed() -> void: + _load_title_slot(true) + + +func _on_title_load_back_pressed() -> void: + _play_ui_cancel() + _set_title_load_panel_visible(false) + _update_title_menu_state() + + +func _load_title_slot(manual: bool) -> void: + if manual and title_load_manual_button != null and title_load_manual_button.disabled: + return + if not manual and title_load_auto_button != null and title_load_auto_button.disabled: + return + _play_ui_confirm() + var loaded := campaign_state.load_manual_game() if manual else campaign_state.load_game() if not loaded: if title_status_label != null: title_status_label.text = "저장된 전기를 열지 못했습니다." - _update_title_menu_state() + _set_title_load_panel_visible(false) + _update_title_load_panel_state() return _enter_campaign_from_title() @@ -10642,6 +10747,8 @@ func _on_title_settings_back_pressed() -> void: func _set_title_settings_visible(visible: bool) -> void: + if visible and title_load_panel != null: + title_load_panel.visible = false if title_status_label != null: title_status_label.visible = not visible if title_start_button != null: @@ -10656,6 +10763,31 @@ func _set_title_settings_visible(visible: bool) -> void: title_settings_back_button.visible = visible +func _set_title_load_panel_visible(visible: bool) -> void: + if visible and title_settings_panel != null: + title_settings_panel.visible = false + if title_status_label != null: + title_status_label.visible = not visible + if title_start_button != null: + title_start_button.visible = not visible + if title_load_button != null: + title_load_button.visible = not visible + if title_settings_button != null: + title_settings_button.visible = not visible + if title_load_panel != null: + title_load_panel.visible = visible + if title_settings_back_button != null: + title_settings_back_button.visible = false + + +func _is_title_load_panel_visible() -> bool: + return title_load_panel != null and title_load_panel.visible + + +func _is_title_settings_panel_visible() -> bool: + return title_settings_panel != null and title_settings_panel.visible + + func _on_title_bgm_toggled(enabled: bool) -> void: title_bgm_enabled = enabled if title_bgm_enabled: diff --git a/tools/smoke_title_menu.gd b/tools/smoke_title_menu.gd index 3d19179..e4b86ea 100644 --- a/tools/smoke_title_menu.gd +++ b/tools/smoke_title_menu.gd @@ -57,6 +57,10 @@ func _check_title_menu_start_load_and_settings(failures: Array[String]) -> void: failures.append("title screen should expose Game Load") elif not scene.title_load_button.disabled: failures.append("Game Load should be disabled when no save exists") + if scene.title_load_panel == null: + failures.append("title screen should create a load-slot selection panel") + elif scene.title_load_panel.visible: + failures.append("load-slot selection panel should stay hidden until Game Load is pressed") if scene.title_settings_button == null or scene.title_settings_button.text != "환경 설정": failures.append("title screen should expose Settings") @@ -118,12 +122,35 @@ func _check_title_menu_start_load_and_settings(failures: Array[String]) -> void: 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") + if not scene.campaign_state.save_manual_game(): + failures.append("manual checkpoint should be writable for title load selection") scene._show_title_menu() scene._update_title_menu_state() if scene.title_load_button == null or scene.title_load_button.disabled: failures.append("Game Load should become available after a campaign save exists") scene._on_title_load_pressed() + if scene.title_load_panel == null or not scene.title_load_panel.visible: + failures.append("Game Load should open a load-slot selection panel instead of loading immediately") + if scene.title_start_button != null and scene.title_start_button.visible: + failures.append("load-slot selection should replace the main title commands while open") + if scene.title_load_auto_button == null or scene.title_load_auto_button.disabled: + failures.append("load-slot selection should enable the automatic campaign save") + elif not scene.title_load_auto_button.text.contains("자동 기록"): + failures.append("automatic load slot should be labeled in Korean") + if scene.title_load_manual_button == null or scene.title_load_manual_button.disabled: + failures.append("load-slot selection should enable the manual checkpoint save") + elif not scene.title_load_manual_button.text.contains("수동 봉인"): + failures.append("manual load slot should be labeled in Korean") + scene._show_title_menu() + if scene.title_load_panel != null and scene.title_load_panel.visible: + failures.append("showing the title menu again should reset the load-slot panel") + scene._on_title_load_pressed() + scene._on_title_load_back_pressed() + if scene.title_load_panel != null and scene.title_load_panel.visible: + failures.append("load-slot back should close the title load panel") + scene._on_title_load_pressed() + scene._on_title_load_auto_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():