Compare commits

...

3 Commits

25 changed files with 795 additions and 266 deletions

View File

@@ -1,24 +1,22 @@
using Godot; using Godot;
using Godot.Collections;
using Mr.BrickAdventures;
using Mr.BrickAdventures.scripts.Resources; using Mr.BrickAdventures.scripts.Resources;
using Mr.BrickAdventures.scripts.State;
namespace Mr.BrickAdventures.Autoloads; namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Manages achievements using GameStateStore.
/// </summary>
public partial class AchievementManager : Node public partial class AchievementManager : Node
{ {
[Export] private string AchievementsFolderPath = "res://achievements/"; [Export] private string AchievementsFolderPath = "res://achievements/";
[Export] private PackedScene AchievementPopupScene { get; set; } [Export] private PackedScene AchievementPopupScene { get; set; }
private System.Collections.Generic.Dictionary<string, AchievementResource> _achievements = new(); private System.Collections.Generic.Dictionary<string, AchievementResource> _achievements = new();
private Array<string> _unlockedAchievementIds = [];
private GameManager _gameManager;
public override void _Ready() public override void _Ready()
{ {
_gameManager = GetNode<GameManager>(Constants.GameManagerPath);
LoadAchievementsFromFolder(); LoadAchievementsFromFolder();
LoadUnlockedAchievements();
} }
private void LoadAchievementsFromFolder() private void LoadAchievementsFromFolder()
@@ -46,6 +44,14 @@ public partial class AchievementManager : Node
} }
} }
/// <summary>
/// Gets the list of unlocked achievement IDs from the store.
/// </summary>
private System.Collections.Generic.List<string> GetUnlockedIds()
{
return GameStateStore.Instance?.Player.UnlockedAchievements ?? new System.Collections.Generic.List<string>();
}
public void UnlockAchievement(string achievementId) public void UnlockAchievement(string achievementId)
{ {
if (!_achievements.TryGetValue(achievementId, out var achievement)) if (!_achievements.TryGetValue(achievementId, out var achievement))
@@ -54,13 +60,14 @@ public partial class AchievementManager : Node
return; return;
} }
if (_unlockedAchievementIds.Contains(achievementId)) var unlockedIds = GetUnlockedIds();
if (unlockedIds.Contains(achievementId))
{ {
return; // Already unlocked return; // Already unlocked
} }
// 1. Mark as unlocked internally // 1. Mark as unlocked
_unlockedAchievementIds.Add(achievementId); unlockedIds.Add(achievementId);
GD.Print($"Achievement Unlocked: {achievement.DisplayName}"); GD.Print($"Achievement Unlocked: {achievement.DisplayName}");
// 2. Show the UI popup // 2. Show the UI popup
@@ -76,31 +83,19 @@ public partial class AchievementManager : Node
{ {
SteamManager.UnlockAchievement(achievement.Id); SteamManager.UnlockAchievement(achievement.Id);
} }
// 4. Save progress
SaveUnlockedAchievements();
} }
public void LockAchievement(string achievementId) public void LockAchievement(string achievementId)
{ {
if (_unlockedAchievementIds.Contains(achievementId)) var unlockedIds = GetUnlockedIds();
if (unlockedIds.Contains(achievementId))
{ {
_unlockedAchievementIds.Remove(achievementId); unlockedIds.Remove(achievementId);
SaveUnlockedAchievements();
} }
} }
private void SaveUnlockedAchievements() public bool IsAchievementUnlocked(string achievementId)
{ {
_gameManager.PlayerState["unlocked_achievements"] = _unlockedAchievementIds; return GetUnlockedIds().Contains(achievementId);
// 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<string>)unlocked;
}
} }
} }

View File

@@ -116,6 +116,8 @@ public partial class EventBus : Node
[Signal] public delegate void GamePausedEventHandler(); [Signal] public delegate void GamePausedEventHandler();
[Signal] public delegate void GameResumedEventHandler(); [Signal] public delegate void GameResumedEventHandler();
[Signal] public delegate void GameSavedEventHandler(); [Signal] public delegate void GameSavedEventHandler();
[Signal] public delegate void GameStartedEventHandler();
[Signal] public delegate void GameContinuedEventHandler();
public static void EmitGamePaused() public static void EmitGamePaused()
=> Instance?.EmitSignal(SignalName.GamePaused); => Instance?.EmitSignal(SignalName.GamePaused);
@@ -126,5 +128,24 @@ public partial class EventBus : Node
public static void EmitGameSaved() public static void EmitGameSaved()
=> Instance?.EmitSignal(SignalName.GameSaved); => 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 #endregion
} }

View File

