atomic pair - purchasing couples them, so neither moves alone. SkillData.Level is gone. It was an [Export] on a cached Resource: one instance per process, so the level never persisted, leaked between save slots, and made "unowned" indistinguishable from "level 1". Level now comes from the Playthrough and is passed to ISkill.Initialize. SkillManager reconciles from SkillSetChanged instead of being told by each caller. The old shape was "record it, then tell SkillManager" as two calls at four sites; AddSkill's early return when no player is registered left the skill owned but inert. Reconciliation also tracks the level each component was built at, so an upgrade rebuilds it rather than being patched in from outside. SkillUnlockerComponent collapses to one TryBuy - unlock and upgrade were two methods each doing affordability, debit, level mutation and manager notification in order. The guard three components copy-pasted around Upgrades[Level - 1] - and which BrickArmor omitted - now lives once in SkillComponentBase. Hud reads coins from CoinsChanged instead of rebuilding translated strings every frame. Lives still polls; it moves with the death path.
204 lines
6.1 KiB
C#
204 lines
6.1 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<PackedScene> 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>
|
|
/// Transitional: coins, lives and skills are still owned by <c>GameStateStore</c> and persisted by
|
|
/// <c>SaveSystem</c>. Only level progression has moved. The two are kept apart deliberately — nothing
|
|
/// is tracked in both places at once.
|
|
/// </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 GameStateStore Store => GameStateStore.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;
|
|
|
|
Store?.ResetAll();
|
|
Progression.StartPlaythrough(ProgressionRoot.SoleSlot);
|
|
SpeedRunManager?.StartTimer();
|
|
SaveSystem.Instance.SaveGame();
|
|
EventBus.EmitGameStarted();
|
|
|
|
GoTo(Progression.Campaign.First);
|
|
}
|
|
|
|
public void ContinueGame()
|
|
{
|
|
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))
|
|
{
|
|
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;
|
|
|
|
Store?.ResetAll();
|
|
Progression.StartPlaythrough(ProgressionRoot.SoleSlot);
|
|
SpeedRunManager?.StartTimer();
|
|
SaveSystem.Instance.SaveGame();
|
|
|
|
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);
|
|
|
|
// 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))
|
|
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
|
|
}
|