refactor (#6)

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-02-01 11:47:40 +00:00
parent dde3eaa52e
commit bfe951939d
71 changed files with 1583 additions and 661 deletions

View File

@@ -1,23 +1,22 @@
using Godot;
using Godot.Collections;
using Mr.BrickAdventures.scripts.Resources;
using Mr.BrickAdventures.scripts.State;
namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Manages achievements using GameStateStore.
/// </summary>
public partial class AchievementManager : Node
{
[Export] private string AchievementsFolderPath = "res://achievements/";
[Export] private PackedScene AchievementPopupScene { get; set; }
private System.Collections.Generic.Dictionary<string, AchievementResource> _achievements = new();
private Array<string> _unlockedAchievementIds = [];
private GameManager _gameManager;
public override void _Ready()
{
_gameManager = GetNode<GameManager>("/root/GameManager");
LoadAchievementsFromFolder();
LoadUnlockedAchievements();
}
private void LoadAchievementsFromFolder()
@@ -44,7 +43,15 @@ public partial class AchievementManager : Node
fileName = dir.GetNext();
}
}
/// <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)
{
if (!_achievements.TryGetValue(achievementId, out var achievement))
@@ -53,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
@@ -75,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))
{
_unlockedAchievementIds.Remove(achievementId);
SaveUnlockedAchievements();
}
}
private void SaveUnlockedAchievements()
{
_gameManager.PlayerState["unlocked_achievements"] = _unlockedAchievementIds;
// You might want to trigger a save game here, depending on your SaveSystem
}
private void LoadUnlockedAchievements()
public void LockAchievement(string achievementId)
{
if (_gameManager.PlayerState.TryGetValue("unlocked_achievements", out var unlocked))
var unlockedIds = GetUnlockedIds();
if (unlockedIds.Contains(achievementId))
{
_unlockedAchievementIds = (Array<string>)unlocked;
unlockedIds.Remove(achievementId);
}
}
public bool IsAchievementUnlocked(string achievementId)
{
return GetUnlockedIds().Contains(achievementId);
}
}

View File

