diff --git a/Autoloads/GameManager.cs b/Autoloads/GameManager.cs index 06ff12c..1650565 100644 --- a/Autoloads/GameManager.cs +++ b/Autoloads/GameManager.cs @@ -116,15 +116,13 @@ public partial class GameManager : Node var seconds = SpeedRunManager?.GetCurrentLevelTime() ?? 0.0; LevelTime? time = seconds > 0 ? LevelTime.Of(seconds) : null; - // Old path: coins and skills earned this attempt. - Store?.CommitSessionCoins(); - Store?.CommitSessionSkills(); - EventBus.EmitLevelCompleted(IndexOf(finished), GetTree().CurrentScene, seconds); - // New path: banks, marks cleared, and advances campaign position if this was a first clear. + // Banks the attempt's coins and skills, marks the level cleared, and advances campaign + // position if this was a First Clear. Progression.FinishLevel(time); - Store?.ResetSession(); + + // Still needed for lives, which move with the death path in the next commit. SaveSystem.Instance.SaveGame(); if (Progression.CurrentLevel is { } next && !next.Equals(finished)) diff --git a/Autoloads/SkillManager.cs b/Autoloads/SkillManager.cs index 82bf84b..efa1d59 100644 --- a/Autoloads/SkillManager.cs +++ b/Autoloads/SkillManager.cs @@ -1,22 +1,40 @@ -using System.Collections.Generic; using System.Linq; using Godot; using Godot.Collections; +using Mr.BrickAdventures.Progression; using Mr.BrickAdventures.scripts.components; using Mr.BrickAdventures.scripts.interfaces; using Mr.BrickAdventures.scripts.Resources; namespace Mr.BrickAdventures.Autoloads; +/// +/// Owns the live skill components attached to the player, and keeps them matching what the Playthrough +/// says is owned. +/// +/// +/// +/// Reconciles from rather than being told by each caller. The old +/// arrangement had four call sites doing "record it in the store, then tell SkillManager" as two +/// separate calls — anything between them, including AddSkill's own early return when no player +/// is registered, left a skill owned but inert. +/// +/// +/// Tracks the level each component was built at, so an upgrade rebuilds it. Previously +/// AddSkill returned early for an already-active skill, and the upgrade was applied by the +/// purchase code reaching into the component instead. +/// +/// public partial class SkillManager : Node { private PlayerController _player; - private GameStateStore Store => GameStateStore.Instance; [Export] public Array AvailableSkills { get; set; } = []; public Dictionary ActiveComponents { get; private set; } = new(); + private readonly System.Collections.Generic.Dictionary _activeLevels = new(); + public static SkillManager Instance { get; private set; } [Signal] @@ -27,10 +45,14 @@ public partial class SkillManager : Node public override void _Ready() { Instance = this; + EventBus.Instance.SkillSetChanged += Reconcile; } public override void _ExitTree() { + if (EventBus.Instance != null) + EventBus.Instance.SkillSetChanged -= Reconcile; + if (Instance == this) Instance = null; } @@ -41,24 +63,17 @@ public partial class SkillManager : Node { if (_player == player) return; - // If a player is already registered (e.g., from a previous scene), unregister it first. if (_player != null && IsInstanceValid(_player)) - { UnregisterPlayer(); - } _player = player; if (_player != null) { - // Automatically unregister when the player node is removed from the scene. _player.TreeExiting += UnregisterPlayer; - ApplyUnlockedSkills(); + Reconcile(); } } - /// - /// Cleans up skills and references related to the current player. - /// private void UnregisterPlayer() { if (_player != null && IsInstanceValid(_player)) @@ -69,9 +84,37 @@ public partial class SkillManager : Node _player = null; } - public void AddSkill(SkillData skillData) + /// + /// Brings the live components in line with what is owned: adds what is missing, rebuilds anything + /// whose level changed, removes what is no longer owned. + /// + public void Reconcile() + { + if (_player == null || !IsInstanceValid(_player)) return; + + var progression = ProgressionRoot.Instance; + if (progression == null) return; + + foreach (var skill in AvailableSkills) + { + if (skill == null || string.IsNullOrWhiteSpace(skill.Name)) continue; + + var owned = progression.LevelOf(SkillId.Of(skill.Name))?.Value; + var active = _activeLevels.TryGetValue(skill.Name, out var at) ? at : (int?)null; + + if (owned == active) continue; + + if (active is not null) RemoveSkill(skill.Name); + if (owned is not null) AddSkill(skill, owned.Value); + } + } + + /// Kept for the marketplace's activate/deactivate toggle, which is a display concern. + public void AddSkill(SkillData skillData) => + AddSkill(skillData, ProgressionRoot.Instance?.LevelOf(SkillId.Of(skillData.Name))?.Value ?? 1); + + private void AddSkill(SkillData skillData, int level) { - // Ensure a valid player is registered before adding a skill. if (_player == null || !IsInstanceValid(_player)) { GD.Print("SkillManager: Player not available to add skill."); @@ -83,10 +126,9 @@ public partial class SkillManager : Node if (skillData.Type == SkillType.Throw) { - // Remove any other active throw skill before adding the new one foreach (var sd in AvailableSkills) { - if (sd != skillData && sd.Type == SkillType.Throw) + if (sd != skillData && sd?.Type == SkillType.Throw) RemoveSkill(sd.Name); } } @@ -94,8 +136,7 @@ public partial class SkillManager : Node var instance = skillData.Node.Instantiate(); if (instance is ISkill skill) { - // Initialize the skill with the registered player instance. - skill.Initialize(_player, skillData); + skill.Initialize(_player, skillData, level); skill.Activate(); } else @@ -105,31 +146,27 @@ public partial class SkillManager : Node return; } - // Add the skill node as a child of the player. _player.AddChild(instance); ActiveComponents[skillData.Name] = instance; + _activeLevels[skillData.Name] = level; if (instance is BrickThrowComponent btc) - { EmitSignalActiveThrowSkillChanged(btc); - } } public void RemoveSkill(string skillName) { + _activeLevels.Remove(skillName); + if (!ActiveComponents.TryGetValue(skillName, out var component)) return; if (component.AsGodotObject() is BrickThrowComponent) - { EmitSignalActiveThrowSkillChanged(null); - } var inst = (Node)component; if (inst is ISkill skill) - { skill.Deactivate(); - } if (IsInstanceValid(inst)) inst.QueueFree(); @@ -141,56 +178,30 @@ public partial class SkillManager : Node private void RemoveAllActiveSkills() { - // Create a copy of keys to avoid modification during iteration - var keys = ActiveComponents.Keys.ToArray(); - var skillNames = keys.Select(key => key.ToString()).ToList(); - - foreach (var skillName in skillNames) - { + foreach (var skillName in ActiveComponents.Keys.Select(k => k.ToString()).ToList()) RemoveSkill(skillName); - } - } - - public void ApplyUnlockedSkills() - { - if (_player == null || !IsInstanceValid(_player)) return; - if (Store == null) return; - - foreach (var sd in AvailableSkills) - { - if (Store.IsSkillUnlocked(sd)) - CallDeferred(MethodName.AddSkill, sd); - else - RemoveSkill(sd.Name); - } } public SkillData GetSkillByName(string skillName) { foreach (var sd in AvailableSkills) - if (sd.Name == skillName) return sd; + if (sd?.Name == skillName) return sd; return null; } - public bool IsSkillActive(SkillData skill) - { - return skill != null && ActiveComponents.ContainsKey(skill.Name); - } + public bool IsSkillActive(SkillData skill) => + skill != null && ActiveComponents.ContainsKey(skill.Name); public void ActivateSkill(SkillData skill) { if (!ActiveComponents.ContainsKey(skill.Name)) - { AddSkill(skill); - } } public void DeactivateSkill(SkillData skill) { if (ActiveComponents.ContainsKey(skill.Name)) - { RemoveSkill(skill.Name); - } } public void ToggleSkillActivation(SkillData skill) @@ -202,4 +213,4 @@ public partial class SkillManager : Node else ActivateSkill(skill); } -} \ No newline at end of file +} diff --git a/scripts/Events/CoinStateHandler.cs b/scripts/Events/CoinStateHandler.cs index dc6b9b1..6b89769 100644 --- a/scripts/Events/CoinStateHandler.cs +++ b/scripts/Events/CoinStateHandler.cs @@ -1,12 +1,16 @@ using Godot; using Mr.BrickAdventures.Autoloads; +using Mr.BrickAdventures.Progression; namespace Mr.BrickAdventures.scripts.Events; /// -/// Handles coin collection events and updates the GameStateStore. -/// Replaces the manual coin logic in GameManager. +/// Records collected coins against the current Attempt. /// +/// +/// Coins are banked only by a First Clear, so dying loses what was found this attempt — including +/// anything spent from it. See docs/adr/0002-attempt-is-a-ledger.md. +/// public partial class CoinStateHandler : Node { public override void _Ready() @@ -17,13 +21,13 @@ public partial class CoinStateHandler : Node public override void _ExitTree() { if (EventBus.Instance != null) - { EventBus.Instance.CoinCollected -= OnCoinCollected; - } } private void OnCoinCollected(int amount, Vector2 position) { - GameStateStore.Instance?.AddSessionCoins(amount); + if (amount <= 0) return; + + ProgressionRoot.Instance?.RecordCoinsFound(Coins.Of(amount)); } } diff --git a/scripts/Events/SkillCollectHandler.cs b/scripts/Events/SkillCollectHandler.cs index 27728be..1c2f8c9 100644 --- a/scripts/Events/SkillCollectHandler.cs +++ b/scripts/Events/SkillCollectHandler.cs @@ -1,17 +1,20 @@ using Godot; using Mr.BrickAdventures.Autoloads; +using Mr.BrickAdventures.Progression; using Mr.BrickAdventures.scripts.Resources; namespace Mr.BrickAdventures.scripts.Events; /// -/// Handles skill collection events and unlocks skills via GameStateStore. -/// Skills are immediately activated but only persisted on level complete. +/// Grants a skill picked up in a level. /// +/// +/// Only records that it is owned. Attaching the live component is SkillManager's job, driven by +/// SkillSetChanged — previously this did both as two separate calls, and the second could silently do +/// nothing if no player was registered yet, leaving the skill owned but inert. +/// public partial class SkillCollectHandler : Node { - private SkillManager SkillManager => SkillManager.Instance; - public override void _Ready() { EventBus.Instance.SkillCollected += OnSkillCollected; @@ -20,22 +23,13 @@ public partial class SkillCollectHandler : Node public override void _ExitTree() { if (EventBus.Instance != null) - { EventBus.Instance.SkillCollected -= OnSkillCollected; - } } private void OnSkillCollected(SkillData skill, Vector2 position) { - if (skill == null) return; + if (skill == null || string.IsNullOrWhiteSpace(skill.Name)) return; - // Unlock in session (will be committed on level complete, lost on death) - GameStateStore.Instance?.UnlockSkillInSession(skill); - - // Immediately activate the skill for the player - SkillManager?.AddSkill(skill); - - // Emit skill unlocked event for UI/achievements - EventBus.EmitSkillUnlocked(skill.Name, skill.Level); + ProgressionRoot.Instance?.GrantSkill(SkillId.Of(skill.Name)); } } diff --git a/scripts/Resources/SkillData.cs b/scripts/Resources/SkillData.cs index 660aba0..9e67e0f 100644 --- a/scripts/Resources/SkillData.cs +++ b/scripts/Resources/SkillData.cs @@ -10,10 +10,12 @@ public partial class SkillData : Resource [Export] public string Name { get; set; } = "New Skill"; [Export] public string Description { get; set; } = "New Skill"; [Export] public Texture2D Icon { get; set; } - [Export] public int Level { get; set; } = 1; [Export] public SkillType Type { get; set; } = SkillType.Throw; [Export] public PackedScene Node { get; set; } [Export] public Array Upgrades { get; set; } = []; + /// + /// Highest level the authored upgrade table defines. Zero means this skill cannot be bought. + /// public int MaxLevel => Upgrades.Count; } \ No newline at end of file diff --git a/scripts/UI/Hud.cs b/scripts/UI/Hud.cs index 11c627d..47680f3 100644 --- a/scripts/UI/Hud.cs +++ b/scripts/UI/Hud.cs @@ -14,16 +14,32 @@ public partial class Hud : Control private GameStateStore Store => GameStateStore.Instance; + public override void _Ready() + { + if (EventBus.Instance != null) + EventBus.Instance.CoinsChanged += OnCoinsChanged; + + SetCoinsLabel(ProgressionRoot.Instance?.TotalCoins.Value ?? 0); + } + + public override void _ExitTree() + { + if (EventBus.Instance != null) + EventBus.Instance.CoinsChanged -= OnCoinsChanged; + } + + // Lives still polls the old store; it moves with the death path in the next commit. public override void _Process(double delta) { SetHealthBar(); SetLivesLabel(); - SetCoinsLabel(); } - private void SetCoinsLabel() + private void OnCoinsChanged(int totalCoins) => SetCoinsLabel(totalCoins); + + private void SetCoinsLabel(int totalCoins) { - CoinsLabel.Text = Tr("COINS_LABEL") + ": " + (Store?.GetTotalCoins() ?? 0); + CoinsLabel.Text = Tr("COINS_LABEL") + ": " + totalCoins; } private void SetLivesLabel() diff --git a/scripts/UI/Marketplace.cs b/scripts/UI/Marketplace.cs index f196707..1b89a29 100644 --- a/scripts/UI/Marketplace.cs +++ b/scripts/UI/Marketplace.cs @@ -3,6 +3,7 @@ using Godot; using Godot.Collections; using Mr.BrickAdventures.Autoloads; using Mr.BrickAdventures.scripts.components; +using Mr.BrickAdventures.Progression; using Mr.BrickAdventures.scripts.Resources; namespace Mr.BrickAdventures.scripts.UI; @@ -18,7 +19,7 @@ public partial class Marketplace : Control [Export] public PackedScene MarketplaceButtonScene { get; set; } [Export] public PackedScene SkillButtonScene { get; set; } - private GameStateStore Store => GameStateStore.Instance; + private static ProgressionRoot Progression => ProgressionRoot.Instance; private SkillManager SkillManager => SkillManager.Instance; private readonly List