@@ -1,13 +1,16 @@
using System.Collections.Generic; using System.Collections.Generic;
using Godot; using Godot;
using Godot.Collections; using Godot.Collections;
using Mr.BrickAdventures;
using Mr.BrickAdventures.scripts.components; using Mr.BrickAdventures.scripts.components;
using Mr.BrickAdventures.scripts.Resources; using Mr.BrickAdventures.scripts.Resources;
using Double = System.Double; using Mr.BrickAdventures.scripts.State;
namespace Mr.BrickAdventures.Autoloads; namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Game orchestrator - handles scene management and game flow.
/// State is delegated to GameStateStore for better separation of concerns.
/// </summary>
public partial class GameManager : Node public partial class GameManager : Node
{ {
[Export] public Array<PackedScene> LevelScenes { get; set; } = []; [Export] public Array<PackedScene> LevelScenes { get; set; } = [];
@@ -22,24 +25,10 @@ public partial class GameManager : Node
private PlayerController _player; private PlayerController _player;
private SpeedRunManager _speedRunManager; private SpeedRunManager _speedRunManager;
/// <summary>
[Export] /// Lazy accessor for GameStateStore - avoids initialization order issues.
public Dictionary PlayerState { get; set; } = new() /// </summary>
{ private GameStateStore Store => GameStateStore.Instance;
{ "coins", 0 },
{ "lives", 3 },
{ "current_level", 0 },
{ "completed_levels", new Array<int>() },
{ "unlocked_levels", new Array<int>() {0}},
{ "unlocked_skills", new Array<SkillData>() }
};
[Export]
public Dictionary CurrentSessionState { get; private set; } = new()
{
{ "coins_collected", 0 },
{ "skills_unlocked", new Array<SkillData>() }
};
public override void _EnterTree() public override void _EnterTree()
{ {
@@ -57,7 +46,6 @@ public partial class GameManager : Node
public override void _Ready() public override void _Ready()
{ {
_speedRunManager = GetNode<SpeedRunManager>(Constants.SpeedRunManagerPath); _speedRunManager = GetNode<SpeedRunManager>(Constants.SpeedRunManagerPath);
} }
private void OnNodeAdded(Node node) private void OnNodeAdded(Node node)
@@ -74,59 +62,73 @@ public partial class GameManager : Node
} }
} }
#region Coin Operations
public void AddCoins(int amount) public void AddCoins(int amount)
{ {
PlayerState["coins"] = Mathf.Max(0, (int)PlayerState["coins"] + amount); if (Store != null)
}
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"]; Store.Player.Coins += amount;
if (amount <= sessionCoins) EventBus.EmitCoinsChanged(Store.GetTotalCoins());
{
CurrentSessionState["coins_collected"] = sessionCoins - amount;
} }
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 SetCoins(int 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)
{ {
return ((Array)PlayerState["unlocked_skills"]).Contains(skill) if (Store != null)
|| ((Array)CurrentSessionState["skills_unlocked"]).Contains(skill); {
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) public void UnlockSkill(SkillData skill)
{ {
if (!IsSkillUnlocked(skill)) if (Store != null && !Store.IsSkillUnlocked(skill))
((Array)PlayerState["unlocked_skills"]).Add(skill); {
Store.Player.UnlockedSkills.Add(skill);
}
} }
public void RemoveSkill(string skillName) public void RemoveSkill(string skillName)
{ {
var arr = (Array)PlayerState["unlocked_skills"]; if (Store == null) return;
foreach (SkillData s in arr) var skills = Store.Player.UnlockedSkills;
for (int i = 0; i < skills.Count; i++)
{ {
if (s.Name != skillName) continue; if (skills[i].Name == skillName)
{
arr.Remove(s); skills.RemoveAt(i);
break; break;
} }
} }
}
public void UnlockSkills(Array<SkillData> skills) public void UnlockSkills(Array<SkillData> skills)
{ {
@@ -134,76 +136,79 @@ public partial class GameManager : Node
UnlockSkill(s); UnlockSkill(s);
} }
public void ResetPlayerState() public Array<SkillData> GetUnlockedSkills()
{ {
PlayerState = new Dictionary if (Store == null) return new Array<SkillData>();
{
{ "coins", 0 }, var result = new Array<SkillData>();
{ "lives", 3 }, foreach (var s in Store.Player.UnlockedSkills)
{ "current_level", 0 }, result.Add(s);
{ "completed_levels", new Array<int>() }, foreach (var s in Store.Session.SkillsUnlocked)
{ "unlocked_levels", new Array<int>() {0}}, if (!result.Contains(s)) result.Add(s);
{ "unlocked_skills", new Array<SkillData>() }, return result;
{ "statistics", new Godot.Collections.Dictionary<string, Variant>()}
};
} }
public void UnlockLevel(int levelIndex) #endregion
{
var unlocked = (Array)PlayerState["unlocked_levels"]; #region Level Operations
if (!unlocked.Contains(levelIndex)) unlocked.Add(levelIndex);
} public void UnlockLevel(int levelIndex) => Store?.UnlockLevel(levelIndex);
public void MarkLevelComplete(int levelIndex) => Store?.MarkLevelComplete(levelIndex);
public void TryToGoToNextLevel() public void TryToGoToNextLevel()
{ {
var next = (int)PlayerState["current_level"] + 1; if (Store == null) return;
var unlocked = (Array)PlayerState["unlocked_levels"];
if (next < LevelScenes.Count && unlocked.Contains(next)) 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]); GetTree().ChangeSceneToPacked(LevelScenes[next]);
EventBus.EmitLevelStarted(next, GetTree().CurrentScene); EventBus.EmitLevelStarted(next, GetTree().CurrentScene);
} }
} }
public void MarkLevelComplete(int levelIndex) #endregion
{
UnlockLevel(levelIndex + 1);
var completed = (Array)PlayerState["completed_levels"];
if (!completed.Contains(levelIndex)) completed.Add(levelIndex);
}
public void ResetCurrentSessionState() #region State Reset
{
CurrentSessionState = new Dictionary public void ResetPlayerState() => Store?.ResetAll();
{
{ "coins_collected", 0 }, public void ResetCurrentSessionState() => Store?.ResetSession();
{ "skills_unlocked", new Array<SkillData>() }
}; #endregion
}
#region Game Flow
public void RestartGame() public void RestartGame()
{ {
ResetPlayerState(); Store?.ResetAll();
ResetCurrentSessionState();
GetTree().ChangeSceneToPacked(LevelScenes[0]); GetTree().ChangeSceneToPacked(LevelScenes[0]);
GetNode<SaveSystem>(Constants.SaveSystemPath).SaveGame(); GetNode<SaveSystem>(Constants.SaveSystemPath).SaveGame();
} }
public void QuitGame() => GetTree().Quit(); public void QuitGame() => GetTree().Quit();
public void PauseGame() => Engine.TimeScale = 0; public void PauseGame()
public void ResumeGame() => Engine.TimeScale = 1; {
Engine.TimeScale = 0;
EventBus.EmitGamePaused();
}
public void ResumeGame()
{
Engine.TimeScale = 1;
EventBus.EmitGameResumed();
}
public void StartNewGame() public void StartNewGame()
{ {
ResetPlayerState(); Store?.ResetAll();
ResetCurrentSessionState();
_speedRunManager?.StartTimer(); _speedRunManager?.StartTimer();
GetTree().ChangeSceneToPacked(LevelScenes[0]); GetTree().ChangeSceneToPacked(LevelScenes[0]);
GetNode<SaveSystem>(Constants.SaveSystemPath).SaveGame(); GetNode<SaveSystem>(Constants.SaveSystemPath).SaveGame();
EventBus.EmitGameStarted();
} }
public void ContinueGame() public void ContinueGame()
@@ -216,41 +221,38 @@ public partial class GameManager : Node
return; return;
} }
var idx = (int)PlayerState["current_level"]; var idx = Store?.Session.CurrentLevel ?? 0;
if (idx < LevelScenes.Count) if (idx < LevelScenes.Count)
{
GetTree().ChangeSceneToPacked(LevelScenes[idx]); GetTree().ChangeSceneToPacked(LevelScenes[idx]);
EventBus.EmitGameContinued();
}
else else
{
GD.PrintErr("No levels unlocked to continue."); GD.PrintErr("No levels unlocked to continue.");
} }
}
public void OnLevelComplete() public void OnLevelComplete()
{ {
var levelIndex = (int)PlayerState["current_level"]; if (Store == null) return;
MarkLevelComplete(levelIndex);
AddCoins((int)CurrentSessionState["coins_collected"]); var levelIndex = Store.Session.CurrentLevel;
foreach (var s in (Array)CurrentSessionState["skills_unlocked"]) Store.MarkLevelComplete(levelIndex);
UnlockSkill((SkillData)s); Store.CommitSessionCoins();
Store.CommitSessionSkills();
var completionTime = _speedRunManager?.GetCurrentLevelTime() ?? 0.0; var completionTime = _speedRunManager?.GetCurrentLevelTime() ?? 0.0;
EventBus.EmitLevelCompleted(levelIndex, GetTree().CurrentScene, completionTime); EventBus.EmitLevelCompleted(levelIndex, GetTree().CurrentScene, completionTime);
ResetCurrentSessionState(); Store.ResetSession();
TryToGoToNextLevel(); TryToGoToNextLevel();
GetNode<SaveSystem>(Constants.SaveSystemPath).SaveGame(); GetNode<SaveSystem>(Constants.SaveSystemPath).SaveGame();
} }
public Array<SkillData> GetUnlockedSkills() #endregion
{
var unlocked = (Array<SkillData>)PlayerState["unlocked_skills"]; #region Player Lookup
var session = (Array<SkillData>)CurrentSessionState["skills_unlocked"];
if (session!.Count == 0) return unlocked;
if (unlocked!.Count == 0) return session;
var joined = new Array<SkillData>();
joined.AddRange(unlocked);
joined.AddRange(session);
return joined;
}
public PlayerController GetPlayer() public PlayerController GetPlayer()
{ {
@@ -269,4 +271,6 @@ public partial class GameManager : Node
GD.PrintErr("PlayerController not found in the scene tree."); GD.PrintErr("PlayerController not found in the scene tree.");
return null; return null;
} }
#endregion
} }

