feat(progression): composition root and event republisher (slice 9)
ProgressionRoot autoload builds the JSON stores from
GlobalizePath("user://"), loads the Profile, reads the authored upgrade
tables into a SkillCatalogue, and owns the ProgressionService. Every
mutating operation is wrapped so draining and republishing cannot be
forgotten - the one risk of the DrainEvents design, now confined to one
file.
ProgressionEventRepublisher turns domain records into EventBus signals
(ADR-0004), so existing subscribers and scene wiring keep working. New
RunEnded signal: GameOverScreen reads Lives directly today, which is the
last-life soft-lock.
Registered after SkillManager, since the skill catalogue is built from
AvailableSkills. GameStateStore stays until slice 10 moves the callers.
Transitional: LevelStarted is republished with a catalogue index because
GhostManager names files ghost_level_{index}.json.
This commit is contained in:
@@ -154,6 +154,16 @@ public partial class EventBus : Node
|
|||||||
[Signal] public delegate void CoinsChangedEventHandler(int totalCoins);
|
[Signal] public delegate void CoinsChangedEventHandler(int totalCoins);
|
||||||
[Signal] public delegate void LivesChangedEventHandler(int lives);
|
[Signal] public delegate void LivesChangedEventHandler(int lives);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Playthrough has no lives left. Raised as part of the same operation that spends the last
|
||||||
|
/// life, so a subscriber can never observe lives that have not been decremented yet — which is
|
||||||
|
/// the race that lets the death screen sit there forever on the last life.
|
||||||
|
/// </summary>
|
||||||
|
[Signal] public delegate void RunEndedEventHandler();
|
||||||
|
|
||||||
|
public static void EmitRunEnded()
|
||||||
|
=> Instance?.EmitSignal(SignalName.RunEnded);
|
||||||
|
|
||||||
public static void EmitCoinsChanged(int totalCoins)
|
public static void EmitCoinsChanged(int totalCoins)
|
||||||
=> Instance?.EmitSignal(SignalName.CoinsChanged, totalCoins);
|
=> Instance?.EmitSignal(SignalName.CoinsChanged, totalCoins);
|
||||||
|
|
||||||
|
|||||||
72
Autoloads/ProgressionEventRepublisher.cs
Normal file
72
Autoloads/ProgressionEventRepublisher.cs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Godot;
|
||||||
|
using Mr.BrickAdventures.Progression;
|
||||||
|
using Mr.BrickAdventures.scripts.Resources;
|
||||||
|
|
||||||
|
namespace Mr.BrickAdventures.Autoloads;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Translates domain events into Godot signals on the <see cref="EventBus"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The adapter half of ADR-0004. Aggregates raise plain records with no engine dependency; this is the
|
||||||
|
/// single place that turns them into signals, which is what makes "state changed ⇒ signal fired"
|
||||||
|
/// un-bypassable. Existing subscribers and scene wiring keep working unchanged.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Not a Node — it has no lifecycle of its own. The composition root owns one and hands it every
|
||||||
|
/// batch of drained events.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ProgressionEventRepublisher
|
||||||
|
{
|
||||||
|
private readonly LevelCatalogue _catalogue;
|
||||||
|
private readonly SceneTree _tree;
|
||||||
|
|
||||||
|
public ProgressionEventRepublisher(LevelCatalogue catalogue, SceneTree tree)
|
||||||
|
{
|
||||||
|
_catalogue = catalogue;
|
||||||
|
_tree = tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Publish(IReadOnlyList<ProgressionEvent> events)
|
||||||
|
{
|
||||||
|
foreach (var @event in events)
|
||||||
|
Publish(@event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Publish(ProgressionEvent @event)
|
||||||
|
{
|
||||||
|
switch (@event)
|
||||||
|
{
|
||||||
|
case LevelStarted started:
|
||||||
|
// The existing signal is keyed by array index because GhostManager names its replay
|
||||||
|
// files ghost_level_{index}.json. Transitional: once ghosts key on LevelId this
|
||||||
|
// becomes a straight pass-through and the index lookup goes away.
|
||||||
|
EventBus.EmitLevelStarted(IndexOf(started.Level), _tree.CurrentScene);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RunEnded:
|
||||||
|
EventBus.EmitRunEnded();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case AchievementUnlocked unlocked:
|
||||||
|
EventBus.EmitAchievementUnlocked(unlocked.Achievement.Value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
GD.PushWarning($"No signal is published for domain event {@event.GetType().Name}.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int IndexOf(LevelId level)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < _catalogue.Levels.Count; i++)
|
||||||
|
if (_catalogue.Levels[i]?.Id == level.Value)
|
||||||
|
return i;
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
147
Autoloads/ProgressionRoot.cs
Normal file
147
Autoloads/ProgressionRoot.cs
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
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; }
|
||||||
|
|
||||||
|
[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 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
|
||||||
|
}
|
||||||
9
objects/progression_root.tscn
Normal file
9
objects/progression_root.tscn
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[gd_scene load_steps=3 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://Autoloads/ProgressionRoot.cs" id="1_script"]
|
||||||
|
[ext_resource type="Resource" path="res://resources/levels/level_catalogue.tres" id="2_catalogue"]
|
||||||
|
|
||||||
|
[node name="ProgressionRoot" type="Node"]
|
||||||
|
script = ExtResource("1_script")
|
||||||
|
Catalogue = ExtResource("2_catalogue")
|
||||||
|
StartingLives = 3
|
||||||
@@ -54,6 +54,7 @@ StatisticsEventHandler="*res://scripts/Events/StatisticsEventHandler.cs"
|
|||||||
CoinStateHandler="*res://scripts/Events/CoinStateHandler.cs"
|
CoinStateHandler="*res://scripts/Events/CoinStateHandler.cs"
|
||||||
LivesStateHandler="*res://scripts/Events/LivesStateHandler.cs"
|
LivesStateHandler="*res://scripts/Events/LivesStateHandler.cs"
|
||||||
SkillCollectHandler="*res://scripts/Events/SkillCollectHandler.cs"
|
SkillCollectHandler="*res://scripts/Events/SkillCollectHandler.cs"
|
||||||
|
ProgressionRoot="*res://objects/progression_root.tscn"
|
||||||
GameStateStore="*res://Autoloads/GameStateStore.cs"
|
GameStateStore="*res://Autoloads/GameStateStore.cs"
|
||||||
GameManager="*res://objects/game_manager.tscn"
|
GameManager="*res://objects/game_manager.tscn"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user