@@ -1,4 +1,5 @@
using Godot;
using Mr.BrickAdventures;
using Mr.BrickAdventures.scripts.components;
namespace Mr.BrickAdventures.Autoloads;
@@ -12,9 +13,9 @@ public partial class ConsoleManager : Node
public override void _Ready()
{
_gameManager = GetNode<GameManager>("/root/GameManager");
_achievementManager = GetNode<AchievementManager>("/root/AchievementManager");
_skillManager = GetNode<SkillManager>("/root/SkillManager");
_gameManager = GameManager.Instance;
_achievementManager = GetNode<AchievementManager>(Constants.AchievementManagerPath);
_skillManager = SkillManager.Instance;
}
private void AddCoinsCommand(int amount)
@@ -88,7 +89,7 @@ public partial class ConsoleManager : Node
_skillUnlockerComponent.UnlockAllSkills();
}
private void RemoveSkillCommand(string skillName)
{
if (!GetSkillManagement()) return;
@@ -102,28 +103,28 @@ public partial class ConsoleManager : Node
_gameManager.RemoveSkill(skill.Name);
_skillManager.DeactivateSkill(skill);
}
private void RemoveAllSkillsCommand()
{
if (!GetSkillManagement()) return;
foreach (var skill in _skillManager.AvailableSkills)
{
_gameManager.RemoveSkill(skill.Name);
_skillManager.DeactivateSkill(skill);
}
}
private void GoToNextLevelCommand()
{
_gameManager.OnLevelComplete();
}
private void UnlockAchievementCommand(string achievementId)
{
_achievementManager.UnlockAchievement(achievementId);
}
private void ResetAchievementCommand(string achievementId)
{
_achievementManager.LockAchievement(achievementId);

View File

@@ -1,9 +1,155 @@
using Godot;
using Mr.BrickAdventures.scripts.components;
using Mr.BrickAdventures.scripts.Resources;
namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Global event bus for decoupled communication between game systems.
/// Use the static Instance property for easy access from anywhere.
/// </summary>
public partial class EventBus : Node
{
/// <summary>
/// Singleton instance. Available after the autoload is initialized.
/// </summary>
public static EventBus Instance { get; private set; }
public override void _Ready()
{
Instance = this;
}
public override void _ExitTree()
{
if (Instance == this)
Instance = null;
}
#region Level Events
[Signal] public delegate void LevelStartedEventHandler(int levelIndex, Node currentScene);
[Signal] public delegate void LevelCompletedEventHandler(int levelIndex, Node currentScene, double completionTime);
[Signal] public delegate void LevelRestartedEventHandler(int levelIndex);
public static void EmitLevelStarted(int levelIndex, Node currentScene)
=> Instance?.EmitSignal(SignalName.LevelStarted, levelIndex, currentScene);
public static void EmitLevelCompleted(int levelIndex, Node currentScene, double completionTime)
=> Instance?.EmitSignal(SignalName.LevelCompleted, levelIndex, currentScene, completionTime);
public static void EmitLevelRestarted(int levelIndex)
=> Instance?.EmitSignal(SignalName.LevelRestarted, levelIndex);
#endregion
#region Player Events
[Signal] public delegate void PlayerSpawnedEventHandler(PlayerController player);
[Signal] public delegate void PlayerDiedEventHandler(Vector2 position);
[Signal] public delegate void PlayerDamagedEventHandler(float damage, float remainingHealth, Vector2 position);
[Signal] public delegate void PlayerHealedEventHandler(float amount, float newHealth, Vector2 position);
public static void EmitPlayerSpawned(PlayerController player)
=> Instance?.EmitSignal(SignalName.PlayerSpawned, player);
public static void EmitPlayerDied(Vector2 position)
=> Instance?.EmitSignal(SignalName.PlayerDied, position);
public static void EmitPlayerDamaged(float damage, float remainingHealth, Vector2 position)
=> Instance?.EmitSignal(SignalName.PlayerDamaged, damage, remainingHealth, position);
public static void EmitPlayerHealed(float amount, float newHealth, Vector2 position)
=> Instance?.EmitSignal(SignalName.PlayerHealed, amount, newHealth, position);
#endregion
#region Combat Events
[Signal] public delegate void EnemyDefeatedEventHandler(Node enemy, Vector2 position);
[Signal] public delegate void EnemyDamagedEventHandler(Node enemy, float damage, Vector2 position);
public static void EmitEnemyDefeated(Node enemy, Vector2 position)
=> Instance?.EmitSignal(SignalName.EnemyDefeated, enemy, position);
public static void EmitEnemyDamaged(Node enemy, float damage, Vector2 position)
=> Instance?.EmitSignal(SignalName.EnemyDamaged, enemy, damage, position);
#endregion
#region Collection Events
[Signal] public delegate void CoinCollectedEventHandler(int amount, Vector2 position);
[Signal] public delegate void ItemCollectedEventHandler(CollectableType itemType, float amount, Vector2 position);
[Signal] public delegate void ChildRescuedEventHandler(Vector2 position);
[Signal] public delegate void SkillCollectedEventHandler(SkillData skill, Vector2 position);
public static void EmitCoinCollected(int amount, Vector2 position)
=> Instance?.EmitSignal(SignalName.CoinCollected, amount, position);
public static void EmitItemCollected(CollectableType itemType, float amount, Vector2 position)
=> Instance?.EmitSignal(SignalName.ItemCollected, (int)itemType, amount, position);
public static void EmitChildRescued(Vector2 position)
=> Instance?.EmitSignal(SignalName.ChildRescued, position);
public static void EmitSkillCollected(SkillData skill, Vector2 position)
=> Instance?.EmitSignal(SignalName.SkillCollected, skill, position);
#endregion
#region Skill Events
[Signal] public delegate void SkillUnlockedEventHandler(string skillName, int level);
[Signal] public delegate void SkillActivatedEventHandler(string skillName);
[Signal] public delegate void SkillDeactivatedEventHandler(string skillName);
public static void EmitSkillUnlocked(string skillName, int level = 1)
=> Instance?.EmitSignal(SignalName.SkillUnlocked, skillName, level);
public static void EmitSkillActivated(string skillName)
=> Instance?.EmitSignal(SignalName.SkillActivated, skillName);
public static void EmitSkillDeactivated(string skillName)
=> Instance?.EmitSignal(SignalName.SkillDeactivated, skillName);
#endregion
#region Game State Events
[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);
public static void EmitGameResumed()
=> Instance?.EmitSignal(SignalName.GameResumed);
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
}

View File

@@ -7,22 +7,34 @@ namespace Mr.BrickAdventures.Autoloads;
public partial class FloatingTextManager : Node
{
[Export] public PackedScene FloatingTextScene { get; set; }
[ExportGroup("Colors")]
[Export] public Color DamageColor { get; set; } = new Color("#b21030"); // Red
[Export] public Color HealColor { get; set; } = new Color("#71f341"); // Green
[Export] public Color CoinColor { get; set; } = new Color("#ebd320"); // Gold
[Export] public Color MessageColor { get; set; } = new Color("#ffffff"); // White
public static FloatingTextManager Instance { get; private set; }
public override void _Ready()
{
Instance = this;
}
public override void _ExitTree()
{
if (Instance == this) Instance = null;
}
public void ShowDamage(float amount, Vector2 position)
{
var text = Mathf.Round(amount * 100f).ToString(CultureInfo.InvariantCulture);
CreateFloatingText(text, position, DamageColor);
}
public void ShowHeal(float amount, Vector2 position)
{
var text = $"+{Mathf.Round(amount)}";
var text = $"+{Mathf.Round(amount * 100f).ToString(CultureInfo.InvariantCulture)}";
CreateFloatingText(text, position, HealColor);
}
@@ -31,12 +43,12 @@ public partial class FloatingTextManager : Node
var text = $"+{amount}";
CreateFloatingText(text, position, CoinColor);
}
public void ShowMessage(string message, Vector2 position)
{
CreateFloatingText(message, position, MessageColor);
}
private void CreateFloatingText(string text, Vector2 position, Color color)
{
if (FloatingTextScene == null)

View File

@@ -3,41 +3,34 @@ using Godot;
using Godot.Collections;
using Mr.BrickAdventures.scripts.components;
using Mr.BrickAdventures.scripts.Resources;
using Double = System.Double;
using Mr.BrickAdventures.scripts.State;
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
{
[Export] public Array<PackedScene> LevelScenes { get; set; } = [];
public PlayerController Player {
public PlayerController Player
{
get => GetPlayer();
private set => _player = value;
}
private List<Node> _sceneNodes = [];
private PlayerController _player;
private SpeedRunManager _speedRunManager;
private EventBus _eventBus;
[Export]
public Dictionary PlayerState { get; set; } = new()
{
{ "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>() }
};
/// <summary>
/// Lazy accessor for GameStateStore - avoids initialization order issues.
/// </summary>
private GameStateStore Store => GameStateStore.Instance;
public static GameManager Instance { get; private set; }
public override void _EnterTree()
{
@@ -47,6 +40,7 @@ public partial class GameManager : Node
public override void _ExitTree()
{
if (Instance == this) Instance = null;
GetTree().NodeAdded -= OnNodeAdded;
GetTree().NodeRemoved -= OnNodeRemoved;
_sceneNodes.Clear();
@@ -54,15 +48,15 @@ public partial class GameManager : Node
public override void _Ready()
{
_speedRunManager = GetNode<SpeedRunManager>("/root/SpeedRunManager");
_eventBus = GetNode<EventBus>("/root/EventBus");
Instance = this;
_speedRunManager = GetNode<SpeedRunManager>(Constants.SpeedRunManagerPath);
}
private void OnNodeAdded(Node node)
{
_sceneNodes.Add(node);
}
private void OnNodeRemoved(Node node)
{
_sceneNodes.Remove(node);
@@ -72,57 +66,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;
}
}
}
@@ -132,81 +140,82 @@ public partial class GameManager : Node
UnlockSkill(s);
}
public void ResetPlayerState()
public Array<SkillData> GetUnlockedSkills()
{
PlayerState = new Dictionary
{
{ "coins", 0 },
{ "lives", 3 },
{ "current_level", 0 },
{ "completed_levels", new Array<int>() },
{ "unlocked_levels", new Array<int>() {0}},
{ "unlocked_skills", new Array<SkillData>() },
{ "statistics", new Godot.Collections.Dictionary<string, Variant>()}
};
}
public void UnlockLevel(int levelIndex)
{
var unlocked = (Array)PlayerState["unlocked_levels"];
if (!unlocked.Contains(levelIndex)) unlocked.Add(levelIndex);
if (Store == null) return new Array<SkillData>();
var skills = Store.GetAllUnlockedSkills();
var result = new Array<SkillData>();
foreach (var s in skills) result.Add(s);
return result;
}
#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.EmitSignal(EventBus.SignalName.LevelStarted, next, GetTree().CurrentScene);
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);
}
public void ResetCurrentSessionState()
{
CurrentSessionState = new Dictionary
{
{ "coins_collected", 0 },
{ "skills_unlocked", new Array<SkillData>() }
};
}
#endregion
#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<SaveSystem>("/root/SaveSystem").SaveGame();
GetNode<SaveSystem>(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<SaveSystem>("/root/SaveSystem").SaveGame();
GetNode<SaveSystem>(Constants.SaveSystemPath).SaveGame();
EventBus.EmitGameStarted();
}
public void ContinueGame()
{
var save = GetNode<SaveSystem>("/root/SaveSystem");
var save = GetNode<SaveSystem>(Constants.SaveSystemPath);
if (!save.LoadGame())
{
GD.PrintErr("Failed to load game. Starting a new game instead.");
@@ -214,57 +223,56 @@ 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);
AddCoins((int)CurrentSessionState["coins_collected"]);
foreach (var s in (Array)CurrentSessionState["skills_unlocked"])
UnlockSkill((SkillData)s);
if (Store == null) return;
var levelIndex = Store.Session.CurrentLevel;
Store.MarkLevelComplete(levelIndex);
Store.CommitSessionCoins();
Store.CommitSessionSkills();
var completionTime = _speedRunManager?.GetCurrentLevelTime() ?? 0.0;
_eventBus.EmitSignal(EventBus.SignalName.LevelCompleted, levelIndex, GetTree().CurrentScene, completionTime);
ResetCurrentSessionState();
EventBus.EmitLevelCompleted(levelIndex, GetTree().CurrentScene, completionTime);
Store.ResetSession();
TryToGoToNextLevel();
GetNode<SaveSystem>("/root/SaveSystem").SaveGame();
GetNode<SaveSystem>(Constants.SaveSystemPath).SaveGame();
}
public Array<SkillData> GetUnlockedSkills()
{
var unlocked = (Array<SkillData>)PlayerState["unlocked_skills"];
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;
}
#endregion
#region Player Lookup
public PlayerController GetPlayer()
{
if (_player != null && IsInstanceValid(_player)) return _player;
_player = null;
foreach (var node in _sceneNodes)
{
if (node is not PlayerController player) continue;
_player = player;
return _player;
}
GD.PrintErr("PlayerController not found in the scene tree.");
return null;
}
#endregion
}

199
Autoloads/GameStateStore.cs Normal file
View File

@@ -0,0 +1,199 @@
using Godot;
using System.Collections.Generic;
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();
}
/// <summary>
/// Gets all unlocked skills from player persistence and current session.
/// </summary>
public List<SkillData> GetAllUnlockedSkills()
{
var result = new List<SkillData>(Player.UnlockedSkills);
foreach (var skill in Session.SkillsUnlocked)
{
if (!result.Contains(skill)) result.Add(skill);
}
return result;
}
#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,61 +1,192 @@
using System.Collections.Generic;
using System.Text.Json;
using Godot;
using Godot.Collections;
using Mr.BrickAdventures.scripts.Resources;
using Mr.BrickAdventures.scripts.State;
namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Save system that serializes state to JSON using DTOs.
/// </summary>
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;
private GameManager _gameManager;
public static SaveSystem Instance { get; private set; }
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public override void _Ready()
{
_gameManager = GetNode<GameManager>("/root/GameManager");
Instance = this;
}
public override void _ExitTree()
{
if (Instance == this) Instance = null;
}
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;
}
// 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)
};
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<SkillData>();
foreach (var skill in (Array<SkillData>)_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<SaveDataDto>(json, JsonOptions);
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.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;
}
_gameManager.UnlockSkills(skills);
return true;
}
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);
}
else
{
GD.PrintErr($"SaveSystem: Skill '{name}' not found in SkillManager.");
}
}
return skills;
}
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

