From 3f02f1d4acac56a089261090bef8f370261c9d8f Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 31 Jan 2026 16:31:36 +0100 Subject: [PATCH] refactor --- Autoloads/AchievementManager.cs | 47 ++-- Autoloads/EventBus.cs | 21 ++ Autoloads/GameManager.cs | 236 ++++++++++--------- Autoloads/GameStateStore.cs | 185 +++++++++++++++ Autoloads/GameStateStore.cs.uid | 1 + Autoloads/SaveSystem.cs | 121 +++++++--- Autoloads/StatisticsManager.cs | 75 +++--- project.godot | 7 +- scripts/Constants.cs | 1 + scripts/Events/CoinStateHandler.cs | 29 +++ scripts/Events/CoinStateHandler.cs.uid | 1 + scripts/Events/LevelStateHandler.cs | 39 +++ scripts/Events/LevelStateHandler.cs.uid | 1 + scripts/Events/LivesStateHandler.cs | 29 +++ scripts/Events/LivesStateHandler.cs.uid | 1 + scripts/Events/ScoreEventHandler.cs | 34 --- scripts/Events/ScoreEventHandler.cs.uid | 1 - scripts/State/PlayerState.cs | 73 ++++++ scripts/State/PlayerState.cs.uid | 1 + scripts/State/SessionState.cs | 55 +++++ scripts/State/SessionState.cs.uid | 1 + scripts/components/ExitDoorComponent.cs | 5 +- scripts/components/SkillUnlockerComponent.cs | 5 +- 23 files changed, 711 insertions(+), 258 deletions(-) create mode 100644 Autoloads/GameStateStore.cs create mode 100644 Autoloads/GameStateStore.cs.uid create mode 100644 scripts/Events/CoinStateHandler.cs create mode 100644 scripts/Events/CoinStateHandler.cs.uid create mode 100644 scripts/Events/LevelStateHandler.cs create mode 100644 scripts/Events/LevelStateHandler.cs.uid create mode 100644 scripts/Events/LivesStateHandler.cs create mode 100644 scripts/Events/LivesStateHandler.cs.uid delete mode 100644 scripts/Events/ScoreEventHandler.cs delete mode 100644 scripts/Events/ScoreEventHandler.cs.uid create mode 100644 scripts/State/PlayerState.cs create mode 100644 scripts/State/PlayerState.cs.uid create mode 100644 scripts/State/SessionState.cs create mode 100644 scripts/State/SessionState.cs.uid diff --git a/Autoloads/AchievementManager.cs b/Autoloads/AchievementManager.cs index 38105e8..bf400cd 100644 --- a/Autoloads/AchievementManager.cs +++ b/Autoloads/AchievementManager.cs @@ -1,24 +1,22 @@ using Godot; -using Godot.Collections; -using Mr.BrickAdventures; using Mr.BrickAdventures.scripts.Resources; +using Mr.BrickAdventures.scripts.State; namespace Mr.BrickAdventures.Autoloads; +/// +/// Manages achievements using GameStateStore. +/// public partial class AchievementManager : Node { [Export] private string AchievementsFolderPath = "res://achievements/"; [Export] private PackedScene AchievementPopupScene { get; set; } private System.Collections.Generic.Dictionary _achievements = new(); - private Array _unlockedAchievementIds = []; - private GameManager _gameManager; public override void _Ready() { - _gameManager = GetNode(Constants.GameManagerPath); LoadAchievementsFromFolder(); - LoadUnlockedAchievements(); } private void LoadAchievementsFromFolder() @@ -46,6 +44,14 @@ public partial class AchievementManager : Node } } + /// + /// Gets the list of unlocked achievement IDs from the store. + /// + private System.Collections.Generic.List GetUnlockedIds() + { + return GameStateStore.Instance?.Player.UnlockedAchievements ?? new System.Collections.Generic.List(); + } + public void UnlockAchievement(string achievementId) { if (!_achievements.TryGetValue(achievementId, out var achievement)) @@ -54,13 +60,14 @@ public partial class AchievementManager : Node return; } - if (_unlockedAchievementIds.Contains(achievementId)) + var unlockedIds = GetUnlockedIds(); + if (unlockedIds.Contains(achievementId)) { return; // Already unlocked } - // 1. Mark as unlocked internally - _unlockedAchievementIds.Add(achievementId); + // 1. Mark as unlocked + unlockedIds.Add(achievementId); GD.Print($"Achievement Unlocked: {achievement.DisplayName}"); // 2. Show the UI popup @@ -76,31 +83,19 @@ public partial class AchievementManager : Node { SteamManager.UnlockAchievement(achievement.Id); } - - // 4. Save progress - SaveUnlockedAchievements(); } public void LockAchievement(string achievementId) { - if (_unlockedAchievementIds.Contains(achievementId)) + var unlockedIds = GetUnlockedIds(); + if (unlockedIds.Contains(achievementId)) { - _unlockedAchievementIds.Remove(achievementId); - SaveUnlockedAchievements(); + unlockedIds.Remove(achievementId); } } - private void SaveUnlockedAchievements() + public bool IsAchievementUnlocked(string achievementId) { - _gameManager.PlayerState["unlocked_achievements"] = _unlockedAchievementIds; - // You might want to trigger a save game here, depending on your SaveSystem - } - - private void LoadUnlockedAchievements() - { - if (_gameManager.PlayerState.TryGetValue("unlocked_achievements", out var unlocked)) - { - _unlockedAchievementIds = (Array)unlocked; - } + return GetUnlockedIds().Contains(achievementId); } } \ No newline at end of file diff --git a/Autoloads/EventBus.cs b/Autoloads/EventBus.cs index fd881de..5647cec 100644 --- a/Autoloads/EventBus.cs +++ b/Autoloads/EventBus.cs @@ -116,6 +116,8 @@ public partial class EventBus : Node [Signal] public delegate void GamePausedEventHandler(); [Signal] public delegate void GameResumedEventHandler(); [Signal] public delegate void GameSavedEventHandler(); + [Signal] public delegate void GameStartedEventHandler(); + [Signal] public delegate void GameContinuedEventHandler(); public static void EmitGamePaused() => Instance?.EmitSignal(SignalName.GamePaused); @@ -126,5 +128,24 @@ public partial class EventBus : Node public static void EmitGameSaved() => Instance?.EmitSignal(SignalName.GameSaved); + public static void EmitGameStarted() + => Instance?.EmitSignal(SignalName.GameStarted); + + public static void EmitGameContinued() + => Instance?.EmitSignal(SignalName.GameContinued); + + #endregion + + #region State Change Events + + [Signal] public delegate void CoinsChangedEventHandler(int totalCoins); + [Signal] public delegate void LivesChangedEventHandler(int lives); + + public static void EmitCoinsChanged(int totalCoins) + => Instance?.EmitSignal(SignalName.CoinsChanged, totalCoins); + + public static void EmitLivesChanged(int lives) + => Instance?.EmitSignal(SignalName.LivesChanged, lives); + #endregion } \ No newline at end of file diff --git a/Autoloads/GameManager.cs b/Autoloads/GameManager.cs index b7bfc9f..55f891e 100644 --- a/Autoloads/GameManager.cs +++ b/Autoloads/GameManager.cs @@ -1,13 +1,16 @@ using System.Collections.Generic; using Godot; using Godot.Collections; -using Mr.BrickAdventures; using Mr.BrickAdventures.scripts.components; using Mr.BrickAdventures.scripts.Resources; -using Double = System.Double; +using Mr.BrickAdventures.scripts.State; namespace Mr.BrickAdventures.Autoloads; +/// +/// Game orchestrator - handles scene management and game flow. +/// State is delegated to GameStateStore for better separation of concerns. +/// public partial class GameManager : Node { [Export] public Array LevelScenes { get; set; } = []; @@ -22,24 +25,10 @@ public partial class GameManager : Node private PlayerController _player; private SpeedRunManager _speedRunManager; - - [Export] - public Dictionary PlayerState { get; set; } = new() - { - { "coins", 0 }, - { "lives", 3 }, - { "current_level", 0 }, - { "completed_levels", new Array() }, - { "unlocked_levels", new Array() {0}}, - { "unlocked_skills", new Array() } - }; - - [Export] - public Dictionary CurrentSessionState { get; private set; } = new() - { - { "coins_collected", 0 }, - { "skills_unlocked", new Array() } - }; + /// + /// Lazy accessor for GameStateStore - avoids initialization order issues. + /// + private GameStateStore Store => GameStateStore.Instance; public override void _EnterTree() { @@ -57,7 +46,6 @@ public partial class GameManager : Node public override void _Ready() { _speedRunManager = GetNode(Constants.SpeedRunManagerPath); - } private void OnNodeAdded(Node node) @@ -74,57 +62,71 @@ public partial class GameManager : Node } } + #region Coin Operations + public void AddCoins(int amount) { - PlayerState["coins"] = Mathf.Max(0, (int)PlayerState["coins"] + amount); - } - - public void SetCoins(int amount) => PlayerState["coins"] = Mathf.Max(0, amount); - - public int GetCoins() => (int)PlayerState["coins"] + (int)CurrentSessionState["coins_collected"]; - - public void RemoveCoins(int amount) - { - var sessionCoins = (int)CurrentSessionState["coins_collected"]; - if (amount <= sessionCoins) + if (Store != null) { - CurrentSessionState["coins_collected"] = sessionCoins - amount; + Store.Player.Coins += amount; + EventBus.EmitCoinsChanged(Store.GetTotalCoins()); } - else - { - var remaining = amount - sessionCoins; - CurrentSessionState["coins_collected"] = 0; - PlayerState["coins"] = Mathf.Max(0, (int)PlayerState["coins"] - remaining); - } - PlayerState["coins"] = Mathf.Max(0, (int)PlayerState["coins"]); } - public void AddLives(int amount) => PlayerState["lives"] = (int)PlayerState["lives"] + amount; - public void RemoveLives(int amount) => PlayerState["lives"] = (int)PlayerState["lives"] - amount; - public void SetLives(int amount) => PlayerState["lives"] = amount; - public int GetLives() => (int)PlayerState["lives"]; - - public bool IsSkillUnlocked(SkillData skill) + public void SetCoins(int amount) { - return ((Array)PlayerState["unlocked_skills"]).Contains(skill) - || ((Array)CurrentSessionState["skills_unlocked"]).Contains(skill); + if (Store != null) + { + Store.Player.Coins = Mathf.Max(0, amount); + EventBus.EmitCoinsChanged(Store.GetTotalCoins()); + } } + public int GetCoins() => Store?.GetTotalCoins() ?? 0; + + public void RemoveCoins(int amount) => Store?.RemoveCoins(amount); + + #endregion + + #region Lives Operations + + public void AddLives(int amount) => Store?.AddLives(amount); + public void RemoveLives(int amount) => Store?.RemoveLife(); + public void SetLives(int amount) + { + if (Store != null) + { + Store.Player.Lives = amount; + EventBus.EmitLivesChanged(amount); + } + } + public int GetLives() => Store?.Player.Lives ?? 0; + + #endregion + + #region Skill Operations + + public bool IsSkillUnlocked(SkillData skill) => Store?.IsSkillUnlocked(skill) ?? false; + public void UnlockSkill(SkillData skill) { - if (!IsSkillUnlocked(skill)) - ((Array)PlayerState["unlocked_skills"]).Add(skill); + if (Store != null && !Store.IsSkillUnlocked(skill)) + { + Store.Player.UnlockedSkills.Add(skill); + } } public void RemoveSkill(string skillName) { - var arr = (Array)PlayerState["unlocked_skills"]; - foreach (SkillData s in arr) + if (Store == null) return; + var skills = Store.Player.UnlockedSkills; + for (int i = 0; i < skills.Count; i++) { - if (s.Name != skillName) continue; - - arr.Remove(s); - break; + if (skills[i].Name == skillName) + { + skills.RemoveAt(i); + break; + } } } @@ -134,76 +136,79 @@ public partial class GameManager : Node UnlockSkill(s); } - public void ResetPlayerState() + public Array GetUnlockedSkills() { - PlayerState = new Dictionary - { - { "coins", 0 }, - { "lives", 3 }, - { "current_level", 0 }, - { "completed_levels", new Array() }, - { "unlocked_levels", new Array() {0}}, - { "unlocked_skills", new Array() }, - { "statistics", new Godot.Collections.Dictionary()} - }; + if (Store == null) return new Array(); + + var result = new Array(); + foreach (var s in Store.Player.UnlockedSkills) + result.Add(s); + foreach (var s in Store.Session.SkillsUnlocked) + if (!result.Contains(s)) result.Add(s); + return result; } - public void UnlockLevel(int levelIndex) - { - var unlocked = (Array)PlayerState["unlocked_levels"]; - if (!unlocked.Contains(levelIndex)) unlocked.Add(levelIndex); - } + #endregion + + #region Level Operations + + public void UnlockLevel(int levelIndex) => Store?.UnlockLevel(levelIndex); + + public void MarkLevelComplete(int levelIndex) => Store?.MarkLevelComplete(levelIndex); public void TryToGoToNextLevel() { - var next = (int)PlayerState["current_level"] + 1; - var unlocked = (Array)PlayerState["unlocked_levels"]; - if (next < LevelScenes.Count && unlocked.Contains(next)) + if (Store == null) return; + + var next = Store.Session.CurrentLevel + 1; + if (next < LevelScenes.Count && Store.IsLevelUnlocked(next)) { - PlayerState["current_level"] = next; + Store.Session.CurrentLevel = next; GetTree().ChangeSceneToPacked(LevelScenes[next]); EventBus.EmitLevelStarted(next, GetTree().CurrentScene); } } - public void MarkLevelComplete(int levelIndex) - { - UnlockLevel(levelIndex + 1); - var completed = (Array)PlayerState["completed_levels"]; - if (!completed.Contains(levelIndex)) completed.Add(levelIndex); - } + #endregion - public void ResetCurrentSessionState() - { - CurrentSessionState = new Dictionary - { - { "coins_collected", 0 }, - { "skills_unlocked", new Array() } - }; - } + #region State Reset + + public void ResetPlayerState() => Store?.ResetAll(); + + public void ResetCurrentSessionState() => Store?.ResetSession(); + + #endregion + + #region Game Flow public void RestartGame() { - ResetPlayerState(); - ResetCurrentSessionState(); + Store?.ResetAll(); GetTree().ChangeSceneToPacked(LevelScenes[0]); GetNode(Constants.SaveSystemPath).SaveGame(); } public void QuitGame() => GetTree().Quit(); - public void PauseGame() => Engine.TimeScale = 0; - public void ResumeGame() => Engine.TimeScale = 1; + public void PauseGame() + { + Engine.TimeScale = 0; + EventBus.EmitGamePaused(); + } + + public void ResumeGame() + { + Engine.TimeScale = 1; + EventBus.EmitGameResumed(); + } public void StartNewGame() { - ResetPlayerState(); - ResetCurrentSessionState(); - + Store?.ResetAll(); _speedRunManager?.StartTimer(); - GetTree().ChangeSceneToPacked(LevelScenes[0]); GetNode(Constants.SaveSystemPath).SaveGame(); + EventBus.EmitGameStarted(); } public void ContinueGame() @@ -216,41 +221,38 @@ public partial class GameManager : Node return; } - var idx = (int)PlayerState["current_level"]; + var idx = Store?.Session.CurrentLevel ?? 0; if (idx < LevelScenes.Count) + { GetTree().ChangeSceneToPacked(LevelScenes[idx]); + EventBus.EmitGameContinued(); + } else + { GD.PrintErr("No levels unlocked to continue."); + } } public void OnLevelComplete() { - var levelIndex = (int)PlayerState["current_level"]; - MarkLevelComplete(levelIndex); + if (Store == null) return; - AddCoins((int)CurrentSessionState["coins_collected"]); - foreach (var s in (Array)CurrentSessionState["skills_unlocked"]) - UnlockSkill((SkillData)s); + var levelIndex = Store.Session.CurrentLevel; + Store.MarkLevelComplete(levelIndex); + Store.CommitSessionCoins(); + Store.CommitSessionSkills(); var completionTime = _speedRunManager?.GetCurrentLevelTime() ?? 0.0; EventBus.EmitLevelCompleted(levelIndex, GetTree().CurrentScene, completionTime); - ResetCurrentSessionState(); + Store.ResetSession(); TryToGoToNextLevel(); GetNode(Constants.SaveSystemPath).SaveGame(); } - public Array GetUnlockedSkills() - { - var unlocked = (Array)PlayerState["unlocked_skills"]; - var session = (Array)CurrentSessionState["skills_unlocked"]; - if (session!.Count == 0) return unlocked; - if (unlocked!.Count == 0) return session; - var joined = new Array(); - joined.AddRange(unlocked); - joined.AddRange(session); - return joined; - } + #endregion + + #region Player Lookup public PlayerController GetPlayer() { @@ -269,4 +271,6 @@ public partial class GameManager : Node GD.PrintErr("PlayerController not found in the scene tree."); return null; } + + #endregion } \ No newline at end of file diff --git a/Autoloads/GameStateStore.cs b/Autoloads/GameStateStore.cs new file mode 100644 index 0000000..46acff8 --- /dev/null +++ b/Autoloads/GameStateStore.cs @@ -0,0 +1,185 @@ +using Godot; +using Mr.BrickAdventures.scripts.Resources; +using Mr.BrickAdventures.scripts.State; + +namespace Mr.BrickAdventures.Autoloads; + +/// +/// Central store for game state - single source of truth. +/// Use the static Instance property for easy access. +/// +public partial class GameStateStore : Node +{ + /// + /// Singleton instance. + /// + public static GameStateStore Instance { get; private set; } + + /// + /// Persistent player state (saved to disk). + /// + public PlayerState Player { get; set; } = new(); + + /// + /// Current session state (transient, reset on death/level complete). + /// + public SessionState Session { get; set; } = new(); + + public override void _Ready() + { + Instance = this; + } + + public override void _ExitTree() + { + if (Instance == this) + Instance = null; + } + + #region Coin Operations + + /// + /// Gets total coins (saved + session). + /// + public int GetTotalCoins() => Player.Coins + Session.CoinsCollected; + + /// + /// Adds coins to the session (not saved until level complete). + /// + public void AddSessionCoins(int amount) + { + Session.CoinsCollected += amount; + EventBus.EmitCoinsChanged(GetTotalCoins()); + } + + /// + /// Commits session coins to player state. + /// + public void CommitSessionCoins() + { + Player.Coins += Session.CoinsCollected; + Session.CoinsCollected = 0; + } + + /// + /// Removes coins, first from session then from saved. + /// + public void RemoveCoins(int amount) + { + if (amount <= Session.CoinsCollected) + { + Session.CoinsCollected -= amount; + } + else + { + var remaining = amount - Session.CoinsCollected; + Session.CoinsCollected = 0; + Player.Coins = Mathf.Max(0, Player.Coins - remaining); + } + EventBus.EmitCoinsChanged(GetTotalCoins()); + } + + #endregion + + #region Lives Operations + + /// + /// Decrements lives by 1. + /// + public void RemoveLife() + { + Player.Lives = Mathf.Max(0, Player.Lives - 1); + EventBus.EmitLivesChanged(Player.Lives); + } + + /// + /// Adds lives. + /// + public void AddLives(int amount) + { + Player.Lives += amount; + EventBus.EmitLivesChanged(Player.Lives); + } + + #endregion + + #region Level Operations + + /// + /// Unlocks a level for access. + /// + public void UnlockLevel(int levelIndex) + { + if (!Player.UnlockedLevels.Contains(levelIndex)) + Player.UnlockedLevels.Add(levelIndex); + } + + /// + /// Marks a level as completed and unlocks the next. + /// + public void MarkLevelComplete(int levelIndex) + { + if (!Player.CompletedLevels.Contains(levelIndex)) + Player.CompletedLevels.Add(levelIndex); + UnlockLevel(levelIndex + 1); + } + + /// + /// Checks if a level is unlocked. + /// + public bool IsLevelUnlocked(int levelIndex) => Player.UnlockedLevels.Contains(levelIndex); + + #endregion + + #region Skill Operations + + /// + /// Checks if a skill is unlocked (saved or session). + /// + public bool IsSkillUnlocked(SkillData skill) + { + return Player.UnlockedSkills.Contains(skill) || Session.SkillsUnlocked.Contains(skill); + } + + /// + /// Unlocks a skill in the session. + /// + public void UnlockSkillInSession(SkillData skill) + { + if (!IsSkillUnlocked(skill)) + Session.SkillsUnlocked.Add(skill); + } + + /// + /// Commits session skills to player state. + /// + public void CommitSessionSkills() + { + foreach (var skill in Session.SkillsUnlocked) + { + if (!Player.UnlockedSkills.Contains(skill)) + Player.UnlockedSkills.Add(skill); + } + Session.SkillsUnlocked.Clear(); + } + + #endregion + + #region Reset Operations + + /// + /// Resets only the session state. + /// + public void ResetSession() => Session.Reset(); + + /// + /// Resets everything to defaults. + /// + public void ResetAll() + { + Player.Reset(); + Session.ResetAll(); + } + + #endregion +} diff --git a/Autoloads/GameStateStore.cs.uid b/Autoloads/GameStateStore.cs.uid new file mode 100644 index 0000000..f216d4d --- /dev/null +++ b/Autoloads/GameStateStore.cs.uid @@ -0,0 +1 @@ +uid://bwrhkipwecytk diff --git a/Autoloads/SaveSystem.cs b/Autoloads/SaveSystem.cs index 0eeda5f..56afb00 100644 --- a/Autoloads/SaveSystem.cs +++ b/Autoloads/SaveSystem.cs @@ -1,62 +1,123 @@ +using System.Text.Json; using Godot; -using Godot.Collections; -using Mr.BrickAdventures; -using Mr.BrickAdventures.scripts.Resources; +using Mr.BrickAdventures.scripts.State; namespace Mr.BrickAdventures.Autoloads; +/// +/// Save system that serializes POCOs directly to JSON. +/// public partial class SaveSystem : Node { - [Export] public string SavePath { get; set; } = "user://savegame.save"; - [Export] public int Version { get; set; } = 1; + [Export] public string SavePath { get; set; } = "user://savegame.json"; + [Export] public int Version { get; set; } = 2; // Bumped version for new format - private GameManager _gameManager; + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; public override void _Ready() { - _gameManager = GetNode(Constants.GameManagerPath); + // No longer needs GameManager reference - works with GameStateStore directly } public void SaveGame() { - var saveData = new Dictionary + var store = GameStateStore.Instance; + if (store == null) { - { "player_state", _gameManager.PlayerState}, - { "version", Version} + GD.PrintErr("SaveSystem: GameStateStore not available."); + return; + } + + var saveData = new SaveData + { + Version = Version, + Player = store.Player, + CurrentLevel = store.Session.CurrentLevel }; - using var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Write); - file.StoreVar(saveData); - GD.Print("Game state saved to: ", SavePath); + try + { + var json = JsonSerializer.Serialize(saveData, JsonOptions); + using var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Write); + file.StoreString(json); + GD.Print("Game saved to: ", SavePath); + EventBus.EmitGameSaved(); + } + catch (System.Exception e) + { + GD.PrintErr($"SaveSystem: Failed to save game: {e.Message}"); + } } public bool LoadGame() { if (!FileAccess.FileExists(SavePath)) - return false; - - using var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Read); - var saveDataObj = (Dictionary)file.GetVar(); - - if (saveDataObj.ContainsKey("version") && (int)saveDataObj["version"] != Version) { - GD.Print($"Save file version mismatch. Expected: {Version}, Found: {saveDataObj["version"]}"); + GD.Print("SaveSystem: No save file found."); return false; } - GD.Print("Game state loaded from: ", SavePath); - GD.Print("Player state: ", saveDataObj["player_state"]); - _gameManager.PlayerState = (Dictionary)saveDataObj["player_state"]; - - var skills = new Array(); - foreach (var skill in (Array)_gameManager.PlayerState["unlocked_skills"]) + try { - skills.Add(skill); - } + using var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Read); + var json = file.GetAsText(); + var saveData = JsonSerializer.Deserialize(json, JsonOptions); - _gameManager.UnlockSkills(skills); - return true; + if (saveData == null) + { + GD.PrintErr("SaveSystem: Failed to deserialize save data."); + return false; + } + + if (saveData.Version != Version) + { + GD.PrintErr($"SaveSystem: Version mismatch. Expected {Version}, found {saveData.Version}"); + return false; + } + + var store = GameStateStore.Instance; + if (store == null) + { + GD.PrintErr("SaveSystem: GameStateStore not available."); + return false; + } + + // Apply loaded state + store.Player = saveData.Player ?? new PlayerState(); + store.Session.CurrentLevel = saveData.CurrentLevel; + + GD.Print("Game loaded from: ", SavePath); + return true; + } + catch (System.Exception e) + { + GD.PrintErr($"SaveSystem: Failed to load game: {e.Message}"); + return false; + } } public bool CheckSaveExists() => FileAccess.FileExists(SavePath); + + public void DeleteSave() + { + if (FileAccess.FileExists(SavePath)) + { + DirAccess.RemoveAbsolute(ProjectSettings.GlobalizePath(SavePath)); + GD.Print("Save file deleted."); + } + } +} + +/// +/// Container for save data. +/// +public class SaveData +{ + public int Version { get; set; } + public PlayerState Player { get; set; } + public int CurrentLevel { get; set; } } \ No newline at end of file diff --git a/Autoloads/StatisticsManager.cs b/Autoloads/StatisticsManager.cs index f8406bd..c2669f3 100644 --- a/Autoloads/StatisticsManager.cs +++ b/Autoloads/StatisticsManager.cs @@ -1,33 +1,21 @@ +using System.Collections.Generic; using Godot; -using Godot.Collections; -using Mr.BrickAdventures; +using Mr.BrickAdventures.scripts.State; namespace Mr.BrickAdventures.Autoloads; +/// +/// Manages game statistics using GameStateStore. +/// public partial class StatisticsManager : Node { - private GameManager _gameManager; - private AchievementManager _achievementManager; - private Dictionary _stats = new(); - public override void _Ready() + /// + /// Gets the statistics dictionary from the store. + /// + private Dictionary GetStats() { - _gameManager = GetNode(Constants.GameManagerPath); - _achievementManager = GetNode(Constants.AchievementManagerPath); - LoadStatistics(); - } - - private void LoadStatistics() - { - if (_gameManager.PlayerState.TryGetValue("statistics", out var statsObj)) - { - _stats = (Dictionary)statsObj; - } - else - { - _stats = new Dictionary(); - _gameManager.PlayerState["statistics"] = _stats; - } + return GameStateStore.Instance?.Player.Statistics ?? new Dictionary(); } /// @@ -35,45 +23,40 @@ public partial class StatisticsManager : Node /// public void IncrementStat(string statName, int amount = 1) { - if (_stats.TryGetValue(statName, out var currentValue)) + var stats = GetStats(); + if (stats.TryGetValue(statName, out var currentValue)) { - _stats[statName] = (int)currentValue + amount; + stats[statName] = currentValue + amount; } else { - _stats[statName] = amount; + stats[statName] = amount; } - GD.Print($"Stat '{statName}' updated to: {_stats[statName]}"); - CheckAchievementsForStat(statName); + } + + /// + /// Sets a statistic to a specific value. + /// + public void SetStat(string statName, int value) + { + var stats = GetStats(); + stats[statName] = value; } /// /// Gets the value of a statistic. /// - public Variant GetStat(string statName, Variant defaultValue = default) + public int GetStat(string statName) { - return _stats.TryGetValue(statName, out var value) ? value : defaultValue; + var stats = GetStats(); + return stats.TryGetValue(statName, out var value) ? value : 0; } /// - /// Checks if the updated stat meets the criteria for any achievements. + /// Gets a copy of all statistics. /// - private void CheckAchievementsForStat(string statName) + public Dictionary GetAllStats() { - switch (statName) - { - case "enemies_defeated": - if ((int)GetStat(statName, 0) >= 100) - { - _achievementManager.UnlockAchievement("slayer_100_enemies"); - } - break; - case "jumps_made": - if ((int)GetStat(statName, 0) >= 1000) - { - _achievementManager.UnlockAchievement("super_jumper"); - } - break; - } + return new Dictionary(GetStats()); } } \ No newline at end of file diff --git a/project.godot b/project.godot index 69c2537..61af395 100644 --- a/project.godot +++ b/project.godot @@ -29,7 +29,6 @@ config/icon="uid://jix7wdn0isr3" [autoload] -GameManager="*res://objects/game_manager.tscn" PhantomCameraManager="*res://addons/phantom_camera/scripts/managers/phantom_camera_manager.gd" AudioController="*res://objects/audio_controller.tscn" UIManager="*res://Autoloads/UIManager.cs" @@ -46,8 +45,12 @@ EventBus="*res://Autoloads/EventBus.cs" StatisticsManager="*res://Autoloads/StatisticsManager.cs" SpeedRunManager="res://Autoloads/SpeedRunManager.cs" GhostManager="res://objects/ghost_manager.tscn" -ScoreEventHandler="*res://scripts/Events/ScoreEventHandler.cs" StatisticsEventHandler="*res://scripts/Events/StatisticsEventHandler.cs" +CoinStateHandler="*res://scripts/Events/CoinStateHandler.cs" +LevelStateHandler="*res://scripts/Events/LevelStateHandler.cs" +LivesStateHandler="*res://scripts/Events/LivesStateHandler.cs" +GameStateStore="*res://Autoloads/GameStateStore.cs" +GameManager="*res://objects/game_manager.tscn" [debug] diff --git a/scripts/Constants.cs b/scripts/Constants.cs index a5b064b..b660e7b 100644 --- a/scripts/Constants.cs +++ b/scripts/Constants.cs @@ -8,6 +8,7 @@ public static class Constants // Autoload paths public const string EventBusPath = "/root/EventBus"; public const string GameManagerPath = "/root/GameManager"; + public const string GameStateStorePath = "/root/GameStateStore"; public const string SaveSystemPath = "/root/SaveSystem"; public const string SpeedRunManagerPath = "/root/SpeedRunManager"; public const string GhostManagerPath = "/root/GhostManager"; diff --git a/scripts/Events/CoinStateHandler.cs b/scripts/Events/CoinStateHandler.cs new file mode 100644 index 0000000..dc6b9b1 --- /dev/null +++ b/scripts/Events/CoinStateHandler.cs @@ -0,0 +1,29 @@ +using Godot; +using Mr.BrickAdventures.Autoloads; + +namespace Mr.BrickAdventures.scripts.Events; + +/// +/// Handles coin collection events and updates the GameStateStore. +/// Replaces the manual coin logic in GameManager. +/// +public partial class CoinStateHandler : Node +{ + public override void _Ready() + { + EventBus.Instance.CoinCollected += OnCoinCollected; + } + + public override void _ExitTree() + { + if (EventBus.Instance != null) + { + EventBus.Instance.CoinCollected -= OnCoinCollected; + } + } + + private void OnCoinCollected(int amount, Vector2 position) + { + GameStateStore.Instance?.AddSessionCoins(amount); + } +} diff --git a/scripts/Events/CoinStateHandler.cs.uid b/scripts/Events/CoinStateHandler.cs.uid new file mode 100644 index 0000000..550fa58 --- /dev/null +++ b/scripts/Events/CoinStateHandler.cs.uid @@ -0,0 +1 @@ +uid://1qg3q53kkh0k diff --git a/scripts/Events/LevelStateHandler.cs b/scripts/Events/LevelStateHandler.cs new file mode 100644 index 0000000..5925e6f --- /dev/null +++ b/scripts/Events/LevelStateHandler.cs @@ -0,0 +1,39 @@ +using Godot; +using Mr.BrickAdventures.Autoloads; + +namespace Mr.BrickAdventures.scripts.Events; + +/// +/// Handles level completion events and updates GameStateStore. +/// +public partial class LevelStateHandler : Node +{ + public override void _Ready() + { + EventBus.Instance.LevelCompleted += OnLevelCompleted; + } + + public override void _ExitTree() + { + if (EventBus.Instance != null) + { + EventBus.Instance.LevelCompleted -= OnLevelCompleted; + } + } + + private void OnLevelCompleted(int levelIndex, Node currentScene, double completionTime) + { + var store = GameStateStore.Instance; + if (store == null) return; + + // Mark level complete and unlock next + store.MarkLevelComplete(levelIndex); + + // Commit session data to persistent state + store.CommitSessionCoins(); + store.CommitSessionSkills(); + + // Reset session for next level + store.ResetSession(); + } +} diff --git a/scripts/Events/LevelStateHandler.cs.uid b/scripts/Events/LevelStateHandler.cs.uid new file mode 100644 index 0000000..4cb8409 --- /dev/null +++ b/scripts/Events/LevelStateHandler.cs.uid @@ -0,0 +1 @@ +uid://gx5vn7viphv diff --git a/scripts/Events/LivesStateHandler.cs b/scripts/Events/LivesStateHandler.cs new file mode 100644 index 0000000..77179b1 --- /dev/null +++ b/scripts/Events/LivesStateHandler.cs @@ -0,0 +1,29 @@ +using Godot; +using Mr.BrickAdventures.Autoloads; + +namespace Mr.BrickAdventures.scripts.Events; + +/// +/// Handles player death events and updates lives in GameStateStore. +/// +public partial class LivesStateHandler : Node +{ + public override void _Ready() + { + EventBus.Instance.PlayerDied += OnPlayerDied; + } + + public override void _ExitTree() + { + if (EventBus.Instance != null) + { + EventBus.Instance.PlayerDied -= OnPlayerDied; + } + } + + private void OnPlayerDied(Vector2 position) + { + GameStateStore.Instance?.RemoveLife(); + GameStateStore.Instance?.ResetSession(); + } +} diff --git a/scripts/Events/LivesStateHandler.cs.uid b/scripts/Events/LivesStateHandler.cs.uid new file mode 100644 index 0000000..ade5217 --- /dev/null +++ b/scripts/Events/LivesStateHandler.cs.uid @@ -0,0 +1 @@ +uid://b4ocg7g8vmtvp diff --git a/scripts/Events/ScoreEventHandler.cs b/scripts/Events/ScoreEventHandler.cs deleted file mode 100644 index 0bb7b43..0000000 --- a/scripts/Events/ScoreEventHandler.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Godot; -using Mr.BrickAdventures; -using Mr.BrickAdventures.Autoloads; - -namespace Mr.BrickAdventures.scripts.Events; - -/// -/// Handles coin collection events and updates the session state. -/// Replaces the manual signal wiring in ScoreComponent. -/// -public partial class ScoreEventHandler : Node -{ - private GameManager _gameManager; - - public override void _Ready() - { - _gameManager = GetNode(Constants.GameManagerPath); - - EventBus.Instance.CoinCollected += OnCoinCollected; - } - - public override void _ExitTree() - { - if (EventBus.Instance != null) - EventBus.Instance.CoinCollected -= OnCoinCollected; - } - - private void OnCoinCollected(int amount, Vector2 position) - { - var currentCoins = (int)_gameManager.CurrentSessionState["coins_collected"]; - _gameManager.CurrentSessionState["coins_collected"] = currentCoins + amount; - GD.Print($"ScoreEventHandler: Collected {amount} coins. Total session coins: {currentCoins + amount}"); - } -} diff --git a/scripts/Events/ScoreEventHandler.cs.uid b/scripts/Events/ScoreEventHandler.cs.uid deleted file mode 100644 index c168916..0000000 --- a/scripts/Events/ScoreEventHandler.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cs4cfk7g5vh2v diff --git a/scripts/State/PlayerState.cs b/scripts/State/PlayerState.cs new file mode 100644 index 0000000..8388217 --- /dev/null +++ b/scripts/State/PlayerState.cs @@ -0,0 +1,73 @@ +using System.Collections.Generic; +using Mr.BrickAdventures.scripts.Resources; + +namespace Mr.BrickAdventures.scripts.State; + +/// +/// Persistent player data that survives across sessions. +/// This is a POCO (Plain Old C# Object) for predictable state management. +/// +public class PlayerState +{ + /// + /// Saved coins (not including current session). + /// + public int Coins { get; set; } + + /// + /// Remaining lives. + /// + public int Lives { get; set; } = 3; + + /// + /// Indices of completed levels. + /// + public List CompletedLevels { get; set; } = new(); + + /// + /// Indices of levels the player can access. + /// + public List UnlockedLevels { get; set; } = new() { 0 }; + + /// + /// Skills the player has permanently unlocked. + /// + public List UnlockedSkills { get; set; } = new(); + + /// + /// Statistics dictionary for tracking game stats. + /// + public Dictionary Statistics { get; set; } = new(); + + /// + /// IDs of unlocked achievements. + /// + public List UnlockedAchievements { get; set; } = new(); + + /// + /// Creates a fresh default player state. + /// + public static PlayerState CreateDefault() => new() + { + Coins = 0, + Lives = 3, + CompletedLevels = new List(), + UnlockedLevels = new List { 0 }, + UnlockedSkills = new List(), + Statistics = new Dictionary() + }; + + /// + /// Resets this state to default values. + /// + public void Reset() + { + Coins = 0; + Lives = 3; + CompletedLevels.Clear(); + UnlockedLevels.Clear(); + UnlockedLevels.Add(0); + UnlockedSkills.Clear(); + Statistics.Clear(); + } +} diff --git a/scripts/State/PlayerState.cs.uid b/scripts/State/PlayerState.cs.uid new file mode 100644 index 0000000..348078b --- /dev/null +++ b/scripts/State/PlayerState.cs.uid @@ -0,0 +1 @@ +uid://gtr1e60jq7iv diff --git a/scripts/State/SessionState.cs b/scripts/State/SessionState.cs new file mode 100644 index 0000000..a9d3578 --- /dev/null +++ b/scripts/State/SessionState.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using Mr.BrickAdventures.scripts.Resources; + +namespace Mr.BrickAdventures.scripts.State; + +/// +/// Data for the current gameplay session. +/// Reset when player dies or completes a level. +/// +public class SessionState +{ + /// + /// Current level index being played. + /// + public int CurrentLevel { get; set; } + + /// + /// Coins collected during this session (not yet saved). + /// + public int CoinsCollected { get; set; } + + /// + /// Skills unlocked during this session (not yet saved). + /// + public List SkillsUnlocked { get; set; } = new(); + + /// + /// Creates a fresh session state. + /// + public static SessionState CreateDefault() => new() + { + CurrentLevel = 0, + CoinsCollected = 0, + SkillsUnlocked = new List() + }; + + /// + /// Resets session state to defaults. + /// + public void Reset() + { + CoinsCollected = 0; + SkillsUnlocked.Clear(); + } + + /// + /// Resets completely including level. + /// + public void ResetAll() + { + CurrentLevel = 0; + CoinsCollected = 0; + SkillsUnlocked.Clear(); + } +} diff --git a/scripts/State/SessionState.cs.uid b/scripts/State/SessionState.cs.uid new file mode 100644 index 0000000..5b23b3e --- /dev/null +++ b/scripts/State/SessionState.cs.uid @@ -0,0 +1 @@ +uid://chqsdleqrnl7b diff --git a/scripts/components/ExitDoorComponent.cs b/scripts/components/ExitDoorComponent.cs index fa816d6..602f53e 100644 --- a/scripts/components/ExitDoorComponent.cs +++ b/scripts/components/ExitDoorComponent.cs @@ -2,6 +2,7 @@ using Godot; using Mr.BrickAdventures; using Mr.BrickAdventures.Autoloads; using Mr.BrickAdventures.scripts.interfaces; +using Mr.BrickAdventures.scripts.State; namespace Mr.BrickAdventures.scripts.components; @@ -34,7 +35,9 @@ public partial class ExitDoorComponent : Area2D, IUnlockable EmitSignalExitTriggered(); _achievementManager.UnlockAchievement(AchievementId); - _gameManager.UnlockLevel((int)_gameManager.PlayerState["current_level"] + 1); + // Get current level from GameStateStore + var currentLevel = GameStateStore.Instance?.Session.CurrentLevel ?? 0; + _gameManager.UnlockLevel(currentLevel + 1); CallDeferred(nameof(GoToNextLevel)); } diff --git a/scripts/components/SkillUnlockerComponent.cs b/scripts/components/SkillUnlockerComponent.cs index 1b1dff4..956a513 100644 --- a/scripts/components/SkillUnlockerComponent.cs +++ b/scripts/components/SkillUnlockerComponent.cs @@ -4,6 +4,7 @@ using Mr.BrickAdventures; using Mr.BrickAdventures.Autoloads; using Mr.BrickAdventures.scripts.interfaces; using Mr.BrickAdventures.scripts.Resources; +using Mr.BrickAdventures.scripts.State; namespace Mr.BrickAdventures.scripts.components; @@ -38,8 +39,8 @@ public partial class SkillUnlockerComponent : Node skill.IsActive = true; _gameManager.RemoveCoins(skill.Upgrades[0].Cost); - var skillsUnlocked = (Array)_gameManager.CurrentSessionState["skills_unlocked"]; - skillsUnlocked.Add(skill); + // Add to session state via GameStateStore + GameStateStore.Instance?.UnlockSkillInSession(skill); SkillManager.AddSkill(skill); EmitSignalSkillUnlocked(skill);