185
Autoloads/GameStateStore.cs Normal file
View File

@@ -0,0 +1,185 @@
using Godot;
using Mr.BrickAdventures.scripts.Resources;
using Mr.BrickAdventures.scripts.State;
namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Central store for game state - single source of truth.
/// Use the static Instance property for easy access.
/// </summary>
public partial class GameStateStore : Node
{
/// <summary>
/// Singleton instance.
/// </summary>
public static GameStateStore Instance { get; private set; }
/// <summary>
/// Persistent player state (saved to disk).
/// </summary>
public PlayerState Player { get; set; } = new();
/// <summary>
/// Current session state (transient, reset on death/level complete).
/// </summary>
public SessionState Session { get; set; } = new();
public override void _Ready()
{
Instance = this;
}
public override void _ExitTree()
{
if (Instance == this)
Instance = null;
}
#region Coin Operations
/// <summary>
/// Gets total coins (saved + session).
/// </summary>
public int GetTotalCoins() => Player.Coins + Session.CoinsCollected;
/// <summary>
/// Adds coins to the session (not saved until level complete).
/// </summary>
public void AddSessionCoins(int amount)
{
Session.CoinsCollected += amount;
EventBus.EmitCoinsChanged(GetTotalCoins());
}
/// <summary>
/// Commits session coins to player state.
/// </summary>
public void CommitSessionCoins()
{
Player.Coins += Session.CoinsCollected;
Session.CoinsCollected = 0;
}
/// <summary>
/// Removes coins, first from session then from saved.
/// </summary>
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
/// <summary>
/// Decrements lives by 1.
/// </summary>
public void RemoveLife()
{
Player.Lives = Mathf.Max(0, Player.Lives - 1);
EventBus.EmitLivesChanged(Player.Lives);
}
/// <summary>
/// Adds lives.
/// </summary>
public void AddLives(int amount)
{
Player.Lives += amount;
EventBus.EmitLivesChanged(Player.Lives);
}
#endregion
#region Level Operations
/// <summary>
/// Unlocks a level for access.
/// </summary>
public void UnlockLevel(int levelIndex)
{
if (!Player.UnlockedLevels.Contains(levelIndex))
Player.UnlockedLevels.Add(levelIndex);
}
/// <summary>
/// Marks a level as completed and unlocks the next.
/// </summary>
public void MarkLevelComplete(int levelIndex)
{
if (!Player.CompletedLevels.Contains(levelIndex))
Player.CompletedLevels.Add(levelIndex);
UnlockLevel(levelIndex + 1);
}
/// <summary>
/// Checks if a level is unlocked.
/// </summary>
public bool IsLevelUnlocked(int levelIndex) => Player.UnlockedLevels.Contains(levelIndex);
#endregion
#region Skill Operations
/// <summary>
/// Checks if a skill is unlocked (saved or session).
/// </summary>
public bool IsSkillUnlocked(SkillData skill)
{
return Player.UnlockedSkills.Contains(skill) || Session.SkillsUnlocked.Contains(skill);
}
/// <summary>
/// Unlocks a skill in the session.
/// </summary>
public void UnlockSkillInSession(SkillData skill)
{
if (!IsSkillUnlocked(skill))
Session.SkillsUnlocked.Add(skill);
}
/// <summary>
/// Commits session skills to player state.
/// </summary>
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
/// <summary>
/// Resets only the session state.
/// </summary>
public void ResetSession() => Session.Reset();
/// <summary>
/// Resets everything to defaults.
/// </summary>
public void ResetAll()
{
Player.Reset();
Session.ResetAll();
}
#endregion
}

