Add title load slot selection

This commit is contained in:
2026-06-19 23:51:19 +09:00
parent 0e52fdd68f
commit c410b06d6c
4 changed files with 189 additions and 21 deletions

View File

@@ -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 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 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. - 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. - 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. - 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. - Completed saves reconcile officer join/leave rewards on load without replaying gold or items.

View File

@@ -191,10 +191,18 @@ func load_manual_game() -> bool:
return false return false
func get_save_summary() -> Dictionary:
return _get_save_summary_from_path(SAVE_PATH)
func get_manual_save_summary() -> Dictionary: 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 {} 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: if typeof(parsed) != TYPE_DICTIONARY:
return {} return {}
var completed_count := 0 var completed_count := 0

View File

@@ -507,6 +507,10 @@ var title_start_button: Button
var title_load_button: Button var title_load_button: Button
var title_settings_button: Button var title_settings_button: Button
var title_status_label: Label 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_settings_panel: PanelContainer
var title_bgm_toggle: CheckButton var title_bgm_toggle: CheckButton
var title_sfx_toggle: CheckButton var title_sfx_toggle: CheckButton
@@ -574,10 +578,13 @@ func _ready() -> void:
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if _is_title_screen_visible(): if _is_title_screen_visible():
if event is InputEventKey and event.pressed and not event.echo: 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() _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 return
if _is_opening_prologue_visible(): if _is_opening_prologue_visible():
@@ -3416,6 +3423,43 @@ func _create_hud() -> void:
title_settings_button.pressed.connect(_on_title_settings_pressed) title_settings_button.pressed.connect(_on_title_settings_pressed)
title_menu_column.add_child(title_settings_button) 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 = PanelContainer.new()
title_settings_panel.visible = false title_settings_panel.visible = false
title_settings_panel.custom_minimum_size = Vector2(432, 236) title_settings_panel.custom_minimum_size = Vector2(432, 236)
@@ -3572,6 +3616,9 @@ func _create_hud() -> void:
title_start_button, title_start_button,
title_load_button, title_load_button,
title_settings_button, title_settings_button,
title_load_auto_button,
title_load_manual_button,
title_load_back_button,
title_settings_back_button title_settings_back_button
]: ]:
_apply_button_style(hud_button) _apply_button_style(hud_button)
@@ -10451,6 +10498,7 @@ func _show_title_menu() -> void:
if title_screen_root != null: if title_screen_root != null:
title_screen_root.visible = true title_screen_root.visible = true
title_screen_root.move_to_front() title_screen_root.move_to_front()
_set_title_load_panel_visible(false)
_set_title_settings_visible(false) _set_title_settings_visible(false)
_update_title_menu_state() _update_title_menu_state()
_refresh_screen_backdrop_visibility() _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() var has_load := campaign_state.has_save() or campaign_state.has_manual_save()
if title_load_button != null: if title_load_button != null:
title_load_button.disabled = not has_load 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: if title_status_label != null:
title_status_label.text = _format_title_status_text(has_load) title_status_label.text = _format_title_status_text(has_load)
_update_title_load_panel_state()
if title_settings_back_button != null: if title_settings_back_button != null:
title_settings_back_button.visible = title_settings_panel != null and title_settings_panel.visible title_settings_back_button.visible = title_settings_panel != null and title_settings_panel.visible
if title_bgm_toggle != null: if title_bgm_toggle != null:
@@ -10558,20 +10607,55 @@ func _update_title_menu_state() -> void:
_refresh_title_volume_controls() _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: func _format_title_status_text(has_load: bool) -> String:
if not has_load: if not has_load:
return "새 전기를 열어 조조와 하후돈의 첫 군령을 시작합니다." return "새 전기를 열어 조조와 하후돈의 첫 군령을 시작합니다."
var summary := campaign_state.get_manual_save_summary() var auto_summary := campaign_state.get_save_summary()
if campaign_state.has_save(): if not auto_summary.is_empty():
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())
return "이어 할 전기: %s · %d/%d 봉인 · %d" % [ return "이어 할 전기: %s · %d/%d 봉인 · %d" % [
title, str(auto_summary.get("current_scenario_title", "전기")),
campaign_state.completed_scenarios.size(), int(auto_summary.get("completed_count", 0)),
campaign_state.get_scenario_count(), int(auto_summary.get("total_count", 0)),
campaign_state.gold int(auto_summary.get("gold", 0))
] ]
var summary := campaign_state.get_manual_save_summary()
if not summary.is_empty(): if not summary.is_empty():
return "수동 봉인: %s · %d/%d 봉인 · %d" % [ return "수동 봉인: %s · %d/%d 봉인 · %d" % [
str(summary.get("current_scenario_title", "전기")), 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: if title_load_button != null and title_load_button.disabled:
return return
_play_ui_confirm() _play_ui_confirm()
var loaded := false _set_title_load_panel_visible(true)
if campaign_state.has_save(): _update_title_menu_state()
loaded = campaign_state.load_game()
elif campaign_state.has_manual_save():
loaded = campaign_state.load_manual_game() 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 not loaded:
if title_status_label != null: if title_status_label != null:
title_status_label.text = "저장된 전기를 열지 못했습니다." title_status_label.text = "저장된 전기를 열지 못했습니다."
_update_title_menu_state() _set_title_load_panel_visible(false)
_update_title_load_panel_state()
return return
_enter_campaign_from_title() _enter_campaign_from_title()
@@ -10642,6 +10747,8 @@ func _on_title_settings_back_pressed() -> void:
func _set_title_settings_visible(visible: bool) -> 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: if title_status_label != null:
title_status_label.visible = not visible title_status_label.visible = not visible
if title_start_button != null: if title_start_button != null:
@@ -10656,6 +10763,31 @@ func _set_title_settings_visible(visible: bool) -> void:
title_settings_back_button.visible = visible 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: func _on_title_bgm_toggled(enabled: bool) -> void:
title_bgm_enabled = enabled title_bgm_enabled = enabled
if title_bgm_enabled: if title_bgm_enabled:

View File

@@ -57,6 +57,10 @@ func _check_title_menu_start_load_and_settings(failures: Array[String]) -> void:
failures.append("title screen should expose Game Load") failures.append("title screen should expose Game Load")
elif not scene.title_load_button.disabled: elif not scene.title_load_button.disabled:
failures.append("Game Load should be disabled when no save exists") 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 != "환경 설정": if scene.title_settings_button == null or scene.title_settings_button.text != "환경 설정":
failures.append("title screen should expose Settings") 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") failures.append("finishing the opening prologue should enter the first briefing")
if not scene.campaign_state.has_save(): if not scene.campaign_state.has_save():
failures.append("starting a new game should create the main campaign 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._show_title_menu()
scene._update_title_menu_state() scene._update_title_menu_state()
if scene.title_load_button == null or scene.title_load_button.disabled: if scene.title_load_button == null or scene.title_load_button.disabled:
failures.append("Game Load should become available after a campaign save exists") failures.append("Game Load should become available after a campaign save exists")
scene._on_title_load_pressed() 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: if scene.title_screen_root != null and scene.title_screen_root.visible:
failures.append("loading a saved game should hide the title screen") failures.append("loading a saved game should hide the title screen")
if scene._is_opening_prologue_visible(): if scene._is_opening_prologue_visible():