@@ -2,6 +2,7 @@ using System.Collections.Generic;
using System.Linq;
using Godot;
using Godot.Collections;
using Mr.BrickAdventures;
using Mr.BrickAdventures.scripts.components;
using Mr.BrickAdventures.scripts.interfaces;
using Mr.BrickAdventures.scripts.Resources;
@@ -14,19 +15,27 @@ public partial class SkillManager : Node
private PlayerController _player;
[Export] public Array<SkillData> AvailableSkills { get; set; } = [];
public Dictionary ActiveComponents { get; private set; } = new();
public static SkillManager Instance { get; private set; }
[Signal]
public delegate void ActiveThrowSkillChangedEventHandler(BrickThrowComponent throwComponent);
[Signal]
public delegate void SkillRemovedEventHandler(SkillData skillData);
public override void _Ready()
{
_gameManager = GetNode<GameManager>("/root/GameManager");
Instance = this;
_gameManager = GetNode<GameManager>(Constants.GameManagerPath);
}
public override void _ExitTree()
{
if (Instance == this) Instance = null;
}
/// <summary>
/// Called by the PlayerController from its _Ready method to register itself with the manager.
/// </summary>
@@ -39,7 +48,7 @@ public partial class SkillManager : Node
{
UnregisterPlayer();
}
_player = player;
if (_player != null)
{
@@ -61,7 +70,7 @@ public partial class SkillManager : Node
}
_player = null;
}
public void AddSkill(SkillData skillData)
{
// Ensure a valid player is registered before adding a skill.
@@ -70,7 +79,7 @@ public partial class SkillManager : Node
GD.Print("SkillManager: Player not available to add skill.");
return;
}
if (ActiveComponents.ContainsKey(skillData.Name))
return;
@@ -98,9 +107,9 @@ public partial class SkillManager : Node
if (instance is ISkill skill)
{
// Initialize the skill with the registered player instance.
skill.Initialize(_player, skillData);
skill.Initialize(_player, skillData);
skill.Activate();
}
}
else
{
GD.PrintErr($"Skill scene for '{skillData.Name}' does not implement ISkill!");
@@ -111,18 +120,18 @@ public partial class SkillManager : Node
// Add the skill node as a child of the player.
_player.AddChild(instance);
ActiveComponents[skillData.Name] = instance;
if (instance is BrickThrowComponent btc)
{
EmitSignalActiveThrowSkillChanged(btc);
EmitSignalActiveThrowSkillChanged(btc);
}
}
public void RemoveSkill(string skillName)
{
if (!ActiveComponents.TryGetValue(skillName, out var component))
return;
if (component.AsGodotObject() is BrickThrowComponent)
{
EmitSignalActiveThrowSkillChanged(null);
@@ -133,7 +142,7 @@ public partial class SkillManager : Node
{
skill.Deactivate();
}
if (IsInstanceValid(inst))
inst.QueueFree();
@@ -142,7 +151,6 @@ public partial class SkillManager : Node
{
if (s.Name == skillName)
{
s.IsActive = false;
break;
}
}
@@ -150,7 +158,7 @@ public partial class SkillManager : Node
var sd = GetSkillByName(skillName);
if (sd != null) EmitSignalSkillRemoved(sd);
}
private void RemoveAllActiveSkills()
{
// Create a copy of keys to avoid modification during iteration
@@ -187,12 +195,16 @@ public partial class SkillManager : Node
return null;
}
public bool IsSkillActive(SkillData skill)
{
return skill != null && ActiveComponents.ContainsKey(skill.Name);
}
public void ActivateSkill(SkillData skill)
{
if (!ActiveComponents.ContainsKey(skill.Name))
{
AddSkill(skill);
skill.IsActive = true;
}
}
@@ -201,7 +213,6 @@ public partial class SkillManager : Node
if (ActiveComponents.ContainsKey(skill.Name))
{
RemoveSkill(skill.Name);
skill.IsActive = false;
}
}

View File

@@ -1,78 +1,62 @@
using System.Collections.Generic;
using Godot;
using Godot.Collections;
using Mr.BrickAdventures.scripts.State;
namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Manages game statistics using GameStateStore.
/// </summary>
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>("/root/GameManager");
_achievementManager = GetNode<AchievementManager>("/root/AchievementManager");
LoadStatistics();
return GameStateStore.Instance?.Player.Statistics ?? new Dictionary<string, int>();
}
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>
/// Increases a numerical statistic by a given amount.
/// </summary>
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);
}
/// <summary>
/// Sets a statistic to a specific value.
/// </summary>
public void SetStat(string statName, int value)
{
var stats = GetStats();
stats[statName] = value;
}
/// <summary>
/// Gets the value of a statistic.
/// </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>
/// Checks if the updated stat meets the criteria for any achievements.
/// Gets a copy of all statistics.
/// </summary>
private void CheckAchievementsForStat(string statName)
public Dictionary<string, int> 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<string, int>(GetStats());
}
}