View File

@@ -0,0 +1 @@
uid://bwrhkipwecytk

View File

@@ -1,62 +1,180 @@
using System.Collections.Generic;
using System.Text.Json;
using Godot; using Godot;
using Godot.Collections;
using Mr.BrickAdventures;
using Mr.BrickAdventures.scripts.Resources; using Mr.BrickAdventures.scripts.Resources;
using Mr.BrickAdventures.scripts.State;
namespace Mr.BrickAdventures.Autoloads; namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Save system that serializes state to JSON using DTOs.
/// </summary>
public partial class SaveSystem : Node public partial class SaveSystem : Node
{ {
[Export] public string SavePath { get; set; } = "user://savegame.save"; [Export] public string SavePath { get; set; } = "user://savegame.json";
[Export] public int Version { get; set; } = 1; [Export] public int Version { get; set; } = 2;
private GameManager _gameManager; private static readonly JsonSerializerOptions JsonOptions = new()
public override void _Ready()
{ {
_gameManager = GetNode<GameManager>(Constants.GameManagerPath); WriteIndented = true,
} PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public void SaveGame() public void SaveGame()
{ {
var saveData = new Dictionary var store = GameStateStore.Instance;
if (store == null)
{ {
{ "player_state", _gameManager.PlayerState}, GD.PrintErr("SaveSystem: GameStateStore not available.");
{ "version", Version} return;
}
// Convert to DTO (only serializable data)
var saveData = new SaveDataDto
{
Version = Version,
Coins = store.Player.Coins,
Lives = store.Player.Lives,
CurrentLevel = store.Session.CurrentLevel,
CompletedLevels = [.. store.Player.CompletedLevels],
UnlockedLevels = new List<int>(store.Player.UnlockedLevels),
UnlockedSkillNames = GetSkillNames(store.Player.UnlockedSkills),
UnlockedAchievements = new List<string>(store.Player.UnlockedAchievements),
Statistics = new Dictionary<string, int>(store.Player.Statistics)
}; };
try
{
var json = JsonSerializer.Serialize(saveData, JsonOptions);
using var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Write); using var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Write);
file.StoreVar(saveData); file.StoreString(json);
GD.Print("Game state saved to: ", SavePath); GD.Print("Game saved to: ", SavePath);
EventBus.EmitGameSaved();
}
catch (System.Exception e)
{
GD.PrintErr($"SaveSystem: Failed to save game: {e.Message}");
}
} }
public bool LoadGame() public bool LoadGame()
{ {
if (!FileAccess.FileExists(SavePath)) 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; return false;
} }
GD.Print("Game state loaded from: ", SavePath); try
GD.Print("Player state: ", saveDataObj["player_state"]); {
_gameManager.PlayerState = (Dictionary)saveDataObj["player_state"]; using var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Read);
var json = file.GetAsText();
var saveData = JsonSerializer.Deserialize<SaveDataDto>(json, JsonOptions);
var skills = new Array<SkillData>(); if (saveData == null)
foreach (var skill in (Array<SkillData>)_gameManager.PlayerState["unlocked_skills"]) {
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.Coins = saveData.Coins;
store.Player.Lives = saveData.Lives;
store.Session.CurrentLevel = saveData.CurrentLevel;
store.Player.CompletedLevels = saveData.CompletedLevels ?? new List<int>();
store.Player.UnlockedLevels = saveData.UnlockedLevels ?? new List<int> { 0 };
store.Player.UnlockedAchievements = saveData.UnlockedAchievements ?? new List<string>();
store.Player.Statistics = saveData.Statistics ?? new Dictionary<string, int>();
// Reload skills by name from SkillManager
store.Player.UnlockedSkills = LoadSkillsByName(saveData.UnlockedSkillNames);
GD.Print("Game loaded from: ", SavePath);
return true;
}
catch (System.Exception e)
{
GD.PrintErr($"SaveSystem: Failed to load game: {e.Message}");
return false;
}
}
private static List<string> GetSkillNames(List<SkillData> skills)
{
var names = new List<string>();
foreach (var skill in skills)
{
if (skill != null)
names.Add(skill.Name);
}
return names;
}
private List<SkillData> LoadSkillsByName(List<string> skillNames)
{
var skills = new List<SkillData>();
if (skillNames == null) return skills;
var skillManager = GetNodeOrNull<SkillManager>(Constants.SkillManagerPath);
if (skillManager == null)
{
GD.PrintErr("SaveSystem: SkillManager not available to resolve skill names.");
return skills;
}
foreach (var name in skillNames)
{
var skill = skillManager.GetSkillByName(name);
if (skill != null)
{ {
skills.Add(skill); skills.Add(skill);
} }
else
_gameManager.UnlockSkills(skills); {
return true; GD.PrintErr($"SaveSystem: Skill '{name}' not found in SkillManager.");
}
}
return skills;
} }
public bool CheckSaveExists() => FileAccess.FileExists(SavePath); public bool CheckSaveExists() => FileAccess.FileExists(SavePath);
public void DeleteSave()
{
if (FileAccess.FileExists(SavePath))
{
DirAccess.RemoveAbsolute(ProjectSettings.GlobalizePath(SavePath));
GD.Print("Save file deleted.");
}
}
}
/// <summary>
/// Serializable DTO for save data - no Godot types.
/// </summary>
public class SaveDataDto
{
public int Version { get; set; }
public int Coins { get; set; }
public int Lives { get; set; }
public int CurrentLevel { get; set; }
public List<int> CompletedLevels { get; set; }
public List<int> UnlockedLevels { get; set; }
public List<string> UnlockedSkillNames { get; set; }
public List<string> UnlockedAchievements { get; set; }
public Dictionary<string, int> Statistics { get; set; }
} }

