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

@@ -4,8 +4,14 @@ using Mr.BrickAdventures.Autoloads;
namespace Mr.BrickAdventures.scripts.Events;
/// <summary>
/// Handles player death events and updates lives in GameStateStore.
/// Charges a death to the Playthrough.
/// </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 override void _Ready()
@@ -16,14 +22,11 @@ public partial class LivesStateHandler : Node
public override void _ExitTree()
{
if (EventBus.Instance != null)
{
EventBus.Instance.PlayerDied -= OnPlayerDied;
}
}
private void OnPlayerDied(Vector2 position)
{
GameStateStore.Instance?.RemoveLife();
GameStateStore.Instance?.ResetSession();
ProgressionRoot.Instance?.HandlePlayerDeath();
}
}

View File

@@ -1,7 +1,6 @@
using Godot;
using Mr.BrickAdventures.Autoloads;
using Mr.BrickAdventures.scripts.Resources;
using Mr.BrickAdventures.scripts.State;
namespace Mr.BrickAdventures.scripts.UI;
@@ -14,46 +13,49 @@ public partial class DeathScreen : Control
[Export] public float TimeoutTime { get; set; } = 2.0f;
[Export] public Godot.Collections.Array<Node> NodesToDisable { get; set; } = new();
private GameManager GameManager => GameManager.Instance;
private Timer _timer;
private bool _runOver;
public override void _Ready()
{
// Subscribe to lives changed event for reactive updates
EventBus.Instance.LivesChanged += OnLivesChanged;
EventBus.Instance.AttemptFailed += OnAttemptFailed;
}
public override void _ExitTree()
{
if (EventBus.Instance != null)
{
EventBus.Instance.LivesChanged -= OnLivesChanged;
}
EventBus.Instance.AttemptFailed -= OnAttemptFailed;
}
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
LivesLeftLabel.Text = $" x {lives}";
}
_runOver = runOver;
LivesLeftLabel.Text = $" x {remainingLives}";
private void SetLabels()
{
if (CurrentLevel != null)
{
CurrentLevelLabel.Text = CurrentLevel.LevelName;
}
// Read current lives from store
var lives = GameStateStore.Instance?.Player.Lives ?? 0;
LivesLeftLabel.Text = $" x {lives}";
ToggleNodes();
Show();
SetupTimer();
}
private void SetupTimer()
{
_timer = new Timer();
_timer.WaitTime = TimeoutTime;
_timer.OneShot = true;
_timer = new Timer
{
WaitTime = TimeoutTime,
OneShot = true,
};
_timer.Timeout += OnTimeout;
AddChild(_timer);
_timer.Start();
@@ -69,21 +71,11 @@ public partial class DeathScreen : Control
}
}
public void OnPlayerDeath()
{
if (GameManager == null) return;
ToggleNodes();
SetLabels();
Show();
SetupTimer();
}
private void OnTimeout()
{
var lives = GameStateStore.Instance?.Player.Lives ?? 0;
if (lives == 0) return;
// Game over is the other screen's job; this one only retries.
if (_runOver) return;
GetTree().ReloadCurrentScene();
}
}
}

View File

@@ -21,12 +21,17 @@ public partial class GameOverScreen : Control
if (InputDeviceManager.Instance != null)
InputDeviceManager.Instance.DeviceChanged += OnDeviceChanged;
EventBus.Instance.RunEnded += OnRunEnded;
}
public override void _ExitTree()
{
if (InputDeviceManager.Instance != null)
InputDeviceManager.Instance.DeviceChanged -= OnDeviceChanged;
if (EventBus.Instance != null)
EventBus.Instance.RunEnded -= OnRunEnded;
}
private void OnDeviceChanged(int device)
@@ -38,7 +43,6 @@ public partial class GameOverScreen : Control
private void OnMainMenuClicked()
{
GameStateStore.Instance?.ResetAll();
SpeedRunManager.Instance?.StopTimer();
GetTree().ChangeSceneToPacked(MainMenuScene);
}
@@ -48,10 +52,13 @@ public partial class GameOverScreen : Control
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();
if (InputDeviceManager.Instance?.IsPointerless == true)
RestartButton.GrabFocus();

View File

@@ -12,29 +12,36 @@ public partial class Hud : Control
[Export] public ProgressBar HealthBar { get; set; }
[Export] public Label LivesLabel { get; set; }
private GameStateStore Store => GameStateStore.Instance;
public override void _Ready()
{
if (EventBus.Instance != null)
{
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()
{
if (EventBus.Instance != null)
{
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)
{
SetHealthBar();
SetLivesLabel();
}
private void OnLivesChanged(int lives) => SetLivesLabel(lives);
private void OnCoinsChanged(int totalCoins) => SetCoinsLabel(totalCoins);
private void SetCoinsLabel(int totalCoins)
@@ -42,9 +49,9 @@ public partial class Hud : Control
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()

View File

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

View File

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