feat: lives and death move to the new domain (commit C of the swap)

fixes the regression commit B introduced: coins and skills lived in the
Attempt ledger, but nothing discarded it, because LivesStateHandler still
called the old store and HandlePlayerDeath was never invoked. Dying kept
everything earned that level. Discard-on-death is the ledger's purpose,
so it could not be separated from it.

new AttemptFailed(remaining, runOver) event, raised after the life is
spent and the ledger discarded. DeathScreen and GameOverScreen react to
that instead of being wired to HealthComponent.Death by scene connections
which fired *before* the life was charged - on the last life game-over
saw one life left and showed nothing, then the death screen saw zero and
refused to reload. 18 scene connections deleted; the race is now
unrepresentable rather than fixed.

DeathScreen also had two read paths for lives - a signal and a direct
store read - because neither was reliable alone. Now one.

Hud reads both facts from signals; only health still polls, and that
belongs to the component rather than to progression. GameManager no
longer touches GameStateStore or SaveSystem at all.
This commit is contained in:
2026-07-28 20:19:35 +02:00
parent 360f73ba24
commit e8d1da8f89
13 changed files with 139 additions and 72 deletions

View File

@@ -161,6 +161,16 @@ public partial class EventBus : Node
/// </summary> /// </summary>
[Signal] public delegate void RunEndedEventHandler(); [Signal] public delegate void RunEndedEventHandler();
/// <summary>
/// A death has been settled. The life is already spent and the attempt already discarded, so
/// <paramref name="remainingLives"/> is safe to display directly — unlike reading Lives from a
/// handler racing the one that writes it.
/// </summary>
[Signal] public delegate void AttemptFailedEventHandler(int remainingLives, bool runOver);
public static void EmitAttemptFailed(int remainingLives, bool runOver)
=> Instance?.EmitSignal(SignalName.AttemptFailed, remainingLives, runOver);
public static void EmitRunEnded() public static void EmitRunEnded()
=> Instance?.EmitSignal(SignalName.RunEnded); => Instance?.EmitSignal(SignalName.RunEnded);

View File