View File

@@ -1,33 +1,21 @@
using System.Collections.Generic;
using Godot; using Godot;
using Godot.Collections; using Mr.BrickAdventures.scripts.State;
using Mr.BrickAdventures;
namespace Mr.BrickAdventures.Autoloads; namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Manages game statistics using GameStateStore.
/// </summary>
public partial class StatisticsManager : Node public partial class StatisticsManager : Node
{ {
private GameManager _gameManager;
private AchievementManager _achievementManager;
private Dictionary<string, Variant> _stats = new();
public override void _Ready() /// <summary>
/// Gets the statistics dictionary from the store.
/// </summary>
private Dictionary<string, int> GetStats()
{ {
_gameManager = GetNode<GameManager>(Constants.GameManagerPath); return GameStateStore.Instance?.Player.Statistics ?? new Dictionary<string, int>();
_achievementManager = GetNode<AchievementManager>(Constants.AchievementManagerPath);
LoadStatistics();
}
private void LoadStatistics()
{
if (_gameManager.PlayerState.TryGetValue("statistics", out var statsObj))
{
_stats = (Dictionary<string, Variant>)statsObj;
}
else
{
_stats = new Dictionary<string, Variant>();
_gameManager.PlayerState["statistics"] = _stats;
}
} }
/// <summary> /// <summary>
@@ -35,45 +23,40 @@ public partial class StatisticsManager : Node
/// </summary> /// </summary>
public void IncrementStat(string statName, int amount = 1) 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 else
{ {
_stats[statName] = amount; stats[statName] = amount;
} }
GD.Print($"Stat '{statName}' updated to: {_stats[statName]}"); }
CheckAchievementsForStat(statName);
/// <summary>
/// Sets a statistic to a specific value.
/// </summary>
public void SetStat(string statName, int value)
{
var stats = GetStats();
stats[statName] = value;
} }
/// <summary> /// <summary>
/// Gets the value of a statistic. /// Gets the value of a statistic.
/// </summary> /// </summary>
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;
} }
/// <summary> /// <summary>
/// Checks if the updated stat meets the criteria for any achievements. /// Gets a copy of all statistics.
/// </summary> /// </summary>
private void CheckAchievementsForStat(string statName) public Dictionary<string, int> GetAllStats()
{ {
switch (statName) return new Dictionary<string, int>(GetStats());
{
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;
}
} }
} }

