Files
przygody-pana-cegly/Autoloads/GameManager.cs
Gabriel Kaszewski e8d1da8f89 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.
2026-07-28 20:19:35 +02:00

192 lines
5.6 KiB
C#

using Godot;
using Mr.BrickAdventures.Progression;
using Mr.BrickAdventures.scripts.components;
namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Game orchestrator: scene transitions and game flow.
/// </summary>
/// <remarks>
/// <para>
/// Which Level the player is on now belongs to <see cref="ProgressionRoot"/>, addressed by a stable
/// <see cref="LevelId"/> and resolved to a scene by the level catalogue. The old
/// <c>Array&lt;PackedScene&gt; LevelScenes</c> is gone: it made a save's meaning depend on the order
/// of an inspector array, so reordering levels silently invalidated every save.
/// </para>
/// <para>
/// Progression is persisted by <see cref="ProgressionRoot"/>'s stores, which save themselves at the
/// points that matter — a First Clear and a death — so there is no separate "save the game" step to
/// forget.
/// </para>
/// </remarks>
public partial class GameManager : Node
{
public PlayerController Player
{
get => GetPlayer();
private set => _player = value;
}
private PlayerController _player;
private SpeedRunManager SpeedRunManager => SpeedRunManager.Instance;
private ProgressionRoot Progression => ProgressionRoot.Instance;
public static GameManager Instance { get; private set; }
public override void _Ready()
{
Instance = this;
EventBus.Instance.PlayerSpawned += OnPlayerSpawned;
}
public override void _ExitTree()
{
if (Instance == this) Instance = null;
if (EventBus.Instance != null)
EventBus.Instance.PlayerSpawned -= OnPlayerSpawned;
}
private void OnPlayerSpawned(PlayerController player)
{
_player = player;
player.TreeExiting += () => { if (_player == player) _player = null; };
}
#region Game Flow
public void StartNewGame()
{
if (Progression == null) return;
Progression.StartPlaythrough(ProgressionRoot.SoleSlot);
SpeedRunManager?.StartTimer();
EventBus.EmitGameStarted();
GoTo(Progression.Campaign.First);
}
public void ContinueGame()
{
if (Progression == null) return;
if (!Progression.LoadPlaythrough(ProgressionRoot.SoleSlot))
{
var why = Progression.PeekPlaythrough(ProgressionRoot.SoleSlot);
if (why == LoadOutcome.Corrupt)
{
// Deliberately not overwritten: the file is left where it is so it can be recovered.
GD.PushError("Saved progress could not be read. It has been left untouched.");
return;
}
StartNewGame();
return;
}
SpeedRunManager?.StartTimer();
EventBus.EmitGameContinued();
GoTo(Progression.CurrentLevel ?? Progression.Campaign.First);
}
public void RestartGame()
{
if (Progression == null) return;
Progression.StartPlaythrough(ProgressionRoot.SoleSlot);
SpeedRunManager?.StartTimer();
GoTo(Progression.Campaign.First);
}
public void OnLevelComplete()
{
if (Progression?.CurrentLevel is not { } finished) return;
var seconds = SpeedRunManager?.GetCurrentLevelTime() ?? 0.0;
LevelTime? time = seconds > 0 ? LevelTime.Of(seconds) : null;
EventBus.EmitLevelCompleted(IndexOf(finished), GetTree().CurrentScene, seconds);
// Banks the attempt's coins and skills, marks the level cleared, and advances campaign
// position if this was a First Clear.
Progression.FinishLevel(time);
if (Progression.CurrentLevel is { } next && !next.Equals(finished))
GoTo(next);
}
/// <summary>
/// Loads a Level's scene, then announces the entry once the new scene is actually current.
/// </summary>
/// <remarks>
/// The announcement is deliberately after a frame. <c>ChangeSceneToPacked</c> is deferred, so at
/// the moment it returns <c>CurrentScene</c> is still the outgoing scene — which is why the old
/// code handed <c>GhostEventHandler</c> the scene being torn down and ghosts were spawned into it.
/// </remarks>
private async void GoTo(LevelId level)
{
if (!Progression.CanEnter(level))
{
GD.PushError($"Level '{level}' is not reachable in this playthrough.");
return;
}
var scene = Progression.Catalogue.SceneFor(level);
if (scene == null)
{
GD.PushError($"Level '{level}' has no loadable scene.");
return;
}
GetTree().ChangeSceneToPacked(scene);
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
Progression.EnterLevel(level);
}
/// <summary>
/// Position in the catalogue, for the signals that are still index-based because GhostManager
/// names its files <c>ghost_level_{index}.json</c>.
/// </summary>
private int IndexOf(LevelId level)
{
var levels = Progression?.Catalogue?.Levels;
if (levels == null) return -1;
for (var i = 0; i < levels.Count; i++)
if (levels[i]?.Id == level.Value)
return i;
return -1;
}
public void PauseGame()
{
Engine.TimeScale = 0;
EventBus.EmitGamePaused();
}
public void ResumeGame()
{
Engine.TimeScale = 1;
EventBus.EmitGameResumed();
}
public void QuitGame() => GetTree().Quit();
#endregion
#region Player Lookup
public PlayerController GetPlayer()
{
if (_player != null && IsInstanceValid(_player)) return _player;
_player = null;
return null;
}
#endregion
}