@@ -15,9 +15,9 @@ namespace Mr.BrickAdventures.Autoloads;
/// of an inspector array, so reordering levels silently invalidated every save. /// of an inspector array, so reordering levels silently invalidated every save.
/// </para> /// </para>
/// <para> /// <para>
/// Transitional: coins, lives and skills are still owned by <c>GameStateStore</c> and persisted by /// Progression is persisted by <see cref="ProgressionRoot"/>'s stores, which save themselves at the
/// <c>SaveSystem</c>. Only level progression has moved. The two are kept apart deliberately — nothing /// points that matter — a First Clear and a death — so there is no separate "save the game" step to
/// is tracked in both places at once. /// forget.
/// </para> /// </para>
/// </remarks> /// </remarks>
public partial class GameManager : Node public partial class GameManager : Node
@@ -30,7 +30,6 @@ public partial class GameManager : Node
private PlayerController _player; private PlayerController _player;
private SpeedRunManager SpeedRunManager => SpeedRunManager.Instance; private SpeedRunManager SpeedRunManager => SpeedRunManager.Instance;
private GameStateStore Store => GameStateStore.Instance;
private ProgressionRoot Progression => ProgressionRoot.Instance; private ProgressionRoot Progression => ProgressionRoot.Instance;
public static GameManager Instance { get; private set; } public static GameManager Instance { get; private set; }
@@ -60,10 +59,8 @@ public partial class GameManager : Node
{ {
if (Progression == null) return; if (Progression == null) return;
Store?.ResetAll();
Progression.StartPlaythrough(ProgressionRoot.SoleSlot); Progression.StartPlaythrough(ProgressionRoot.SoleSlot);
SpeedRunManager?.StartTimer(); SpeedRunManager?.StartTimer();
SaveSystem.Instance.SaveGame();
EventBus.EmitGameStarted(); EventBus.EmitGameStarted();
GoTo(Progression.Campaign.First); GoTo(Progression.Campaign.First);
@@ -73,10 +70,6 @@ public partial class GameManager : Node
{ {
if (Progression == null) return; if (Progression == null) return;
// Coins and skills still come from the old save; level progression from the new one.
if (!SaveSystem.Instance.LoadGame())
GD.PrintErr("GameManager: no legacy save to continue; starting fresh.");
if (!Progression.LoadPlaythrough(ProgressionRoot.SoleSlot)) if (!Progression.LoadPlaythrough(ProgressionRoot.SoleSlot))
{ {
var why = Progression.PeekPlaythrough(ProgressionRoot.SoleSlot); var why = Progression.PeekPlaythrough(ProgressionRoot.SoleSlot);
@@ -101,10 +94,8 @@ public partial class GameManager : Node
{ {
if (Progression == null) return; if (Progression == null) return;
Store?.ResetAll();
Progression.StartPlaythrough(ProgressionRoot.SoleSlot); Progression.StartPlaythrough(ProgressionRoot.SoleSlot);
SpeedRunManager?.StartTimer(); SpeedRunManager?.StartTimer();
SaveSystem.Instance.SaveGame();
GoTo(Progression.Campaign.First); GoTo(Progression.Campaign.First);
} }
@@ -122,9 +113,6 @@ public partial class GameManager : Node
// position if this was a First Clear. // position if this was a First Clear.
Progression.FinishLevel(time); Progression.FinishLevel(time);
// 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)) if (Progression.CurrentLevel is { } next && !next.Equals(finished))
GoTo(next); GoTo(next);
} }

View File

@@ -51,6 +51,10 @@ public sealed class ProgressionEventRepublisher
EventBus.EmitRunEnded(); EventBus.EmitRunEnded();
break; break;
case AttemptFailed failed:
EventBus.EmitAttemptFailed(failed.Remaining.Value, failed.RunOver);
break;
case AchievementUnlocked unlocked: case AchievementUnlocked unlocked:
EventBus.EmitAchievementUnlocked(unlocked.Achievement.Value); EventBus.EmitAchievementUnlocked(unlocked.Achievement.Value);
break; break;

View File

@@ -110,8 +110,6 @@ tile_set = ExtResource("11_0vx27")
[node name="Killzone" parent="." unique_id=319936093 instance=ExtResource("13_0vx27")] [node name="Killzone" parent="." unique_id=319936093 instance=ExtResource("13_0vx27")]
position = Vector2(215, 324) position = Vector2(215, 324)
[connection signal="Death" from="Brick Player/HealthComponent" to="UI Layer/DeathScreen" method="OnPlayerDeath"]
[connection signal="Death" from="Brick Player/HealthComponent" to="UI Layer/GameOverScreen" method="OnPlayerDeath"]
[editable path="Brick Player"] [editable path="Brick Player"]
[editable path="UI Layer"] [editable path="UI Layer"]

View File

@@ -230,6 +230,50 @@ public class ProgressionServiceTests
Assert.False(service.CanEnter(Second)); Assert.False(service.CanEnter(Second));
} }
[Fact]
public void A_settled_death_announces_the_outcome_so_no_screen_has_to_read_lives_itself()
{
// DeathScreen and GameOverScreen were both wired to HealthComponent.Death by scene
// connections that fire before lives are decremented, so on the last life neither saw zero.
// Announcing the settled result means the value is already correct when they hear about it.
var service = ServiceWithSkills();
service.HandlePlayerDeath();
var failed = service.DrainEvents().OfType<AttemptFailed>().Single();
Assert.Equal(2, failed.Remaining.Value);
Assert.False(failed.RunOver);
}
[Fact]
public void Dying_on_the_last_life_announces_the_run_is_over_in_the_same_breath()
{
var service = new ProgressionService(
Profile.Empty(), new InMemoryPlaythroughStore(), new InMemoryProfileStore(),
TwoLevels(), Skills(), Lives.Of(1));
service.StartPlaythrough(Id);
service.EnterLevel(First);
service.DrainEvents();
service.HandlePlayerDeath();
var events = service.DrainEvents();
Assert.True(events.OfType<AttemptFailed>().Single().RunOver);
Assert.Single(events.OfType<RunEnded>());
}
[Fact]
public void A_repeated_death_event_announces_nothing_further()
{
var service = ServiceWithSkills();
service.HandlePlayerDeath();
service.DrainEvents();
service.HandlePlayerDeath();
Assert.Empty(service.DrainEvents().OfType<AttemptFailed>());
}
[Fact] [Fact]
public void A_collected_skill_is_granted_free_and_only_once() public void A_collected_skill_is_granted_free_and_only_once()
{ {

View File

@@ -10,6 +10,18 @@ public abstract record ProgressionEvent;
/// <summary>Raised when a Playthrough loses its last life.</summary> /// <summary>Raised when a Playthrough loses its last life.</summary>
public sealed record RunEnded : ProgressionEvent; public sealed record RunEnded : ProgressionEvent;
/// <summary>
/// An Attempt ended in death and has been settled: the life is already spent and the ledger already
/// discarded when this is raised.
/// </summary>
/// <remarks>
/// Exists so no screen has to read <c>Lives</c> for itself. The death and game-over screens were wired
/// straight to <c>HealthComponent.Death</c> by scene connections, which fire before the life is charged
/// — so on the last life the game-over screen saw one life left and showed nothing, and the death
/// screen then saw zero and refused to reload. Carrying the settled values removes the race entirely.
/// </remarks>
public sealed record AttemptFailed(Lives Remaining, bool RunOver) : ProgressionEvent;
/// <summary> /// <summary>
/// Raised the first time an Achievement is unlocked, and only then — so a subscriber that shows a /// Raised the first time an Achievement is unlocked, and only then — so a subscriber that shows a
/// popup or pushes to Steam cannot fire twice however often it is told. /// popup or pushes to Steam cannot fire twice however often it is told.

View File

@@ -246,10 +246,13 @@ public sealed class ProgressionService
if (!_playthrough.SettleDeath(_attempt)) return; if (!_playthrough.SettleDeath(_attempt)) return;
// The ledger was discarded, so it must stop contributing to TotalCoins. // The ledger was discarded, so it must stop contributing to TotalCoins or to owned skills.
_attempt = null; _attempt = null;
_playthroughs.Save(_playthrough); _playthroughs.Save(_playthrough);
AnnounceChanges(); AnnounceChanges();
// Announced last, once lives and the discard have both landed.
_events.Raise(new AttemptFailed(_playthrough.Lives, _playthrough.IsRunOver));
} }
/// <summary> /// <summary>

View File

@@ -4,8 +4,14 @@ using Mr.BrickAdventures.Autoloads;
namespace Mr.BrickAdventures.scripts.Events; namespace Mr.BrickAdventures.scripts.Events;
/// <summary> /// <summary>
/// Handles player death events and updates lives in GameStateStore. /// Charges a death to the Playthrough.
/// </summary> /// </summary>
/// <remarks>
/// One call now does what three did: spend a life, discard the Attempt's ledger, and announce the
/// settled result. It is also idempotent — overlapping hazards in a kill zone, or a hazard re-firing
/// during the two seconds before the scene reloads, cost one life. See
/// docs/adr/0005-playthrough-settles-attempts.md.
/// </remarks>
public partial class LivesStateHandler : Node public partial class LivesStateHandler : Node
{ {
public override void _Ready() public override void _Ready()
@@ -16,14 +22,11 @@ public partial class LivesStateHandler : Node
public override void _ExitTree() public override void _ExitTree()
{ {
if (EventBus.Instance != null) if (EventBus.Instance != null)
{
EventBus.Instance.PlayerDied -= OnPlayerDied; EventBus.Instance.PlayerDied -= OnPlayerDied;
} }
}
private void OnPlayerDied(Vector2 position) private void OnPlayerDied(Vector2 position)
{ {
GameStateStore.Instance?.RemoveLife(); ProgressionRoot.Instance?.HandlePlayerDeath();
GameStateStore.Instance?.ResetSession();
} }
} }

View File

@@ -1,7 +1,6 @@
using Godot; using Godot;
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;
@@ -14,46 +13,49 @@ public partial class DeathScreen : Control
[Export] public float TimeoutTime { get; set; } = 2.0f; [Export] public float TimeoutTime { get; set; } = 2.0f;
[Export] public Godot.Collections.Array<Node> NodesToDisable { get; set; } = new(); [Export] public Godot.Collections.Array<Node> NodesToDisable { get; set; } = new();
private GameManager GameManager => GameManager.Instance;
private Timer _timer; private Timer _timer;
private bool _runOver;
public override void _Ready() public override void _Ready()
{ {
// Subscribe to lives changed event for reactive updates EventBus.Instance.AttemptFailed += OnAttemptFailed;
EventBus.Instance.LivesChanged += OnLivesChanged;
} }
public override void _ExitTree() public override void _ExitTree()
{ {
if (EventBus.Instance != null) if (EventBus.Instance != null)
{ EventBus.Instance.AttemptFailed -= OnAttemptFailed;
EventBus.Instance.LivesChanged -= OnLivesChanged;
}
} }
private void OnLivesChanged(int lives) /// <summary>
/// Shows the death screen.
/// </summary>
/// <remarks>
/// Driven by the settled outcome rather than by a scene connection to
/// <c>HealthComponent.Death</c>, so the life is already spent and the count already correct. The old
/// arrangement read <c>Lives</c> from a handler racing the one that wrote it, then read it a second
/// time on timeout — two read paths for one fact, because neither was reliable alone.
/// </remarks>
private void OnAttemptFailed(int remainingLives, bool runOver)
{ {
// Update the label when lives change _runOver = runOver;
LivesLeftLabel.Text = $" x {lives}"; LivesLeftLabel.Text = $" x {remainingLives}";
}
private void SetLabels()
{
if (CurrentLevel != null) if (CurrentLevel != null)
{
CurrentLevelLabel.Text = CurrentLevel.LevelName; CurrentLevelLabel.Text = CurrentLevel.LevelName;
}
// Read current lives from store ToggleNodes();
var lives = GameStateStore.Instance?.Player.Lives ?? 0; Show();
LivesLeftLabel.Text = $" x {lives}"; SetupTimer();
} }
private void SetupTimer() private void SetupTimer()
{ {
_timer = new Timer(); _timer = new Timer
_timer.WaitTime = TimeoutTime; {
_timer.OneShot = true; WaitTime = TimeoutTime,
OneShot = true,
};
_timer.Timeout += OnTimeout; _timer.Timeout += OnTimeout;
AddChild(_timer); AddChild(_timer);
_timer.Start(); _timer.Start();
@@ -69,20 +71,10 @@ public partial class DeathScreen : Control
} }
} }
public void OnPlayerDeath()
{
if (GameManager == null) return;
ToggleNodes();
SetLabels();
Show();
SetupTimer();
}
private void OnTimeout() private void OnTimeout()
{ {
var lives = GameStateStore.Instance?.Player.Lives ?? 0; // Game over is the other screen's job; this one only retries.
if (lives == 0) return; if (_runOver) return;
GetTree().ReloadCurrentScene(); GetTree().ReloadCurrentScene();
} }

