Add manual campaign checkpoint saves

This commit is contained in:
2026-06-18 04:21:55 +09:00
parent 87aed5a449
commit cf09306b8e
9 changed files with 340 additions and 95 deletions

View File

@@ -13,21 +13,21 @@ const SKILL_OVERLAY_COLOR := Color(0.56, 0.28, 0.95, 0.28)
const ITEM_OVERLAY_COLOR := Color(0.24, 0.74, 0.34, 0.30)
const FORMATION_OVERLAY_COLOR := Color(0.95, 0.76, 0.25, 0.34)
const OBJECTIVE_OVERLAY_COLOR := Color(1.0, 0.78, 0.18, 0.30)
const BGM_MENU := preload("res://audio/bgm/menu_theme_placeholder.wav")
const BGM_BATTLE := preload("res://audio/bgm/battle_loop_placeholder.wav")
const SFX_BOW_RELEASE := preload("res://audio/sfx/bow_release_01.wav")
const SFX_DEFEAT_STING := preload("res://audio/sfx/defeat_sting_01.wav")
const SFX_FIRE_SKILL := preload("res://audio/sfx/fire_skill_01.wav")
const SFX_FOOTSTEP := preload("res://audio/sfx/footstep_armor_01.wav")
const SFX_GUARD_BLOCK := preload("res://audio/sfx/guard_block_01.wav")
const SFX_HIT_HEAVY := preload("res://audio/sfx/hit_heavy_01.wav")
const SFX_ITEM_PICKUP := preload("res://audio/sfx/item_pickup_01.wav")
const SFX_MENU_CANCEL := preload("res://audio/sfx/menu_cancel_01.wav")
const SFX_MENU_CONFIRM := preload("res://audio/sfx/menu_confirm_01.wav")
const SFX_SKILL_CAST := preload("res://audio/sfx/skill_cast_01.wav")
const SFX_SLASH := preload("res://audio/sfx/slash_01.wav")
const SFX_UI_CLICK := preload("res://audio/sfx/ui_click_01.wav")
const SFX_VICTORY_STING := preload("res://audio/sfx/victory_sting_01.wav")
const BGM_MENU_PATH := "res://audio/bgm/menu_theme_placeholder.wav"
const BGM_BATTLE_PATH := "res://audio/bgm/battle_loop_placeholder.wav"
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"
const SFX_FOOTSTEP_PATH := "res://audio/sfx/footstep_armor_01.wav"
const SFX_GUARD_BLOCK_PATH := "res://audio/sfx/guard_block_01.wav"
const SFX_HIT_HEAVY_PATH := "res://audio/sfx/hit_heavy_01.wav"
const SFX_ITEM_PICKUP_PATH := "res://audio/sfx/item_pickup_01.wav"
const SFX_MENU_CANCEL_PATH := "res://audio/sfx/menu_cancel_01.wav"
const SFX_MENU_CONFIRM_PATH := "res://audio/sfx/menu_confirm_01.wav"
const SFX_SKILL_CAST_PATH := "res://audio/sfx/skill_cast_01.wav"
const SFX_SLASH_PATH := "res://audio/sfx/slash_01.wav"
const SFX_UI_CLICK_PATH := "res://audio/sfx/ui_click_01.wav"
const SFX_VICTORY_STING_PATH := "res://audio/sfx/victory_sting_01.wav"
const BGM_VOLUME_DB := -14.0
const SFX_VOLUME_DB := -4.0
const UI_SFX_VOLUME_DB := -8.0
@@ -90,6 +90,11 @@ var formation_menu: VBoxContainer
var formation_list: VBoxContainer
var formation_status_label: Label
var formation_unit_id := ""
var save_button: Button
var save_menu: VBoxContainer
var save_status_label: Label
var manual_save_button: Button
var manual_load_button: Button
var dialogue_row: HBoxContainer
var dialogue_panel: PanelContainer
var dialogue_portrait_panel: PanelContainer
@@ -123,6 +128,7 @@ var active_dialogue_lines: Array = []
var active_dialogue_index := 0
var bgm_player: AudioStreamPlayer
var current_bgm_key := ""
var audio_stream_cache := {}
var last_announced_battle_status := ""
var floating_texts: Array[Dictionary] = []
var unit_motion_by_unit: Dictionary = {}
@@ -383,6 +389,11 @@ func _create_hud() -> void:
formation_button.pressed.connect(_on_formation_pressed)
prep_button_row.add_child(formation_button)
save_button = Button.new()
save_button.text = "Save"
save_button.pressed.connect(_on_save_pressed)
prep_button_row.add_child(save_button)
chapter_menu = VBoxContainer.new()
chapter_menu.visible = false
chapter_menu.add_theme_constant_override("separation", 6)
@@ -492,6 +503,30 @@ func _create_hud() -> void:
formation_list.add_theme_constant_override("separation", 4)
formation_scroll.add_child(formation_list)
save_menu = VBoxContainer.new()
save_menu.visible = false
save_menu.add_theme_constant_override("separation", 6)
briefing_column.add_child(save_menu)
save_status_label = Label.new()
save_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
save_status_label.custom_minimum_size = Vector2(600, 64)
save_menu.add_child(save_status_label)
var save_action_row := HBoxContainer.new()
save_action_row.add_theme_constant_override("separation", 8)
save_menu.add_child(save_action_row)
manual_save_button = Button.new()
manual_save_button.text = "Save Checkpoint"
manual_save_button.pressed.connect(_on_manual_save_pressed)
save_action_row.add_child(manual_save_button)
manual_load_button = Button.new()
manual_load_button.text = "Load Checkpoint"
manual_load_button.pressed.connect(_on_manual_load_pressed)
save_action_row.add_child(manual_load_button)
var begin_button := Button.new()
begin_button.text = "Begin Battle"
begin_button.pressed.connect(_on_begin_battle_pressed)
@@ -597,7 +632,7 @@ func _play_bgm(key: String) -> void:
return
if current_bgm_key == key and bgm_player.playing:
return
var stream = _bgm_stream_for(key)
var stream := _bgm_stream_for(key)
if stream == null:
return
current_bgm_key = key
@@ -606,16 +641,16 @@ func _play_bgm(key: String) -> void:
bgm_player.play()
func _bgm_stream_for(key: String):
func _bgm_stream_for(key: String) -> AudioStream:
if key == "battle":
return BGM_BATTLE
return _audio_stream_from_path(BGM_BATTLE_PATH)
if key == "menu":
return BGM_MENU
return _audio_stream_from_path(BGM_MENU_PATH)
return null
func _play_sfx(key: String, volume_db := SFX_VOLUME_DB) -> void:
var stream = _sfx_stream_for(key)
var stream := _sfx_stream_for(key)
if stream == null:
return
var player := AudioStreamPlayer.new()
@@ -627,36 +662,46 @@ func _play_sfx(key: String, volume_db := SFX_VOLUME_DB) -> void:
player.play()
func _sfx_stream_for(key: String):
func _sfx_stream_for(key: String) -> AudioStream:
if key == "bow_release":
return SFX_BOW_RELEASE
return _audio_stream_from_path(SFX_BOW_RELEASE_PATH)
if key == "defeat_sting":
return SFX_DEFEAT_STING
return _audio_stream_from_path(SFX_DEFEAT_STING_PATH)
if key == "fire_skill":
return SFX_FIRE_SKILL
return _audio_stream_from_path(SFX_FIRE_SKILL_PATH)
if key == "footstep":
return SFX_FOOTSTEP
return _audio_stream_from_path(SFX_FOOTSTEP_PATH)
if key == "guard_block":
return SFX_GUARD_BLOCK
return _audio_stream_from_path(SFX_GUARD_BLOCK_PATH)
if key == "hit_heavy":
return SFX_HIT_HEAVY
return _audio_stream_from_path(SFX_HIT_HEAVY_PATH)
if key == "item_pickup":
return SFX_ITEM_PICKUP
return _audio_stream_from_path(SFX_ITEM_PICKUP_PATH)
if key == "menu_cancel":
return SFX_MENU_CANCEL
return _audio_stream_from_path(SFX_MENU_CANCEL_PATH)
if key == "menu_confirm":
return SFX_MENU_CONFIRM
return _audio_stream_from_path(SFX_MENU_CONFIRM_PATH)
if key == "skill_cast":
return SFX_SKILL_CAST
return _audio_stream_from_path(SFX_SKILL_CAST_PATH)
if key == "slash":
return SFX_SLASH
return _audio_stream_from_path(SFX_SLASH_PATH)
if key == "ui_click":
return SFX_UI_CLICK
return _audio_stream_from_path(SFX_UI_CLICK_PATH)
if key == "victory_sting":
return SFX_VICTORY_STING
return _audio_stream_from_path(SFX_VICTORY_STING_PATH)
return null
func _audio_stream_from_path(path: String) -> AudioStream:
if path.is_empty():
return null
if not ResourceLoader.exists(path, "AudioStream"):
return null
if not audio_stream_cache.has(path):
audio_stream_cache[path] = load(path)
return audio_stream_cache[path] as AudioStream
func _play_ui_click() -> void:
_play_sfx("ui_click", UI_SFX_VOLUME_DB)
@@ -795,6 +840,8 @@ func _handle_key(event: InputEventKey) -> void:
_hide_roster_menu()
elif formation_menu != null and formation_menu.visible:
_hide_formation_menu()
elif save_menu != null and save_menu.visible:
_hide_save_menu()
elif tactic_menu != null and tactic_menu.visible:
_hide_tactic_menu()
elif item_menu != null and item_menu.visible:
@@ -963,7 +1010,7 @@ func _visual_rect_for_unit(unit: Dictionary) -> Rect2:
var motion: Dictionary = unit_motion_by_unit[unit_id]
var from_cell: Vector2i = motion.get("from", cell)
var to_cell: Vector2i = motion.get("to", cell)
var duration := max(0.01, float(motion.get("duration", UNIT_MOVE_ANIMATION_DURATION)))
var duration: float = maxf(0.01, float(motion.get("duration", UNIT_MOVE_ANIMATION_DURATION)))
var age := float(motion.get("age", 0.0))
var progress := clampf(age / duration, 0.0, 1.0)
var smooth_progress := progress * progress * (3.0 - 2.0 * progress)
@@ -978,17 +1025,17 @@ func _draw_floating_texts() -> void:
return
var font := ThemeDB.fallback_font
for popup in floating_texts:
var duration := max(0.01, float(popup.get("duration", FLOATING_TEXT_LIFETIME)))
var duration: float = maxf(0.01, float(popup.get("duration", FLOATING_TEXT_LIFETIME)))
var age := float(popup.get("age", 0.0))
var progress := clampf(age / duration, 0.0, 1.0)
var alpha := 1.0 - progress
var color := _floating_text_color(str(popup.get("kind", "")))
color.a = alpha
var shadow := Color(0.02, 0.02, 0.025, 0.82 * alpha)
var center = popup.get("pos", Vector2.ZERO)
var center: Vector2 = popup.get("pos", Vector2.ZERO)
var text := str(popup.get("text", ""))
var width := 118.0
var draw_position := center - Vector2(width * 0.5, 24.0 + progress * FLOATING_TEXT_RISE)
var width: float = 118.0
var draw_position: Vector2 = center - Vector2(width * 0.5, 24.0 + progress * FLOATING_TEXT_RISE)
draw_string(font, draw_position + Vector2(1, 1), text, HORIZONTAL_ALIGNMENT_CENTER, width, 18, shadow)
draw_string(font, draw_position, text, HORIZONTAL_ALIGNMENT_CENTER, width, 18, color)
@@ -1858,6 +1905,7 @@ func _load_scenario(scenario_id: String) -> void:
_hide_armory_menu()
_hide_roster_menu()
_hide_formation_menu()
_hide_save_menu()
_clear_dialogue()
state.load_battle(
scenario_path,
@@ -1951,6 +1999,7 @@ func _show_briefing() -> void:
_hide_armory_menu()
_hide_roster_menu()
_hide_formation_menu()
_hide_save_menu()
var prep_locked := _is_prebattle_prep_locked()
if chapter_button != null:
chapter_button.disabled = campaign_state.get_chapter_progress_entries().is_empty()
@@ -1962,6 +2011,8 @@ func _show_briefing() -> void:
roster_button.disabled = prep_locked or not state.has_deployment_roster() or state.get_player_units(true).is_empty()
if formation_button != null:
formation_button.disabled = prep_locked or state.get_formation_cells().is_empty()
if save_button != null:
save_button.disabled = prep_locked
func _on_begin_battle_pressed() -> void:
@@ -1979,6 +2030,7 @@ func _start_battle_from_briefing() -> void:
_hide_armory_menu()
_hide_roster_menu()
_hide_formation_menu()
_hide_save_menu()
state.run_battle_begin_events()
_show_next_dialogue()
_update_hud()
@@ -1995,6 +2047,7 @@ func _on_chapter_pressed() -> void:
_hide_armory_menu()
_hide_roster_menu()
_hide_formation_menu()
_hide_save_menu()
_rebuild_chapter_menu()
chapter_menu.visible = true
_update_hud()
@@ -2101,6 +2154,7 @@ func _on_shop_pressed() -> void:
_hide_armory_menu()
_hide_roster_menu()
_hide_formation_menu()
_hide_save_menu()
_rebuild_shop_menu()
shop_menu.visible = true
_update_hud()
@@ -2123,6 +2177,7 @@ func _on_armory_pressed() -> void:
_hide_shop_menu()
_hide_roster_menu()
_hide_formation_menu()
_hide_save_menu()
_rebuild_armory_menu()
armory_menu.visible = true
_update_hud()
@@ -2145,6 +2200,7 @@ func _on_roster_pressed() -> void:
_hide_shop_menu()
_hide_armory_menu()
_hide_formation_menu()
_hide_save_menu()
_rebuild_roster_menu()
roster_menu.visible = true
_update_hud()
@@ -2167,6 +2223,7 @@ func _on_formation_pressed() -> void:
_hide_shop_menu()
_hide_armory_menu()
_hide_roster_menu()
_hide_save_menu()
_rebuild_formation_menu()
formation_menu.visible = true
_update_hud()
@@ -2179,6 +2236,127 @@ func _hide_formation_menu() -> void:
formation_menu.visible = false
func _on_save_pressed() -> void:
if battle_started or _is_prebattle_prep_locked() or briefing_panel == null or not briefing_panel.visible:
return
_play_ui_click()
if save_menu != null and save_menu.visible:
_hide_save_menu()
else:
_hide_chapter_menu()
_hide_shop_menu()
_hide_armory_menu()
_hide_roster_menu()
_hide_formation_menu()
_rebuild_save_menu()
save_menu.visible = true
_update_hud()
func _hide_save_menu() -> void:
if save_menu == null:
return
save_menu.visible = false
func _rebuild_save_menu() -> void:
if save_status_label != null:
save_status_label.text = "%s\n%s" % [
_format_current_checkpoint_text(),
_format_manual_checkpoint_text()
]
if manual_load_button != null:
manual_load_button.disabled = not campaign_state.has_manual_save()
func _format_current_checkpoint_text() -> String:
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 "Current: %s | %d/%d complete | Gold %d" % [
title,
campaign_state.completed_scenarios.size(),
campaign_state.get_scenario_count(),
campaign_state.gold
]
func _format_manual_checkpoint_text() -> String:
var summary := campaign_state.get_manual_save_summary()
if summary.is_empty():
return "Manual: empty"
var title := str(summary.get("current_scenario_title", ""))
if title.is_empty():
title = str(summary.get("current_scenario_id", "Unknown"))
var pending_suffix := " | choice pending" if bool(summary.get("pending_choice", false)) else ""
return "Manual: %s | %d/%d complete | Gold %d%s" % [
title,
int(summary.get("completed_count", 0)),
int(summary.get("total_count", 0)),
int(summary.get("gold", 0)),
pending_suffix
]
func _on_manual_save_pressed() -> void:
if battle_started or _is_prebattle_prep_locked():
return
_play_ui_confirm()
if campaign_state.save_manual_game():
_on_log_added("Manual checkpoint saved.")
else:
_on_log_added("Could not save manual checkpoint.")
_rebuild_save_menu()
func _on_manual_load_pressed() -> void:
if battle_started or _is_prebattle_prep_locked() or not campaign_state.has_manual_save():
return
_play_ui_confirm()
if not campaign_state.load_manual_game():
_on_log_added("Could not load manual checkpoint.")
_rebuild_save_menu()
return
if log_box != null:
log_box.clear()
log_box.append_text("Manual checkpoint loaded.\n")
_reload_from_campaign_state()
func _reload_from_campaign_state() -> void:
campaign_complete_screen = false
battle_started = false
battle_result_applied = false
battle_result_summary.clear()
post_battle_dialogue_started = false
floating_texts.clear()
unit_motion_by_unit.clear()
selected_skill_id = ""
selected_item_id = ""
armory_unit_id = ""
formation_unit_id = ""
_hide_tactic_menu()
_hide_item_menu()
_hide_equip_menu()
_hide_chapter_menu()
_hide_shop_menu()
_hide_armory_menu()
_hide_roster_menu()
_hide_formation_menu()
_hide_save_menu()
if result_panel != null:
result_panel.visible = false
if campaign_state.has_pending_post_battle_choice():
_load_pending_post_battle_choice()
_update_result_panel()
elif campaign_state.is_campaign_complete():
_show_campaign_complete()
else:
_load_current_battle()
_show_briefing()
_update_hud()
func _is_prep_menu_visible() -> bool:
return (
(chapter_menu != null and chapter_menu.visible)
@@ -2186,6 +2364,7 @@ func _is_prep_menu_visible() -> bool:
or (armory_menu != null and armory_menu.visible)
or (roster_menu != null and roster_menu.visible)
or (formation_menu != null and formation_menu.visible)
or (save_menu != null and save_menu.visible)
)
@@ -2914,6 +3093,7 @@ func _show_campaign_complete() -> void:
_hide_armory_menu()
_hide_roster_menu()
_hide_formation_menu()
_hide_save_menu()
_clear_dialogue()
active_scenario_id = campaign_state.current_scenario_id
if briefing_panel != null:
@@ -2946,7 +3126,11 @@ func _on_new_campaign_pressed() -> void:
_hide_item_menu()
_hide_equip_menu()
_hide_chapter_menu()
_hide_shop_menu()
_hide_armory_menu()
_hide_roster_menu()
_hide_formation_menu()
_hide_save_menu()
_load_current_battle()
_show_briefing()
_update_hud()