View File

@@ -29,7 +29,6 @@ config/icon="uid://jix7wdn0isr3"
[autoload] [autoload]
GameManager="*res://objects/game_manager.tscn"
PhantomCameraManager="*res://addons/phantom_camera/scripts/managers/phantom_camera_manager.gd" PhantomCameraManager="*res://addons/phantom_camera/scripts/managers/phantom_camera_manager.gd"
AudioController="*res://objects/audio_controller.tscn" AudioController="*res://objects/audio_controller.tscn"
UIManager="*res://Autoloads/UIManager.cs" UIManager="*res://Autoloads/UIManager.cs"
@@ -46,8 +45,12 @@ EventBus="*res://Autoloads/EventBus.cs"
StatisticsManager="*res://Autoloads/StatisticsManager.cs" StatisticsManager="*res://Autoloads/StatisticsManager.cs"
SpeedRunManager="res://Autoloads/SpeedRunManager.cs" SpeedRunManager="res://Autoloads/SpeedRunManager.cs"
GhostManager="res://objects/ghost_manager.tscn" GhostManager="res://objects/ghost_manager.tscn"
ScoreEventHandler="*res://scripts/Events/ScoreEventHandler.cs"
StatisticsEventHandler="*res://scripts/Events/StatisticsEventHandler.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] [debug]

View File

@@ -8,6 +8,7 @@ public static class Constants
// Autoload paths // Autoload paths
public const string EventBusPath = "/root/EventBus"; public const string EventBusPath = "/root/EventBus";
public const string GameManagerPath = "/root/GameManager"; public const string GameManagerPath = "/root/GameManager";
public const string GameStateStorePath = "/root/GameStateStore";
public const string SaveSystemPath = "/root/SaveSystem"; public const string SaveSystemPath = "/root/SaveSystem";
public const string SpeedRunManagerPath = "/root/SpeedRunManager"; public const string SpeedRunManagerPath = "/root/SpeedRunManager";
public const string GhostManagerPath = "/root/GhostManager"; public const string GhostManagerPath = "/root/GhostManager";

View File