View File

@@ -21,12 +21,17 @@ public partial class GameOverScreen : Control
if (InputDeviceManager.Instance != null) if (InputDeviceManager.Instance != null)
InputDeviceManager.Instance.DeviceChanged += OnDeviceChanged; InputDeviceManager.Instance.DeviceChanged += OnDeviceChanged;
EventBus.Instance.RunEnded += OnRunEnded;
} }
public override void _ExitTree() public override void _ExitTree()
{ {
if (InputDeviceManager.Instance != null) if (InputDeviceManager.Instance != null)
InputDeviceManager.Instance.DeviceChanged -= OnDeviceChanged; InputDeviceManager.Instance.DeviceChanged -= OnDeviceChanged;
if (EventBus.Instance != null)
EventBus.Instance.RunEnded -= OnRunEnded;
} }
private void OnDeviceChanged(int device) private void OnDeviceChanged(int device)
@@ -38,7 +43,6 @@ public partial class GameOverScreen : Control
private void OnMainMenuClicked() private void OnMainMenuClicked()
{ {
GameStateStore.Instance?.ResetAll();
SpeedRunManager.Instance?.StopTimer(); SpeedRunManager.Instance?.StopTimer();
GetTree().ChangeSceneToPacked(MainMenuScene); GetTree().ChangeSceneToPacked(MainMenuScene);
} }
@@ -48,10 +52,13 @@ public partial class GameOverScreen : Control
GameManager.Instance?.RestartGame(); GameManager.Instance?.RestartGame();
} }
public void OnPlayerDeath() /// <summary>
/// Shown when the run is actually over. Previously this read Lives from a scene connection that
/// fired before the life was charged, so on the last life it saw one remaining and showed nothing —
/// leaving the death screen up forever.
/// </summary>
private void OnRunEnded()
{ {
if (GameStateStore.Instance?.Player.Lives != 0) return;
GameOverPanel.Show(); GameOverPanel.Show();
if (InputDeviceManager.Instance?.IsPointerless == true) if (InputDeviceManager.Instance?.IsPointerless == true)
RestartButton.GrabFocus(); RestartButton.GrabFocus();

View File

@@ -12,29 +12,36 @@ public partial class Hud : Control
[Export] public ProgressBar HealthBar { get; set; } [Export] public ProgressBar HealthBar { get; set; }
[Export] public Label LivesLabel { get; set; } [Export] public Label LivesLabel { get; set; }
private GameStateStore Store => GameStateStore.Instance;
public override void _Ready() public override void _Ready()
{ {
if (EventBus.Instance != null) if (EventBus.Instance != null)
{
EventBus.Instance.CoinsChanged += OnCoinsChanged; EventBus.Instance.CoinsChanged += OnCoinsChanged;
EventBus.Instance.LivesChanged += OnLivesChanged;
}
SetCoinsLabel(ProgressionRoot.Instance?.TotalCoins.Value ?? 0); var progression = ProgressionRoot.Instance;
SetCoinsLabel(progression?.TotalCoins.Value ?? 0);
SetLivesLabel(progression?.Lives.Value ?? 0);
} }
public override void _ExitTree() public override void _ExitTree()
{ {
if (EventBus.Instance != null) if (EventBus.Instance != null)
{
EventBus.Instance.CoinsChanged -= OnCoinsChanged; EventBus.Instance.CoinsChanged -= OnCoinsChanged;
EventBus.Instance.LivesChanged -= OnLivesChanged;
}
} }
// Lives still polls the old store; it moves with the death path in the next commit. // Health belongs to the component on the player, not to progression, so it stays a direct read.
public override void _Process(double delta) public override void _Process(double delta)
{ {
SetHealthBar(); SetHealthBar();
SetLivesLabel();
} }
private void OnLivesChanged(int lives) => SetLivesLabel(lives);
private void OnCoinsChanged(int totalCoins) => SetCoinsLabel(totalCoins); private void OnCoinsChanged(int totalCoins) => SetCoinsLabel(totalCoins);
private void SetCoinsLabel(int totalCoins) private void SetCoinsLabel(int totalCoins)
@@ -42,9 +49,9 @@ public partial class Hud : Control
CoinsLabel.Text = Tr("COINS_LABEL") + ": " + totalCoins; CoinsLabel.Text = Tr("COINS_LABEL") + ": " + totalCoins;
} }
private void SetLivesLabel() private void SetLivesLabel(int lives)
{ {
LivesLabel.Text = Tr("LIVES_LABEL") + ": " + (Store?.Player.Lives ?? 0); LivesLabel.Text = Tr("LIVES_LABEL") + ": " + lives;
} }
private void SetHealthBar() private void SetHealthBar()

