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);