@@ -0,0 +1,29 @@
using Godot;
using Mr.BrickAdventures.Autoloads;
namespace Mr.BrickAdventures.scripts.Events;
/// <summary>
/// Handles coin collection events and updates the GameStateStore.
/// Replaces the manual coin logic in GameManager.
/// </summary>
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);
}
}

View File

@@ -0,0 +1 @@
uid://1qg3q53kkh0k

View File

@@ -0,0 +1,39 @@
using Godot;
using Mr.BrickAdventures.Autoloads;
namespace Mr.BrickAdventures.scripts.Events;
/// <summary>
/// Handles level completion events and updates GameStateStore.
/// </summary>
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();
}
}

View File

@@ -0,0 +1 @@
uid://gx5vn7viphv

View File

@@ -0,0 +1,29 @@
using Godot;
using Mr.BrickAdventures.Autoloads;
namespace Mr.BrickAdventures.scripts.Events;
/// <summary>
/// Handles player death events and updates lives in GameStateStore.
/// </summary>
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();
}
}

View File

@@ -0,0 +1 @@
uid://b4ocg7g8vmtvp

View File

@@ -1,34 +0,0 @@
using Godot;
using Mr.BrickAdventures;
using Mr.BrickAdventures.Autoloads;
namespace Mr.BrickAdventures.scripts.Events;
/// <summary>
/// Handles coin collection events and updates the session state.
/// Replaces the manual signal wiring in ScoreComponent.
/// </summary>
public partial class ScoreEventHandler : Node
{
private GameManager _gameManager;
public override void _Ready()
{
_gameManager = GetNode<GameManager>(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}");
}
}

View File

@@ -1 +0,0 @@
uid://cs4cfk7g5vh2v

View File

@@ -0,0 +1,73 @@
using System.Collections.Generic;
using Mr.BrickAdventures.scripts.Resources;
namespace Mr.BrickAdventures.scripts.State;
/// <summary>
/// Persistent player data that survives across sessions.
/// This is a POCO (Plain Old C# Object) for predictable state management.
/// </summary>
public class PlayerState
{
/// <summary>
/// Saved coins (not including current session).
/// </summary>
public int Coins { get; set; }
/// <summary>
/// Remaining lives.
/// </summary>
public int Lives { get; set; } = 3;
/// <summary>
/// Indices of completed levels.
/// </summary>
public List<int> CompletedLevels { get; set; } = new();
/// <summary>
/// Indices of levels the player can access.
/// </summary>
public List<int> UnlockedLevels { get; set; } = new() { 0 };
/// <summary>
/// Skills the player has permanently unlocked.
/// </summary>
public List<SkillData> UnlockedSkills { get; set; } = new();
/// <summary>
/// Statistics dictionary for tracking game stats.
/// </summary>
public Dictionary<string, int> Statistics { get; set; } = new();
/// <summary>
/// IDs of unlocked achievements.
/// </summary>
public List<string> UnlockedAchievements { get; set; } = new();
/// <summary>
/// Creates a fresh default player state.
/// </summary>
public static PlayerState CreateDefault() => new()
{
Coins = 0,
Lives = 3,
CompletedLevels = new List<int>(),
UnlockedLevels = new List<int> { 0 },
UnlockedSkills = new List<SkillData>(),
Statistics = new Dictionary<string, int>()
};
/// <summary>
/// Resets this state to default values.
/// </summary>
public void Reset()
{
Coins = 0;
Lives = 3;
CompletedLevels.Clear();
UnlockedLevels.Clear();
UnlockedLevels.Add(0);
UnlockedSkills.Clear();
Statistics.Clear();
}
}

View File

@@ -0,0 +1 @@
uid://gtr1e60jq7iv

View File

@@ -0,0 +1,55 @@
using System.Collections.Generic;
using Mr.BrickAdventures.scripts.Resources;
namespace Mr.BrickAdventures.scripts.State;
/// <summary>
/// Data for the current gameplay session.
/// Reset when player dies or completes a level.
/// </summary>
public class SessionState
{
/// <summary>
/// Current level index being played.
/// </summary>
public int CurrentLevel { get; set; }
/// <summary>
/// Coins collected during this session (not yet saved).
/// </summary>
public int CoinsCollected { get; set; }
/// <summary>
/// Skills unlocked during this session (not yet saved).
/// </summary>
public List<SkillData> SkillsUnlocked { get; set; } = new();
/// <summary>
/// Creates a fresh session state.
/// </summary>
public static SessionState CreateDefault() => new()
{
CurrentLevel = 0,
CoinsCollected = 0,
SkillsUnlocked = new List<SkillData>()
};
/// <summary>
/// Resets session state to defaults.
/// </summary>
public void Reset()
{
CoinsCollected = 0;
SkillsUnlocked.Clear();
}
/// <summary>
/// Resets completely including level.
/// </summary>
public void ResetAll()
{
CurrentLevel = 0;
CoinsCollected = 0;
SkillsUnlocked.Clear();
}
}