View File

@@ -15,7 +15,6 @@ public partial class MainMenu : Control
[Export] public Control SettingsControl { get; set; } [Export] public Control SettingsControl { get; set; }
[Export] public Control CreditsControl { get; set; } [Export] public Control CreditsControl { get; set; }
private SaveSystem SaveSystem => SaveSystem.Instance;
private GameManager GameManager => GameManager.Instance; private GameManager GameManager => GameManager.Instance;
private UIManager UIManager => UIManager.Instance; private UIManager UIManager => UIManager.Instance;
@@ -34,7 +33,7 @@ public partial class MainMenu : Control
ExitButton.Pressed += OnExitPressed; ExitButton.Pressed += OnExitPressed;
VersionLabel.Text = $"v. {ProjectSettings.GetSetting("application/config/version")}"; VersionLabel.Text = $"v. {ProjectSettings.GetSetting("application/config/version")}";
ContinueButton.Disabled = !SaveSystem.CheckSaveExists(); ContinueButton.Disabled = !(ProgressionRoot.Instance?.HasSavedPlaythrough ?? false);
if (InputDeviceManager.Instance != null) if (InputDeviceManager.Instance != null)
InputDeviceManager.Instance.DeviceChanged += OnDeviceChanged; InputDeviceManager.Instance.DeviceChanged += OnDeviceChanged;
@@ -66,7 +65,7 @@ public partial class MainMenu : Control
private void GrabFirstFocus() private void GrabFirstFocus()
{ {
if (SaveSystem.CheckSaveExists()) if (ProgressionRoot.Instance?.HasSavedPlaythrough ?? false)
ContinueButton.GrabFocus(); ContinueButton.GrabFocus();
else else
NewGameButton.GrabFocus(); NewGameButton.GrabFocus();

View File

@@ -83,7 +83,7 @@ public partial class PauseMenu : Control
private void OnMainMenuPressed() private void OnMainMenuPressed()
{ {
GameManager.ResumeGame(); GameManager.ResumeGame();
GameStateStore.Instance?.ResetSession(); ProgressionRoot.Instance?.AbandonAttempt();
// The run is over once you leave to the menu; otherwise the timer keeps ticking there. // The run is over once you leave to the menu; otherwise the timer keeps ticking there.
SpeedRunManager.Instance?.StopTimer(); SpeedRunManager.Instance?.StopTimer();
GetTree().ChangeSceneToPacked(MainMenuScene); GetTree().ChangeSceneToPacked(MainMenuScene);