narrowest safe step: only level identity and progression move. Coins, lives, skills and death stay entirely on GameStateStore and behave as before, so nothing is tracked in two places. GameManager addresses levels by stable LevelId via the catalogue; LevelScenes is gone, so reordering levels no longer invalidates saves. Every level entry goes through EnterLevel, which fixes LevelStarted never firing on the first level or after Continue - ghost recording and splits have been dead outside mid-campaign. GoTo announces after a frame, once the incoming scene is actually current. ChangeSceneToPacked is deferred, so the old code handed GhostEventHandler the scene being torn down. FinishLevel takes LevelTime? - GetCurrentLevelTime can legitimately be 0 and inventing a value is exactly the bad data LevelTime refuses. Continue distinguishes absent from corrupt and refuses to overwrite a corrupt save. Statistics move to the Profile. 69 tests.
189 lines
6.5 KiB
C#
189 lines
6.5 KiB
C#
using System;
|
|
using Godot;
|
|
using Mr.BrickAdventures.Progression;
|
|
using Mr.BrickAdventures.Progression.Adapters;
|
|
using Mr.BrickAdventures.scripts.Resources;
|
|
|
|
namespace Mr.BrickAdventures.Autoloads;
|
|
|
|
/// <summary>
|
|
/// Composition root for player progression: builds the stores, loads the Profile, and owns the
|
|
/// <see cref="ProgressionService"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The only Node in this area. The aggregates are plain objects, so their lifetime is no longer tied
|
|
/// to the scene tree — which is why the operations below need no null-guarding of the store itself.
|
|
/// </para>
|
|
/// <para>
|
|
/// Every mutating operation is wrapped so that draining and republishing cannot be forgotten. That is
|
|
/// the one risk of the DrainEvents design, and wrapping collapses it to this file.
|
|
/// </para>
|
|
/// </remarks>
|
|
public partial class ProgressionRoot : Node
|
|
{
|
|
public static ProgressionRoot Instance { get; private set; }
|
|
|
|
/// <summary>
|
|
/// The one slot the game currently uses. Phase B replaces this with a picker over several
|
|
/// Playthroughs; until then a fixed id keeps the file name stable across runs.
|
|
/// </summary>
|
|
public static readonly PlaythroughId SoleSlot =
|
|
PlaythroughId.Of(new Guid("a17e0000-0000-4000-8000-000000000001"));
|
|
|
|
[Export] public LevelCatalogue Catalogue { get; set; }
|
|
|
|
/// <summary>Lives a new Playthrough starts with. A tuning value, so it is authored here rather than baked into the domain.</summary>
|
|
[Export] public int StartingLives { get; set; } = 3;
|
|
|
|
private ProgressionService _service;
|
|
private ProgressionEventRepublisher _republisher;
|
|
|
|
/// <summary>Levels in campaign order, as the domain sees them.</summary>
|
|
public Campaign Campaign { get; private set; }
|
|
|
|
public override void _Ready()
|
|
{
|
|
Instance = this;
|
|
|
|
if (Catalogue == null)
|
|
{
|
|
GD.PushError("ProgressionRoot has no LevelCatalogue assigned; progression is unavailable.");
|
|
return;
|
|
}
|
|
|
|
foreach (var problem in Catalogue.Validate())
|
|
GD.PushError($"LevelCatalogue: {problem}");
|
|
|
|
Campaign = Catalogue.ToCampaign();
|
|
|
|
var directory = ProjectSettings.GlobalizePath("user://");
|
|
var playthroughs = new JsonPlaythroughStore(directory);
|
|
var profiles = new JsonProfileStore(directory);
|
|
|
|
_service = new ProgressionService(
|
|
LoadProfile(profiles), playthroughs, profiles,
|
|
Campaign, BuildSkillCatalogue(), Lives.Of(StartingLives));
|
|
|
|
_republisher = new ProgressionEventRepublisher(Catalogue, GetTree());
|
|
}
|
|
|
|
public override void _ExitTree()
|
|
{
|
|
if (Instance == this) Instance = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A missing Profile is a first run. A corrupt one is reported loudly and replaced with an empty
|
|
/// Profile so the game still starts — but the file is left alone, so it can still be recovered.
|
|
/// </summary>
|
|
private static Profile LoadProfile(IProfileStore profiles)
|
|
{
|
|
var result = profiles.Load();
|
|
|
|
if (result.Outcome == LoadOutcome.Corrupt)
|
|
GD.PushError($"Profile could not be read and has been left untouched: {result.Problem}");
|
|
|
|
return result.Outcome == LoadOutcome.Loaded ? result.Value : Profile.Empty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the authored upgrade tables into the domain's view of them. A Skill with no upgrade
|
|
/// entries yields an empty cost list, which the domain reports as not purchasable rather than
|
|
/// throwing on <c>Upgrades[0]</c>.
|
|
/// </summary>
|
|
private static SkillCatalogue BuildSkillCatalogue()
|
|
{
|
|
var available = SkillManager.Instance?.AvailableSkills;
|
|
if (available == null) return SkillCatalogue.Empty();
|
|
|
|
var costs = new System.Collections.Generic.Dictionary<SkillId, System.Collections.Generic.IReadOnlyList<Coins>>();
|
|
|
|
foreach (var skill in available)
|
|
{
|
|
if (skill == null || string.IsNullOrWhiteSpace(skill.Name)) continue;
|
|
|
|
var levels = new System.Collections.Generic.List<Coins>();
|
|
foreach (var upgrade in skill.Upgrades)
|
|
levels.Add(Coins.Of(Mathf.Max(0, upgrade.Cost)));
|
|
|
|
costs[SkillId.Of(skill.Name)] = levels;
|
|
}
|
|
|
|
return SkillCatalogue.Of(costs);
|
|
}
|
|
|
|
#region Operations — each drains and republishes, so callers cannot forget to
|
|
|
|
public void StartPlaythrough(PlaythroughId id) => Run(() => _service.StartPlaythrough(id));
|
|
|
|
public bool LoadPlaythrough(PlaythroughId id)
|
|
{
|
|
if (_service == null) return false;
|
|
|
|
var loaded = _service.LoadPlaythrough(id);
|
|
Publish();
|
|
return loaded;
|
|
}
|
|
|
|
public LoadOutcome PeekPlaythrough(PlaythroughId id) =>
|
|
_service?.PeekPlaythrough(id) ?? LoadOutcome.Absent;
|
|
|
|
public void RecordStat(StatName stat, int amount = 1) => _service?.RecordStat(stat, amount);
|
|
|
|
#endregion
|
|
|
|
#region Reads
|
|
|
|
public bool IsPlaying => _service?.IsPlaying ?? false;
|
|
public bool CanEnter(LevelId level) => _service?.CanEnter(level) ?? false;
|
|
public LevelId? CurrentLevel => _service?.CurrentLevel;
|
|
public Coins TotalCoins => _service?.TotalCoins ?? Coins.Zero;
|
|
public Lives Lives => _service?.Lives ?? Lives.Of(0);
|
|
public bool IsRunOver => _service?.IsRunOver ?? false;
|
|
public SkillLevel? LevelOf(SkillId skill) => _service?.LevelOf(skill);
|
|
public bool IsOwned(SkillId skill) => _service?.IsOwned(skill) ?? false;
|
|
public bool CanAfford(SkillId skill) => _service?.CanAfford(skill) ?? false;
|
|
public bool HasCleared(LevelId level) => _service?.HasCleared(level) ?? false;
|
|
|
|
#endregion
|
|
|
|
#region Operations, continued
|
|
|
|
public bool EnterLevel(LevelId level)
|
|
{
|
|
if (_service == null) return false;
|
|
|
|
var entered = _service.EnterLevel(level);
|
|
Publish();
|
|
return entered;
|
|
}
|
|
|
|
public void RecordCoinsFound(Coins amount) => Run(() => _service.RecordCoinsFound(amount));
|
|
|
|
public void HandlePlayerDeath() => Run(() => _service.HandlePlayerDeath());
|
|
|
|
public void FinishLevel(LevelTime? time) => Run(() => _service.FinishLevel(time));
|
|
|
|
public PurchaseOutcome PurchaseSkill(SkillId skill)
|
|
{
|
|
if (_service == null) return PurchaseOutcome.NotPlaying;
|
|
|
|
var outcome = _service.PurchaseSkill(skill);
|
|
Publish();
|
|
return outcome;
|
|
}
|
|
|
|
private void Run(System.Action operation)
|
|
{
|
|
if (_service == null) return;
|
|
|
|
operation();
|
|
Publish();
|
|
}
|
|
|
|
private void Publish() => _republisher?.Publish(_service.DrainEvents());
|
|
|
|
#endregion
|
|
}
|