From cf09306b8e93614ef7ce10247ce120dbef359523 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 18 Jun 2026 04:21:55 +0900 Subject: [PATCH] Add manual campaign checkpoint saves --- README.md | 2 + docs/ARCHITECTURE.md | 6 +- docs/DATA_MODEL.md | 6 +- docs/GODOT_4_6_MIGRATION.md | 5 +- docs/ROADMAP.md | 3 +- scripts/core/battle_state.gd | 26 ++-- scripts/core/campaign_state.gd | 119 +++++++++++---- scripts/core/data_catalog.gd | 6 +- scripts/scenes/battle_scene.gd | 262 ++++++++++++++++++++++++++++----- 9 files changed, 340 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index 04a286c..53dbd25 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Pre-battle Formation lets deployed officers swap starting positions inside scenario-defined cells. - Pre-battle briefing shows the campaign chapter, chapter battle number, location, and victory/defeat summary. - 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. - 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. @@ -105,6 +106,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Next-battle flow after victory. - Campaign completion screen. - New campaign save reset. +- Manual campaign checkpoint save/load. - Victory and defeat result overlay. - Dialogue portrait slot with officer default image paths, optional per-line overrides, and speaker-initial fallback. - Dialogue lines can place the portrait on the left or right side of the dialogue panel. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index de1f5a2..012b4d1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -35,7 +35,7 @@ 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, and optional pre-battle shop, Armory, Roster, and Formation setup. +4. Show scenario briefing with the campaign chapter header, battle header, objective summary, chapter overview, 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. Tactic buttons show MP cost, kind, range, and power or support effect. Physical attacks roll hit chance from unit agility and target terrain avoid; misses give reduced EXP. The scene plays placeholder SFX, a short movement slide, low-HP warning rings, hover target preview badges, and floating combat text for core action and UI feedback. @@ -52,11 +52,11 @@ ## Save Boundary -`BattleState` owns tactical runtime fields. `CampaignState` saves only campaign-facing data: completed scenario ids, gold, inventory counts, joined officers, campaign flags, and a player roster snapshot. It does not save map positions, pre-battle Formation choices, action flags, temporary status effects, or transient battle occupancy. +`BattleState` owns tactical runtime fields. `CampaignState` saves only campaign-facing data: completed scenario ids, gold, inventory counts, joined officers, campaign flags, and a player roster snapshot. It does not save map positions, pre-battle Roster/Formation choices, action flags, temporary status effects, or transient battle occupancy. Joined officers gate whether `requires_joined` player deployments are loaded at all. Rewards and post-battle choices can add or remove joined officers without deleting their saved roster snapshot, so a later rejoin can preserve progression. When a save loads, `CampaignState` scans completed scenarios in campaign order and reapplies only officer join/leave transitions from rewards and the saved `applied_post_battle_choices` ledger, allowing added recruitment rewards to reach older saves without replaying gold or items. Old saves without the ledger can backfill a choice id only when exactly one completed scenario choice matches saved flags. Roster entries are keyed by `officer_id` when available, so future scenarios can deploy the same joined officer under different scenario-specific `unit_id` values. Saved progression overlays final battle stats for now; a later equipment refactor should split base stats from equipment bonuses before adding item leveling or stat recalculation. New battles currently start player units healed to their saved maximum HP/MP. Skill access is carried in roster snapshots, with class defaults still applied for old saves that do not include skills. Promoted class ids are saved in the roster, and the next battle rehydrates class-derived movement, growth, skills, and range from the saved class id before equipment range is recalculated. -Inventory and campaign flags are copied from `CampaignState` into `BattleState` when a scenario starts. The pre-battle Chapters overview reads `CampaignState` chapter progress and can load completed or current battles without mutating the save. Pre-battle shop stock comes from the loaded scenario's `shop.items` list plus matching `shop.conditional_items` blocks. Shop purchases and 50% sell-back are campaign transactions: they update saved gold and unequipped inventory immediately, write the save file, and refresh the already-loaded battle inventory before the player begins the battle. Pre-battle Armory equipment changes use the same equipment rules as battle HUD equipment changes, then immediately save merged roster equipment/stat snapshots and inventory stock. Pre-battle Roster uses scenario `roster.max_units`, `roster.required_officers`, and `roster.required_units` to mark optional player deployments as sortie or reserve; reserve units remain in the candidate list but are excluded from board occupancy, drawing, selection, AI targeting, and living-unit victory checks. Non-controllable player units can act as protected escort targets: enemies can attack them and conditions can reference them, but they are skipped by player selection, Armory, Formation, counterattacks, and campaign roster progression snapshots when `persist_progression` is false. Scenario `ai_target_priority` nudges enemy movement and damage-skill scoring toward important targets such as envoys without overriding defeat bonuses or range checks. Pre-battle Formation uses the loaded scenario's `formation.cells` and only mutates current battle unit positions before battle-begin events fire. Destination victory cells from `unit_reaches_tile` conditions are exposed to the scene for objective overlays and tile info. Movement-triggered `unit_reaches_tile` events receive the unit that just moved, so ambushes fire on entry rather than from units already standing on a marker. Briefing data can append matching `briefing.conditional_lines`, and scenario events can use `when.campaign_flags` to branch dialogue, objective changes, and reinforcements from saved campaign choices. Consumable use can restore HP or MP, and in-battle item use plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items or preserve in-battle gear changes. Post-battle choices write campaign `flags` only after the result-panel button is pressed; if the player reloads after rewards are saved but before selecting, the pending scenario id restores the victory choice panel without replaying rewards. The next battle button stays locked until the campaign choice save succeeds. Equipment rewards share the same `item_id -> count` inventory stock, cover weapon, armor, and accessory slots, can be equipped from the side HUD before the selected unit moves or acts, and do not appear in the consumable action menu. Already completed scenarios can be replayed without granting duplicate rewards, choices, replay inventory consumption, save writes, or current-scenario advancement; pre-battle Shop, Armory, Roster, and Formation are disabled on completed-scenario replays to avoid side effects. +Inventory and campaign flags are copied from `CampaignState` into `BattleState` when a scenario starts. The pre-battle Chapters overview reads `CampaignState` chapter progress and can load completed or current battles without mutating the save. The pre-battle Save menu can write the current campaign state to `user://campaign_manual_save.json`, and loading that checkpoint restores it into `user://campaign_save.json` before re-entering the same pending-choice, completion, or current-briefing branch as startup. Pre-battle shop stock comes from the loaded scenario's `shop.items` list plus matching `shop.conditional_items` blocks. Shop purchases and 50% sell-back are campaign transactions: they update saved gold and unequipped inventory immediately, write the save file, and refresh the already-loaded battle inventory before the player begins the battle. Pre-battle Armory equipment changes use the same equipment rules as battle HUD equipment changes, then immediately save merged roster equipment/stat snapshots and inventory stock. Pre-battle Roster uses scenario `roster.max_units`, `roster.required_officers`, and `roster.required_units` to mark optional player deployments as sortie or reserve; reserve units remain in the candidate list but are excluded from board occupancy, drawing, selection, AI targeting, and living-unit victory checks. Non-controllable player units can act as protected escort targets: enemies can attack them and conditions can reference them, but they are skipped by player selection, Armory, Formation, counterattacks, and campaign roster progression snapshots when `persist_progression` is false. Scenario `ai_target_priority` nudges enemy movement and damage-skill scoring toward important targets such as envoys without overriding defeat bonuses or range checks. Pre-battle Formation uses the loaded scenario's `formation.cells` and only mutates current battle unit positions before battle-begin events fire. Destination victory cells from `unit_reaches_tile` conditions are exposed to the scene for objective overlays and tile info. Movement-triggered `unit_reaches_tile` events receive the unit that just moved, so ambushes fire on entry rather than from units already standing on a marker. Briefing data can append matching `briefing.conditional_lines`, and scenario events can use `when.campaign_flags` to branch dialogue, objective changes, and reinforcements from saved campaign choices. Consumable use can restore HP or MP, and in-battle item use plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items or preserve in-battle gear changes. Post-battle choices write campaign `flags` only after the result-panel button is pressed; if the player reloads after rewards are saved but before selecting, the pending scenario id restores the victory choice panel without replaying rewards. The next battle button stays locked until the campaign choice save succeeds. Equipment rewards share the same `item_id -> count` inventory stock, cover weapon, armor, and accessory slots, can be equipped from the side HUD before the selected unit moves or acts, and do not appear in the consumable action menu. Already completed scenarios can be replayed without granting duplicate rewards, choices, replay inventory consumption, save writes, or current-scenario advancement; pre-battle Shop, Armory, Roster, Formation, and Save are disabled on completed-scenario replays to avoid side effects. The battle scene owns presentation feedback: briefing and result states use menu BGM, briefing headers combine `CampaignState` chapter ranges, campaign order, and title data with `BattleState` briefing and objective text, active battles use battle BGM, dialogue lines can show optional portrait textures or speaker-initial fallback panels and can place the portrait on the left or right side of the dialogue panel, board units render low-HP warning rings and HP bar color states from battle state fields, hover previews are rendered as target badges from existing `BattleState` forecast APIs, log/result hooks trigger placeholder SFX, `BattleState.unit_motion_requested` asks the scene to interpolate a unit's draw position during movement, and `BattleState.combat_feedback_requested` asks the scene to draw transient floating combat text without changing battle rules. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 40d4222..6c204f3 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -475,7 +475,7 @@ Event-spawned units use the same compact deployment shape as scenario starting u `scripts/core/data_catalog.gd` reads definition files once and `BattleState._apply_battle_data()` accepts either legacy `units` or catalog-backed `deployments`. Runtime units now carry `min_range`, `range`, `skills`, `exp`, `growth`, `growth_bonus`, and battle-only `status_effects` so the battle resolver can handle counterattacks, MP tactics, support effects, EXP, level-ups, and enemy tactic AI without changing scenario files. -`scripts/core/campaign_state.gd` writes `user://campaign_save.json` with `save_version`, completed scenarios, gold, inventory counts, joined officers, pending post-battle choice scenario id, applied post-battle choice ids, campaign flags, and player roster progression. Roster snapshots include level, EXP, stats, class id/name, class-derived movement/range/growth fields, skills, and equipment. Rewards, joined/left officers, and post-battle choices are guarded by completed scenario id so restarting a finished battle does not duplicate gold, items, membership changes, or branch decisions. On load, completed-scenario officer join/leave transitions are reconciled without replaying gold or item rewards, which lets new officer rewards added to older completed scenarios become available to existing saves. +`scripts/core/campaign_state.gd` writes `user://campaign_save.json` with `save_version`, completed scenarios, gold, inventory counts, joined officers, pending post-battle choice scenario id, applied post-battle choice ids, campaign flags, and player roster progression. The same snapshot can be copied to the one-slot manual checkpoint at `user://campaign_manual_save.json` from the pre-battle Save menu. Loading that checkpoint restores it into the active automatic save and then reloads the pending choice panel, campaign completion state, or current scenario briefing. Roster snapshots include level, EXP, stats, class id/name, class-derived movement/range/growth fields, skills, and equipment. Rewards, joined/left officers, and post-battle choices are guarded by completed scenario id so restarting a finished battle does not duplicate gold, items, membership changes, or branch decisions. On load, completed-scenario officer join/leave transitions are reconciled without replaying gold or item rewards, which lets new officer rewards added to older completed scenarios become available to existing saves. After a first-time victory, `CampaignState` advances `current_scenario_id` to the next entry in `data/campaign/campaign.json`. Completed-scenario replays return a result summary without rewriting the save or moving `current_scenario_id`. The battle scene passes `CampaignState.get_roster_overrides()`, `CampaignState.get_inventory_snapshot()`, `CampaignState.get_flags_snapshot()`, and `CampaignState.get_joined_officers_snapshot()` into `BattleState.load_battle()`, so officers can keep level/EXP/stat progression, equipped gear, consumable/equipment stock counts, recruitment gates, and branch flags into the next scenario. @@ -485,8 +485,8 @@ During a battle, item consumption and equipment swaps affect `BattleState.battle Physical attacks grant normal attack/counter EXP only on hit after the attack/counter exchange resolves. A missed physical attack or counter grants a small miss EXP value, while defeat bonuses still require the attack to hit and defeat the target. -Before a battle starts, `BattleScene` can buy priced items through `CampaignState.try_buy_item()` and sell unequipped inventory through `CampaignState.try_sell_item()`. Successful shop transactions update campaign gold and inventory in the save file immediately, then call `BattleState.set_inventory_snapshot()` so the upcoming battle sees the new stock. Pre-battle Armory changes call `CampaignState.try_save_prebattle_loadout()`, which merges the currently deployed roster snapshot into the saved roster and saves the adjusted inventory in the same operation. Pre-battle Roster calls `BattleState.try_set_unit_deployed()` to toggle optional player units while enforcing required officers and sortie limits. Pre-battle Formation calls `BattleState.try_set_prebattle_formation()`, which can move a player unit to an open formation cell or swap two player units inside the formation area. Matching `briefing.conditional_lines` and `shop.conditional_items` are merged while the scenario loads, before the briefing panel opens. `post_battle_dialogue` is rendered by the battle scene after victory and before rewards/choices are shown in the result panel. Post-battle choices call `CampaignState.try_apply_post_battle_choice()`, which saves selected flags and rolls back flags, gold, and inventory if the save write fails. +Before a battle starts, `BattleScene` can buy priced items through `CampaignState.try_buy_item()` and sell unequipped inventory through `CampaignState.try_sell_item()`. Successful shop transactions update campaign gold and inventory in the save file immediately, then call `BattleState.set_inventory_snapshot()` so the upcoming battle sees the new stock. Pre-battle Armory changes call `CampaignState.try_save_prebattle_loadout()`, which merges the currently deployed roster snapshot into the saved roster and saves the adjusted inventory in the same operation. Pre-battle Roster calls `BattleState.try_set_unit_deployed()` to toggle optional player units while enforcing required officers and sortie limits. Pre-battle Formation calls `BattleState.try_set_prebattle_formation()`, which can move a player unit to an open formation cell or swap two player units inside the formation area. Roster and Formation choices are battle setup state, so the manual checkpoint stores campaign progression and saved loadout state but not unsaved sortie or starting-cell edits. Matching `briefing.conditional_lines` and `shop.conditional_items` are merged while the scenario loads, before the briefing panel opens. `post_battle_dialogue` is rendered by the battle scene after victory and before rewards/choices are shown in the result panel. Post-battle choices call `CampaignState.try_apply_post_battle_choice()`, which saves selected flags and rolls back flags, gold, and inventory if the save write fails. -When every campaign scenario id is present in `completed_scenarios`, the battle scene shows a campaign completion panel. Starting a new campaign clears the save file and resets `current_scenario_id` to `start_scenario`. +When every campaign scenario id is present in `completed_scenarios`, the battle scene shows a campaign completion panel. Starting a new campaign clears the automatic save file and resets `current_scenario_id` to `start_scenario`; the manual checkpoint is left alone until overwritten from the Save menu. Run `tools/validate_data.ps1` after data edits to check campaign paths, chapter ranges, map dimensions, terrain keys, class/officer/item/skill references, item effect names and amounts, equipment slot compatibility including accessory types, promotion routes and promotion equipment compatibility, condition/event names, condition unit references, destination condition coordinates, campaign flag references, joined-officer gates, briefing line blocks, deployment ids, deployment bounds, impassable spawn cells, shop stock, roster rules, formation cells, post-battle dialogue, post-battle choices, and reward item ids. diff --git a/docs/GODOT_4_6_MIGRATION.md b/docs/GODOT_4_6_MIGRATION.md index de2db4c..b18bb92 100644 --- a/docs/GODOT_4_6_MIGRATION.md +++ b/docs/GODOT_4_6_MIGRATION.md @@ -42,7 +42,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readines - Main scene starts without script errors. - Current campaign save loads or a new campaign starts cleanly. - Briefing opens and can enter battle. -- Chapters, Shop, Armory, Roster, and Formation open and close; completed or current Chapter entries can open a battle briefing. +- Chapters, Shop, Armory, Roster, Formation, and Save open and close; completed or current Chapter entries can open a battle briefing. +- Manual checkpoint save/load restores the expected current briefing without script errors. - A player unit can move, attack, wait, use a tactic, use an item, and equip compatible gear. - Enemy turn advances and AI acts. - Victory and defeat panels still appear. @@ -54,4 +55,4 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readines - If the editor updates `config/features`, review the diff before making content changes. - If UI colors or canvas rendering look different, compare the battle board, overlays, and panels before changing art direction. -- If a save file behaves oddly after migration, test both a fresh campaign and an existing `user://campaign_save.json`. +- If a save file behaves oddly after migration, test both a fresh campaign and existing `user://campaign_save.json` / `user://campaign_manual_save.json` files. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 681c043..d52c3a6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -51,7 +51,8 @@ - Party roster persistence has a first save-file skeleton. - Level, experience, and equipment persistence. -- Chapter overview exists in pre-battle briefing and can open completed or current battles. Manual save/load is still planned. +- Chapter overview exists in pre-battle briefing and can open completed or current battles. +- A one-slot manual campaign checkpoint can be saved and loaded from the pre-battle briefing. - Linear sixty-seven-scenario campaign order exists with chapter ranges for story arcs. - Save reset exists. - Victory rewards, consumable counts, equipment stock, and equipped gear persist. diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index 1dbd412..afc4ede 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -225,7 +225,7 @@ func _normalized_battle_conditions(source) -> Dictionary: func _normalized_briefing(source) -> Dictionary: if typeof(source) != TYPE_DICTIONARY: return {} - var result := source.duplicate(true) + var result: Dictionary = source.duplicate(true) var lines := [] for line in result.get("lines", []): lines.append(str(line)) @@ -668,7 +668,7 @@ func get_damage_preview(attacker_id: String, target_id: String) -> Dictionary: return {} var damage := calculate_damage(attacker, target) - var target_hp_after := max(0, int(target["hp"]) - damage) + var target_hp_after: int = maxi(0, int(target["hp"]) - damage) var counter_damage := 0 var attacker_hp_after := int(attacker["hp"]) var hit_chance := calculate_hit_chance(attacker, target) @@ -679,7 +679,7 @@ func get_damage_preview(attacker_id: String, target_id: String) -> Dictionary: if counter_in_range: counter_damage = calculate_damage(target, attacker) counter_hit_chance = calculate_hit_chance(target, attacker) - attacker_hp_after = max(0, int(attacker["hp"]) - counter_damage) + attacker_hp_after = maxi(0, int(attacker["hp"]) - counter_damage) return { "attacker_id": attacker_id, "target_id": target_id, @@ -884,7 +884,7 @@ func _format_support_effects(skill: Dictionary) -> String: if not _is_supported_status_stat(stat) or amount == 0: continue var sign := "+" if amount > 0 else "" - var duration := max(1, int(effect.get("duration_turns", 1))) + var duration: int = maxi(1, int(effect.get("duration_turns", 1))) var duration_text := "next phase" if duration == 1 else "%d phases" % duration parts.append("%s %s%d until %s" % [stat.to_upper(), sign, amount, duration_text]) if parts.is_empty(): @@ -1557,13 +1557,13 @@ func _skill_range_pair(skill: Dictionary) -> Dictionary: return _normalized_skill_range(1, 1) if typeof(value) != TYPE_INT and typeof(value) != TYPE_FLOAT: return _normalized_skill_range(1, 1) - var scalar := max(0, int(value)) + var scalar: int = maxi(0, int(value)) return _normalized_skill_range(scalar, scalar) func _normalized_skill_range(min_range: int, max_range: int) -> Dictionary: - var safe_min := max(0, min_range) - var safe_max := max(safe_min, max_range) + var safe_min: int = maxi(0, min_range) + var safe_max: int = maxi(safe_min, max_range) return {"min": safe_min, "max": safe_max} @@ -1616,7 +1616,7 @@ func _item_range_pair(item: Dictionary) -> Dictionary: return _normalized_skill_range(0, 1) if typeof(value) != TYPE_INT and typeof(value) != TYPE_FLOAT: return _normalized_skill_range(0, 1) - var scalar := max(0, int(value)) + var scalar: int = maxi(0, int(value)) return _normalized_skill_range(scalar, scalar) @@ -1787,13 +1787,13 @@ func _attack_range_pair_from_value(value) -> Dictionary: return _normalized_attack_range_pair(1, 1) if typeof(value) != TYPE_INT and typeof(value) != TYPE_FLOAT: return _normalized_attack_range_pair(1, 1) - var scalar := max(1, int(value)) + var scalar: int = maxi(1, int(value)) return _normalized_attack_range_pair(scalar, scalar) func _normalized_attack_range_pair(min_range: int, max_range: int) -> Dictionary: - var safe_min := max(1, min_range) - var safe_max := max(safe_min, max_range) + var safe_min: int = maxi(1, min_range) + var safe_max: int = maxi(safe_min, max_range) return {"min": safe_min, "max": safe_max} @@ -2262,8 +2262,8 @@ func _unit_reaches_tile(condition: Dictionary) -> bool: func _unit_matches_condition_ids(unit: Dictionary, unit_ids, officer_ids) -> bool: - var has_unit_filter := typeof(unit_ids) == TYPE_ARRAY and not unit_ids.is_empty() - var has_officer_filter := typeof(officer_ids) == TYPE_ARRAY and not officer_ids.is_empty() + var has_unit_filter: bool = typeof(unit_ids) == TYPE_ARRAY and not unit_ids.is_empty() + var has_officer_filter: bool = typeof(officer_ids) == TYPE_ARRAY and not officer_ids.is_empty() if not has_unit_filter and not has_officer_filter: return true diff --git a/scripts/core/campaign_state.gd b/scripts/core/campaign_state.gd index 8c95494..ffd9e48 100644 --- a/scripts/core/campaign_state.gd +++ b/scripts/core/campaign_state.gd @@ -2,6 +2,7 @@ extends RefCounted class_name CampaignState const SAVE_PATH := "user://campaign_save.json" +const MANUAL_SAVE_PATH := "user://campaign_manual_save.json" const SAVE_VERSION := 3 var save_version := SAVE_VERSION @@ -151,38 +152,73 @@ func reset_save(scenario_id: String) -> bool: func load_game() -> bool: - if not FileAccess.file_exists(SAVE_PATH): - return false - - var parsed = JSON.parse_string(FileAccess.get_file_as_string(SAVE_PATH)) - if typeof(parsed) != TYPE_DICTIONARY: - return false - - save_version = int(parsed.get("save_version", 1)) - current_scenario_id = str(parsed.get("current_scenario_id", "")) - completed_scenarios.clear() - for scenario_id in parsed.get("completed_scenarios", []): - completed_scenarios.append(str(scenario_id)) - gold = int(parsed.get("gold", 0)) - inventory = _copy_dictionary(parsed.get("inventory", {})) - roster = _copy_dictionary(parsed.get("roster", {})) - flags = _copy_dictionary(parsed.get("flags", {})) - applied_post_battle_choices = _copy_dictionary(parsed.get("applied_post_battle_choices", {})) - pending_post_battle_choice_scenario_id = str(parsed.get("pending_post_battle_choice_scenario_id", "")) - joined_officers.clear() - var saved_joined = parsed.get("joined_officers", initial_joined_officers) - for officer_id in saved_joined: - var normalized := str(officer_id) - if normalized.is_empty() or joined_officers.has(normalized): - continue - joined_officers.append(normalized) - return true + return _load_game_from_path(SAVE_PATH) func save_game() -> bool: - var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE) + return _save_game_to_path(SAVE_PATH) + + +func has_manual_save() -> bool: + return FileAccess.file_exists(MANUAL_SAVE_PATH) + + +func save_manual_game() -> bool: + return _save_game_to_path(MANUAL_SAVE_PATH) + + +func load_manual_game() -> bool: + if not has_manual_save(): + return false + var previous_state := to_dict() + if not _load_game_from_path(MANUAL_SAVE_PATH): + return false + _clear_invalid_pending_post_battle_choice() + _normalize_current_scenario() + _backfill_applied_post_battle_choices() + _reconcile_completed_officer_transitions() + if save_game(): + return true + _apply_save_data(previous_state) + return false + + +func get_manual_save_summary() -> Dictionary: + if not has_manual_save(): + return {} + var parsed = JSON.parse_string(FileAccess.get_file_as_string(MANUAL_SAVE_PATH)) + if typeof(parsed) != TYPE_DICTIONARY: + return {} + var completed_count := 0 + var completed = parsed.get("completed_scenarios", []) + if typeof(completed) == TYPE_ARRAY: + completed_count = completed.size() + var current_id := str(parsed.get("current_scenario_id", "")) + return { + "save_version": int(parsed.get("save_version", 1)), + "current_scenario_id": current_id, + "current_scenario_title": get_scenario_title(current_id), + "completed_count": completed_count, + "total_count": scenario_order.size(), + "gold": int(parsed.get("gold", 0)), + "pending_choice": not str(parsed.get("pending_post_battle_choice_scenario_id", "")).is_empty() + } + + +func _load_game_from_path(path: String) -> bool: + if not FileAccess.file_exists(path): + return false + var parsed = JSON.parse_string(FileAccess.get_file_as_string(path)) + if typeof(parsed) != TYPE_DICTIONARY: + return false + _apply_save_data(parsed) + return true + + +func _save_game_to_path(path: String) -> bool: + var file := FileAccess.open(path, FileAccess.WRITE) if file == null: - push_error("Unable to save campaign state: %s" % SAVE_PATH) + push_error("Unable to save campaign state: %s" % path) return false file.store_string(JSON.stringify(to_dict())) return true @@ -202,8 +238,8 @@ func apply_battle_result(battle_state) -> Dictionary: } var already_completed := completed_scenarios.has(battle_state.battle_id) - var rewards := battle_state.get_rewards() - var post_battle_choices := battle_state.get_post_battle_choices() + var rewards: Dictionary = battle_state.get_rewards() + var post_battle_choices: Array = battle_state.get_post_battle_choices() if already_completed: var return_scenario_id := current_scenario_id if not is_campaign_complete() else "" return { @@ -678,7 +714,7 @@ func get_chapter_progress_entries() -> Array: var scenarios := [] for scenario_id in scenario_ids: var completed := completed_scenarios.has(scenario_id) - var current := scenario_id == current_scenario_id + var current: bool = scenario_id == current_scenario_id if completed_scenarios.has(scenario_id): completed_count += 1 if current: @@ -763,6 +799,27 @@ func to_dict() -> Dictionary: } +func _apply_save_data(parsed: Dictionary) -> void: + save_version = int(parsed.get("save_version", 1)) + current_scenario_id = str(parsed.get("current_scenario_id", "")) + completed_scenarios.clear() + for scenario_id in parsed.get("completed_scenarios", []): + completed_scenarios.append(str(scenario_id)) + gold = int(parsed.get("gold", 0)) + inventory = _copy_dictionary(parsed.get("inventory", {})) + roster = _copy_dictionary(parsed.get("roster", {})) + flags = _copy_dictionary(parsed.get("flags", {})) + applied_post_battle_choices = _copy_dictionary(parsed.get("applied_post_battle_choices", {})) + pending_post_battle_choice_scenario_id = str(parsed.get("pending_post_battle_choice_scenario_id", "")) + joined_officers.clear() + var saved_joined = parsed.get("joined_officers", initial_joined_officers) + for officer_id in saved_joined: + var normalized := str(officer_id) + if normalized.is_empty() or joined_officers.has(normalized): + continue + joined_officers.append(normalized) + + func _copy_dictionary(value) -> Dictionary: if typeof(value) == TYPE_DICTIONARY: return value.duplicate(true) diff --git a/scripts/core/data_catalog.gd b/scripts/core/data_catalog.gd index 707b50c..99a62cc 100644 --- a/scripts/core/data_catalog.gd +++ b/scripts/core/data_catalog.gd @@ -221,13 +221,13 @@ func _range_pair_from_value(value) -> Dictionary: return _normalized_range_pair(1, 1) if typeof(value) != TYPE_INT and typeof(value) != TYPE_FLOAT: return _normalized_range_pair(1, 1) - var scalar := max(1, int(value)) + var scalar: int = maxi(1, int(value)) return _normalized_range_pair(scalar, scalar) func _normalized_range_pair(min_range: int, max_range: int) -> Dictionary: - var safe_min := max(1, min_range) - var safe_max := max(safe_min, max_range) + var safe_min: int = maxi(1, min_range) + var safe_max: int = maxi(safe_min, max_range) return { "min": safe_min, "max": safe_max diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 393c051..15397ef 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -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()