View File

@@ -0,0 +1 @@
uid://chqsdleqrnl7b

View File

@@ -2,6 +2,7 @@ using Godot;
using Mr.BrickAdventures; using Mr.BrickAdventures;
using Mr.BrickAdventures.Autoloads; using Mr.BrickAdventures.Autoloads;
using Mr.BrickAdventures.scripts.Resources; using Mr.BrickAdventures.scripts.Resources;
using Mr.BrickAdventures.scripts.State;
namespace Mr.BrickAdventures.scripts.UI; namespace Mr.BrickAdventures.scripts.UI;
@@ -20,18 +21,35 @@ public partial class DeathScreen : Control
public override void _Ready() public override void _Ready()
{ {
_gameManager = GetNode<GameManager>(Constants.GameManagerPath); _gameManager = GetNode<GameManager>(Constants.GameManagerPath);
SetLabels();
// Subscribe to lives changed event for reactive updates
EventBus.Instance.LivesChanged += OnLivesChanged;
}
public override void _ExitTree()
{
if (EventBus.Instance != null)
{
EventBus.Instance.LivesChanged -= OnLivesChanged;
}
}
private void OnLivesChanged(int lives)
{
// Update the label when lives change
LivesLeftLabel.Text = $" x {lives}";
} }
private void SetLabels() private void SetLabels()
{ {
if (_gameManager == null) return;
if (CurrentLevel != null) if (CurrentLevel != null)
{ {
CurrentLevelLabel.Text = CurrentLevel.LevelName; CurrentLevelLabel.Text = CurrentLevel.LevelName;
} }
LivesLeftLabel.Text = $" x {_gameManager.GetLives()}";
// Read current lives from store
var lives = GameStateStore.Instance?.Player.Lives ?? 0;
LivesLeftLabel.Text = $" x {lives}";
} }
private void SetupTimer() private void SetupTimer()
@@ -66,7 +84,8 @@ public partial class DeathScreen : Control
private void OnTimeout() private void OnTimeout()
{ {
if (_gameManager == null || _gameManager.GetLives() == 0) return; var lives = GameStateStore.Instance?.Player.Lives ?? 0;
if (lives == 0) return;
GetTree().ReloadCurrentScene(); GetTree().ReloadCurrentScene();
} }

View File

@@ -2,6 +2,7 @@ using Godot;
using Mr.BrickAdventures; using Mr.BrickAdventures;
using Mr.BrickAdventures.Autoloads; using Mr.BrickAdventures.Autoloads;
using Mr.BrickAdventures.scripts.interfaces; using Mr.BrickAdventures.scripts.interfaces;
using Mr.BrickAdventures.scripts.State;
namespace Mr.BrickAdventures.scripts.components; namespace Mr.BrickAdventures.scripts.components;
@@ -34,7 +35,9 @@ public partial class ExitDoorComponent : Area2D, IUnlockable
EmitSignalExitTriggered(); EmitSignalExitTriggered();
_achievementManager.UnlockAchievement(AchievementId); _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)); CallDeferred(nameof(GoToNextLevel));
} }

View File

@@ -32,7 +32,7 @@ public partial class PlayerDeathComponent : Node2D
effect.Scale = EffectScale; effect.Scale = EffectScale;
} }
_gameManager.RemoveLives(1); // Lives are now decremented by LivesStateHandler via PlayerDied event
_gameManager.ResetCurrentSessionState(); // Session state is reset by LivesStateHandler as well
} }
} }

View File

@@ -4,6 +4,7 @@ using Mr.BrickAdventures;
using Mr.BrickAdventures.Autoloads; using Mr.BrickAdventures.Autoloads;
using Mr.BrickAdventures.scripts.interfaces; using Mr.BrickAdventures.scripts.interfaces;
using Mr.BrickAdventures.scripts.Resources; using Mr.BrickAdventures.scripts.Resources;
using Mr.BrickAdventures.scripts.State;
namespace Mr.BrickAdventures.scripts.components; namespace Mr.BrickAdventures.scripts.components;
@@ -38,8 +39,8 @@ public partial class SkillUnlockerComponent : Node
skill.IsActive = true; skill.IsActive = true;
_gameManager.RemoveCoins(skill.Upgrades[0].Cost); _gameManager.RemoveCoins(skill.Upgrades[0].Cost);
var skillsUnlocked = (Array<SkillData>)_gameManager.CurrentSessionState["skills_unlocked"]; // Add to session state via GameStateStore
skillsUnlocked.Add(skill); GameStateStore.Instance?.UnlockSkillInSession(skill);
SkillManager.AddSkill(skill); SkillManager.AddSkill(skill);
EmitSignalSkillUnlocked(skill); EmitSignalSkillUnlocked(skill);