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

@@ -10,6 +10,18 @@ public abstract record ProgressionEvent;
/// <summary>Raised when a Playthrough loses its last life.</summary>
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>
/// 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.

View File

@@ -246,10 +246,13 @@ public sealed class ProgressionService
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;
_playthroughs.Save(_playthrough);
AnnounceChanges();
// Announced last, once lives and the discard have both landed.
_events.Raise(new AttemptFailed(_playthrough.Lives, _playthrough.IsRunOver));
}
/// <summary>