Compare commits
29 Commits
057cf7b368
...
phase-a-pr
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e6d5cf6f9 | |||
| e8d1da8f89 | |||
| 360f73ba24 | |||
| b6a8448743 | |||
| f4411e05a3 | |||
| a1c9042652 | |||
| ca54bf0f82 | |||
| c1c3e7b020 | |||
| 627900b2e8 | |||
| f85c16c641 | |||
| 7a69292066 | |||
| f991bb99fc | |||
| 330e10880c | |||
| 003ec4b010 | |||
| 05149bb4a9 | |||
| ffa2b986ae | |||
| 40bd5c1d86 | |||
| 9767941c65 | |||
| 313a5315b1 | |||
| c3bbf3282a | |||
| dd113af786 | |||
| 6b784303b4 | |||
| 562be1049d | |||
| b2a406ca2b | |||
| 7f0869ffa4 | |||
| 7f1b4b5e0e | |||
| 8769308c85 | |||
| 4b63368286 | |||
| cbf625d2ff |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -3,3 +3,11 @@
|
||||
/android/
|
||||
builds/
|
||||
.worktrees/
|
||||
|
||||
# IDE / local tooling
|
||||
.idea/
|
||||
.superpowers/
|
||||
|
||||
# .NET build output
|
||||
bin/
|
||||
obj/
|
||||
|
||||
@@ -154,6 +154,36 @@ public partial class EventBus : Node
|
||||
[Signal] public delegate void CoinsChangedEventHandler(int totalCoins);
|
||||
[Signal] public delegate void LivesChangedEventHandler(int lives);
|
||||
|
||||
/// <summary>
|
||||
/// The Playthrough has no lives left. Raised as part of the same operation that spends the last
|
||||
/// life, so a subscriber can never observe lives that have not been decremented yet — which is
|
||||
/// the race that lets the death screen sit there forever on the last life.
|
||||
/// </summary>
|
||||
[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()
|
||||
=> Instance?.EmitSignal(SignalName.RunEnded);
|
||||
|
||||
/// <summary>
|
||||
/// The set of owned skills, or the level of one of them, changed. SkillManager reconciles its live
|
||||
/// components against this instead of every caller remembering to update it separately — the
|
||||
/// non-atomic pair of calls that could leave a skill owned but inert.
|
||||
/// </summary>
|
||||
[Signal] public delegate void SkillSetChangedEventHandler();
|
||||
|
||||
public static void EmitSkillSetChanged()
|
||||
=> Instance?.EmitSignal(SignalName.SkillSetChanged);
|
||||
|
||||
public static void EmitCoinsChanged(int totalCoins)
|
||||
=> Instance?.EmitSignal(SignalName.CoinsChanged, totalCoins);
|
||||
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
using Godot;
|
||||
using Godot.Collections;
|
||||
using Mr.BrickAdventures.Progression;
|
||||
using Mr.BrickAdventures.scripts.components;
|
||||
|
||||
namespace Mr.BrickAdventures.Autoloads;
|
||||
|
||||
/// <summary>
|
||||
/// Game orchestrator - handles scene transitions and game flow.
|
||||
/// State lives in GameStateStore; query it directly for reads/writes.
|
||||
/// 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>
|
||||
/// 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
|
||||
{
|
||||
[Export] public Array<PackedScene> LevelScenes { get; set; } = [];
|
||||
|
||||
public PlayerController Player
|
||||
{
|
||||
get => GetPlayer();
|
||||
@@ -20,7 +30,7 @@ public partial class GameManager : Node
|
||||
|
||||
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; }
|
||||
|
||||
@@ -47,70 +57,109 @@ public partial class GameManager : Node
|
||||
|
||||
public void StartNewGame()
|
||||
{
|
||||
Store?.ResetAll();
|
||||
if (Progression == null) return;
|
||||
|
||||
Progression.StartPlaythrough(ProgressionRoot.SoleSlot);
|
||||
SpeedRunManager?.StartTimer();
|
||||
GetTree().ChangeSceneToPacked(LevelScenes[0]);
|
||||
SaveSystem.Instance.SaveGame();
|
||||
EventBus.EmitGameStarted();
|
||||
|
||||
GoTo(Progression.Campaign.First);
|
||||
}
|
||||
|
||||
public void ContinueGame()
|
||||
{
|
||||
var save = SaveSystem.Instance;
|
||||
if (!save.LoadGame())
|
||||
if (Progression == null) return;
|
||||
|
||||
if (!Progression.LoadPlaythrough(ProgressionRoot.SoleSlot))
|
||||
{
|
||||
GD.PrintErr("Failed to load game. Starting a new game instead.");
|
||||
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;
|
||||
}
|
||||
|
||||
var idx = Store?.Player.CurrentLevel ?? 0;
|
||||
if (idx < LevelScenes.Count)
|
||||
{
|
||||
GetTree().ChangeSceneToPacked(LevelScenes[idx]);
|
||||
EventBus.EmitGameContinued();
|
||||
}
|
||||
else
|
||||
{
|
||||
GD.PrintErr("No levels unlocked to continue.");
|
||||
}
|
||||
SpeedRunManager?.StartTimer();
|
||||
EventBus.EmitGameContinued();
|
||||
|
||||
GoTo(Progression.CurrentLevel ?? Progression.Campaign.First);
|
||||
}
|
||||
|
||||
public void RestartGame()
|
||||
{
|
||||
Store?.ResetAll();
|
||||
GetTree().ChangeSceneToPacked(LevelScenes[0]);
|
||||
SaveSystem.Instance.SaveGame();
|
||||
if (Progression == null) return;
|
||||
|
||||
Progression.StartPlaythrough(ProgressionRoot.SoleSlot);
|
||||
SpeedRunManager?.StartTimer();
|
||||
|
||||
GoTo(Progression.Campaign.First);
|
||||
}
|
||||
|
||||
public void OnLevelComplete()
|
||||
{
|
||||
if (Store == null) return;
|
||||
if (Progression?.CurrentLevel is not { } finished) return;
|
||||
|
||||
var levelIndex = Store.Player.CurrentLevel;
|
||||
Store.MarkLevelComplete(levelIndex);
|
||||
Store.CommitSessionCoins();
|
||||
Store.CommitSessionSkills();
|
||||
var seconds = SpeedRunManager?.GetCurrentLevelTime() ?? 0.0;
|
||||
LevelTime? time = seconds > 0 ? LevelTime.Of(seconds) : null;
|
||||
|
||||
var completionTime = SpeedRunManager?.GetCurrentLevelTime() ?? 0.0;
|
||||
EventBus.EmitLevelCompleted(levelIndex, GetTree().CurrentScene, completionTime);
|
||||
EventBus.EmitLevelCompleted(IndexOf(finished), GetTree().CurrentScene, seconds);
|
||||
|
||||
Store.ResetSession();
|
||||
TryToGoToNextLevel();
|
||||
SaveSystem.Instance.SaveGame();
|
||||
// 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);
|
||||
}
|
||||
|
||||
public void TryToGoToNextLevel()
|
||||
/// <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 (Store == null) return;
|
||||
|
||||
var next = Store.Player.CurrentLevel + 1;
|
||||
if (next < LevelScenes.Count && Store.IsLevelUnlocked(next))
|
||||
if (!Progression.CanEnter(level))
|
||||
{
|
||||
Store.Player.CurrentLevel = next;
|
||||
GetTree().ChangeSceneToPacked(LevelScenes[next]);
|
||||
EventBus.EmitLevelStarted(next, GetTree().CurrentScene);
|
||||
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()
|
||||
|
||||
88
Autoloads/ProgressionEventRepublisher.cs
Normal file
88
Autoloads/ProgressionEventRepublisher.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using System.Collections.Generic;
|
||||
using Godot;
|
||||
using Mr.BrickAdventures.Progression;
|
||||
using Mr.BrickAdventures.scripts.Resources;
|
||||
|
||||
namespace Mr.BrickAdventures.Autoloads;
|
||||
|
||||
/// <summary>
|
||||
/// Translates domain events into Godot signals on the <see cref="EventBus"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The adapter half of ADR-0004. Aggregates raise plain records with no engine dependency; this is the
|
||||
/// single place that turns them into signals, which is what makes "state changed ⇒ signal fired"
|
||||
/// un-bypassable. Existing subscribers and scene wiring keep working unchanged.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Not a Node — it has no lifecycle of its own. The composition root owns one and hands it every
|
||||
/// batch of drained events.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ProgressionEventRepublisher
|
||||
{
|
||||
private readonly LevelCatalogue _catalogue;
|
||||
private readonly SceneTree _tree;
|
||||
|
||||
public ProgressionEventRepublisher(LevelCatalogue catalogue, SceneTree tree)
|
||||
{
|
||||
_catalogue = catalogue;
|
||||
_tree = tree;
|
||||
}
|
||||
|
||||
public void Publish(IReadOnlyList<ProgressionEvent> events)
|
||||
{
|
||||
foreach (var @event in events)
|
||||
Publish(@event);
|
||||
}
|
||||
|
||||
private void Publish(ProgressionEvent @event)
|
||||
{
|
||||
switch (@event)
|
||||
{
|
||||
case LevelStarted started:
|
||||
// The existing signal is keyed by array index because GhostManager names its replay
|
||||
// files ghost_level_{index}.json. Transitional: once ghosts key on LevelId this
|
||||
// becomes a straight pass-through and the index lookup goes away.
|
||||
EventBus.EmitLevelStarted(IndexOf(started.Level), _tree.CurrentScene);
|
||||
break;
|
||||
|
||||
case RunEnded:
|
||||
EventBus.EmitRunEnded();
|
||||
break;
|
||||
|
||||
case AttemptFailed failed:
|
||||
EventBus.EmitAttemptFailed(failed.Remaining.Value, failed.RunOver);
|
||||
break;
|
||||
|
||||
case AchievementUnlocked unlocked:
|
||||
EventBus.EmitAchievementUnlocked(unlocked.Achievement.Value);
|
||||
break;
|
||||
|
||||
case LivesChanged lives:
|
||||
EventBus.EmitLivesChanged(lives.Remaining.Value);
|
||||
break;
|
||||
|
||||
case TotalCoinsChanged coins:
|
||||
EventBus.EmitCoinsChanged(coins.Total.Value);
|
||||
break;
|
||||
|
||||
case SkillSetChanged:
|
||||
EventBus.EmitSkillSetChanged();
|
||||
break;
|
||||
|
||||
default:
|
||||
GD.PushWarning($"No signal is published for domain event {@event.GetType().Name}.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private int IndexOf(LevelId level)
|
||||
{
|
||||
for (var i = 0; i < _catalogue.Levels.Count; i++)
|
||||
if (_catalogue.Levels[i]?.Id == level.Value)
|
||||
return i;
|
||||
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
1
Autoloads/ProgressionEventRepublisher.cs.uid
Normal file
1
Autoloads/ProgressionEventRepublisher.cs.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cjm0g50la7uie
|
||||
219
Autoloads/ProgressionRoot.cs
Normal file
219
Autoloads/ProgressionRoot.cs
Normal file
@@ -0,0 +1,219 @@
|
||||
using System;
|
||||
using Godot;
|
||||
using Mr.BrickAdventures.Progression;
|
||||
using Mr.BrickAdventures.Progression.Adapters;
|
||||
using Mr.BrickAdventures.scripts.Resources;
|
||||
|
||||
namespace Mr.BrickAdventures.Autoloads;
|
||||
|
||||
/// <summary>
|
||||
/// Composition root for player progression: builds the stores, loads the Profile, and owns the
|
||||
/// <see cref="ProgressionService"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The only Node in this area. The aggregates are plain objects, so their lifetime is no longer tied
|
||||
/// to the scene tree — which is why the operations below need no null-guarding of the store itself.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every mutating operation is wrapped so that draining and republishing cannot be forgotten. That is
|
||||
/// the one risk of the DrainEvents design, and wrapping collapses it to this file.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public partial class ProgressionRoot : Node
|
||||
{
|
||||
public static ProgressionRoot Instance { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The one slot the game currently uses. Phase B replaces this with a picker over several
|
||||
/// Playthroughs; until then a fixed id keeps the file name stable across runs.
|
||||
/// </summary>
|
||||
public static readonly PlaythroughId SoleSlot =
|
||||
PlaythroughId.Of(new Guid("a17e0000-0000-4000-8000-000000000001"));
|
||||
|
||||
[Export] public LevelCatalogue Catalogue { get; set; }
|
||||
|
||||
/// <summary>Lives a new Playthrough starts with. A tuning value, so it is authored here rather than baked into the domain.</summary>
|
||||
[Export] public int StartingLives { get; set; } = 3;
|
||||
|
||||
private ProgressionService _service;
|
||||
private ProgressionEventRepublisher _republisher;
|
||||
|
||||
/// <summary>Levels in campaign order, as the domain sees them.</summary>
|
||||
public Campaign Campaign { get; private set; }
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
Instance = this;
|
||||
|
||||
if (Catalogue == null)
|
||||
{
|
||||
GD.PushError("ProgressionRoot has no LevelCatalogue assigned; progression is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var problem in Catalogue.Validate())
|
||||
GD.PushError($"LevelCatalogue: {problem}");
|
||||
|
||||
Campaign = Catalogue.ToCampaign();
|
||||
|
||||
var directory = ProjectSettings.GlobalizePath("user://");
|
||||
var playthroughs = new JsonPlaythroughStore(directory);
|
||||
var profiles = new JsonProfileStore(directory);
|
||||
|
||||
_service = new ProgressionService(
|
||||
LoadProfile(profiles), playthroughs, profiles,
|
||||
Campaign, BuildSkillCatalogue(), Lives.Of(StartingLives));
|
||||
|
||||
_republisher = new ProgressionEventRepublisher(Catalogue, GetTree());
|
||||
}
|
||||
|
||||
public override void _ExitTree()
|
||||
{
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A missing Profile is a first run. A corrupt one is reported loudly and replaced with an empty
|
||||
/// Profile so the game still starts — but the file is left alone, so it can still be recovered.
|
||||
/// </summary>
|
||||
private static Profile LoadProfile(IProfileStore profiles)
|
||||
{
|
||||
var result = profiles.Load();
|
||||
|
||||
if (result.Outcome == LoadOutcome.Corrupt)
|
||||
GD.PushError($"Profile could not be read and has been left untouched: {result.Problem}");
|
||||
|
||||
return result.Outcome == LoadOutcome.Loaded ? result.Value : Profile.Empty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the authored upgrade tables into the domain's view of them. A Skill with no upgrade
|
||||
/// entries yields an empty cost list, which the domain reports as not purchasable rather than
|
||||
/// throwing on <c>Upgrades[0]</c>.
|
||||
/// </summary>
|
||||
private static SkillCatalogue BuildSkillCatalogue()
|
||||
{
|
||||
var available = SkillManager.Instance?.AvailableSkills;
|
||||
if (available == null) return SkillCatalogue.Empty();
|
||||
|
||||
var costs = new System.Collections.Generic.Dictionary<SkillId, System.Collections.Generic.IReadOnlyList<Coins>>();
|
||||
|
||||
foreach (var skill in available)
|
||||
{
|
||||
if (skill == null || string.IsNullOrWhiteSpace(skill.Name)) continue;
|
||||
|
||||
var levels = new System.Collections.Generic.List<Coins>();
|
||||
foreach (var upgrade in skill.Upgrades)
|
||||
levels.Add(Coins.Of(Mathf.Max(0, upgrade.Cost)));
|
||||
|
||||
costs[SkillId.Of(skill.Name)] = levels;
|
||||
}
|
||||
|
||||
return SkillCatalogue.Of(costs);
|
||||
}
|
||||
|
||||
#region Operations — each drains and republishes, so callers cannot forget to
|
||||
|
||||
public void StartPlaythrough(PlaythroughId id) => Run(() => _service.StartPlaythrough(id));
|
||||
|
||||
public bool LoadPlaythrough(PlaythroughId id)
|
||||
{
|
||||
if (_service == null) return false;
|
||||
|
||||
var loaded = _service.LoadPlaythrough(id);
|
||||
Publish();
|
||||
return loaded;
|
||||
}
|
||||
|
||||
public LoadOutcome PeekPlaythrough(PlaythroughId id) =>
|
||||
_service?.PeekPlaythrough(id) ?? LoadOutcome.Absent;
|
||||
|
||||
public void RecordStat(StatName stat, int amount = 1) => _service?.RecordStat(stat, amount);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reads
|
||||
|
||||
public bool IsPlaying => _service?.IsPlaying ?? false;
|
||||
public bool CanEnter(LevelId level) => _service?.CanEnter(level) ?? false;
|
||||
public LevelId? CurrentLevel => _service?.CurrentLevel;
|
||||
public Coins TotalCoins => _service?.TotalCoins ?? Coins.Zero;
|
||||
public Lives Lives => _service?.Lives ?? Lives.Of(0);
|
||||
public bool IsRunOver => _service?.IsRunOver ?? false;
|
||||
public SkillLevel? LevelOf(SkillId skill) => _service?.LevelOf(skill);
|
||||
public bool IsOwned(SkillId skill) => _service?.IsOwned(skill) ?? false;
|
||||
public bool CanAfford(SkillId skill) => _service?.CanAfford(skill) ?? false;
|
||||
public bool HasCleared(LevelId level) => _service?.HasCleared(level) ?? false;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Operations, continued
|
||||
|
||||
public bool EnterLevel(LevelId level)
|
||||
{
|
||||
if (_service == null) return false;
|
||||
|
||||
var entered = _service.EnterLevel(level);
|
||||
Publish();
|
||||
return entered;
|
||||
}
|
||||
|
||||
public void RecordCoinsFound(Coins amount) => Run(() => _service.RecordCoinsFound(amount));
|
||||
|
||||
public void HandlePlayerDeath() => Run(() => _service.HandlePlayerDeath());
|
||||
|
||||
public void FinishLevel(LevelTime? time) => Run(() => _service.FinishLevel(time));
|
||||
|
||||
public PurchaseOutcome PurchaseSkill(SkillId skill)
|
||||
{
|
||||
if (_service == null) return PurchaseOutcome.NotPlaying;
|
||||
|
||||
var outcome = _service.PurchaseSkill(skill);
|
||||
Publish();
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/// <summary>Free unlock, as a pickup gives. Returns false if it was already owned.</summary>
|
||||
public bool GrantSkill(SkillId skill)
|
||||
{
|
||||
if (_service == null) return false;
|
||||
|
||||
var granted = _service.GrantSkill(skill);
|
||||
Publish();
|
||||
return granted;
|
||||
}
|
||||
|
||||
/// <summary>Takes a Skill away — for Skills that consume themselves.</summary>
|
||||
public bool RevokeSkill(SkillId skill)
|
||||
{
|
||||
if (_service == null) return false;
|
||||
|
||||
var revoked = _service.RevokeSkill(skill);
|
||||
Publish();
|
||||
return revoked;
|
||||
}
|
||||
|
||||
/// <summary>Discards the current Attempt without charging a life — leaving a Level, not dying.</summary>
|
||||
public void AbandonAttempt() => Run(() => _service.AbandonAttempt());
|
||||
|
||||
/// <summary>Console only — see <see cref="ProgressionService.ApplyCheat"/>.</summary>
|
||||
public void ApplyCheat(Func<PlaythroughSnapshot, PlaythroughSnapshot> change) =>
|
||||
Run(() => _service.ApplyCheat(change));
|
||||
|
||||
/// <summary>Whether there is a resumable Playthrough, for enabling the Continue button.</summary>
|
||||
public bool HasSavedPlaythrough =>
|
||||
PeekPlaythrough(SoleSlot) == LoadOutcome.Loaded;
|
||||
|
||||
private void Run(System.Action operation)
|
||||
{
|
||||
if (_service == null) return;
|
||||
|
||||
operation();
|
||||
Publish();
|
||||
}
|
||||
|
||||
private void Publish() => _republisher?.Publish(_service.DrainEvents());
|
||||
|
||||
#endregion
|
||||
}
|
||||
1
Autoloads/ProgressionRoot.cs.uid
Normal file
1
Autoloads/ProgressionRoot.cs.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b6amqp58yjfc4
|
||||
@@ -1,22 +1,40 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Godot;
|
||||
using Godot.Collections;
|
||||
using Mr.BrickAdventures.Progression;
|
||||
using Mr.BrickAdventures.scripts.components;
|
||||
using Mr.BrickAdventures.scripts.interfaces;
|
||||
using Mr.BrickAdventures.scripts.Resources;
|
||||
|
||||
namespace Mr.BrickAdventures.Autoloads;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the live skill components attached to the player, and keeps them matching what the Playthrough
|
||||
/// says is owned.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Reconciles from <see cref="EventBus.SkillSetChanged"/> rather than being told by each caller. The old
|
||||
/// arrangement had four call sites doing "record it in the store, then tell SkillManager" as two
|
||||
/// separate calls — anything between them, including <c>AddSkill</c>'s own early return when no player
|
||||
/// is registered, left a skill owned but inert.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Tracks the level each component was built at, so an upgrade rebuilds it. Previously
|
||||
/// <c>AddSkill</c> returned early for an already-active skill, and the upgrade was applied by the
|
||||
/// purchase code reaching into the component instead.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public partial class SkillManager : Node
|
||||
{
|
||||
private PlayerController _player;
|
||||
private GameStateStore Store => GameStateStore.Instance;
|
||||
|
||||
[Export] public Array<SkillData> AvailableSkills { get; set; } = [];
|
||||
|
||||
public Dictionary ActiveComponents { get; private set; } = new();
|
||||
|
||||
private readonly System.Collections.Generic.Dictionary<string, int> _activeLevels = new();
|
||||
|
||||
public static SkillManager Instance { get; private set; }
|
||||
|
||||
[Signal]
|
||||
@@ -27,10 +45,14 @@ public partial class SkillManager : Node
|
||||
public override void _Ready()
|
||||
{
|
||||
Instance = this;
|
||||
EventBus.Instance.SkillSetChanged += Reconcile;
|
||||
}
|
||||
|
||||
public override void _ExitTree()
|
||||
{
|
||||
if (EventBus.Instance != null)
|
||||
EventBus.Instance.SkillSetChanged -= Reconcile;
|
||||
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
@@ -41,24 +63,17 @@ public partial class SkillManager : Node
|
||||
{
|
||||
if (_player == player) return;
|
||||
|
||||
// If a player is already registered (e.g., from a previous scene), unregister it first.
|
||||
if (_player != null && IsInstanceValid(_player))
|
||||
{
|
||||
UnregisterPlayer();
|
||||
}
|
||||
|
||||
_player = player;
|
||||
if (_player != null)
|
||||
{
|
||||
// Automatically unregister when the player node is removed from the scene.
|
||||
_player.TreeExiting += UnregisterPlayer;
|
||||
ApplyUnlockedSkills();
|
||||
Reconcile();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up skills and references related to the current player.
|
||||
/// </summary>
|
||||
private void UnregisterPlayer()
|
||||
{
|
||||
if (_player != null && IsInstanceValid(_player))
|
||||
@@ -69,9 +84,37 @@ public partial class SkillManager : Node
|
||||
_player = null;
|
||||
}
|
||||
|
||||
public void AddSkill(SkillData skillData)
|
||||
/// <summary>
|
||||
/// Brings the live components in line with what is owned: adds what is missing, rebuilds anything
|
||||
/// whose level changed, removes what is no longer owned.
|
||||
/// </summary>
|
||||
public void Reconcile()
|
||||
{
|
||||
if (_player == null || !IsInstanceValid(_player)) return;
|
||||
|
||||
var progression = ProgressionRoot.Instance;
|
||||
if (progression == null) return;
|
||||
|
||||
foreach (var skill in AvailableSkills)
|
||||
{
|
||||
if (skill == null || string.IsNullOrWhiteSpace(skill.Name)) continue;
|
||||
|
||||
var owned = progression.LevelOf(SkillId.Of(skill.Name))?.Value;
|
||||
var active = _activeLevels.TryGetValue(skill.Name, out var at) ? at : (int?)null;
|
||||
|
||||
if (owned == active) continue;
|
||||
|
||||
if (active is not null) RemoveSkill(skill.Name);
|
||||
if (owned is not null) AddSkill(skill, owned.Value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Kept for the marketplace's activate/deactivate toggle, which is a display concern.</summary>
|
||||
public void AddSkill(SkillData skillData) =>
|
||||
AddSkill(skillData, ProgressionRoot.Instance?.LevelOf(SkillId.Of(skillData.Name))?.Value ?? 1);
|
||||
|
||||
private void AddSkill(SkillData skillData, int level)
|
||||
{
|
||||
// Ensure a valid player is registered before adding a skill.
|
||||
if (_player == null || !IsInstanceValid(_player))
|
||||
{
|
||||
GD.Print("SkillManager: Player not available to add skill.");
|
||||
@@ -83,10 +126,9 @@ public partial class SkillManager : Node
|
||||
|
||||
if (skillData.Type == SkillType.Throw)
|
||||
{
|
||||
// Remove any other active throw skill before adding the new one
|
||||
foreach (var sd in AvailableSkills)
|
||||
{
|
||||
if (sd != skillData && sd.Type == SkillType.Throw)
|
||||
if (sd != skillData && sd?.Type == SkillType.Throw)
|
||||
RemoveSkill(sd.Name);
|
||||
}
|
||||
}
|
||||
@@ -94,8 +136,7 @@ public partial class SkillManager : Node
|
||||
var instance = skillData.Node.Instantiate();
|
||||
if (instance is ISkill skill)
|
||||
{
|
||||
// Initialize the skill with the registered player instance.
|
||||
skill.Initialize(_player, skillData);
|
||||
skill.Initialize(_player, skillData, level);
|
||||
skill.Activate();
|
||||
}
|
||||
else
|
||||
@@ -105,31 +146,27 @@ public partial class SkillManager : Node
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the skill node as a child of the player.
|
||||
_player.AddChild(instance);
|
||||
ActiveComponents[skillData.Name] = instance;
|
||||
_activeLevels[skillData.Name] = level;
|
||||
|
||||
if (instance is BrickThrowComponent btc)
|
||||
{
|
||||
EmitSignalActiveThrowSkillChanged(btc);
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveSkill(string skillName)
|
||||
{
|
||||
_activeLevels.Remove(skillName);
|
||||
|
||||
if (!ActiveComponents.TryGetValue(skillName, out var component))
|
||||
return;
|
||||
|
||||
if (component.AsGodotObject() is BrickThrowComponent)
|
||||
{
|
||||
EmitSignalActiveThrowSkillChanged(null);
|
||||
}
|
||||
|
||||
var inst = (Node)component;
|
||||
if (inst is ISkill skill)
|
||||
{
|
||||
skill.Deactivate();
|
||||
}
|
||||
|
||||
if (IsInstanceValid(inst))
|
||||
inst.QueueFree();
|
||||
@@ -141,56 +178,30 @@ public partial class SkillManager : Node
|
||||
|
||||
private void RemoveAllActiveSkills()
|
||||
{
|
||||
// Create a copy of keys to avoid modification during iteration
|
||||
var keys = ActiveComponents.Keys.ToArray();
|
||||
var skillNames = keys.Select(key => key.ToString()).ToList();
|
||||
|
||||
foreach (var skillName in skillNames)
|
||||
{
|
||||
foreach (var skillName in ActiveComponents.Keys.Select(k => k.ToString()).ToList())
|
||||
RemoveSkill(skillName);
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyUnlockedSkills()
|
||||
{
|
||||
if (_player == null || !IsInstanceValid(_player)) return;
|
||||
if (Store == null) return;
|
||||
|
||||
foreach (var sd in AvailableSkills)
|
||||
{
|
||||
if (Store.IsSkillUnlocked(sd))
|
||||
CallDeferred(MethodName.AddSkill, sd);
|
||||
else
|
||||
RemoveSkill(sd.Name);
|
||||
}
|
||||
}
|
||||
|
||||
public SkillData GetSkillByName(string skillName)
|
||||
{
|
||||
foreach (var sd in AvailableSkills)
|
||||
if (sd.Name == skillName) return sd;
|
||||
if (sd?.Name == skillName) return sd;
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool IsSkillActive(SkillData skill)
|
||||
{
|
||||
return skill != null && ActiveComponents.ContainsKey(skill.Name);
|
||||
}
|
||||
public bool IsSkillActive(SkillData skill) =>
|
||||
skill != null && ActiveComponents.ContainsKey(skill.Name);
|
||||
|
||||
public void ActivateSkill(SkillData skill)
|
||||
{
|
||||
if (!ActiveComponents.ContainsKey(skill.Name))
|
||||
{
|
||||
AddSkill(skill);
|
||||
}
|
||||
}
|
||||
|
||||
public void DeactivateSkill(SkillData skill)
|
||||
{
|
||||
if (ActiveComponents.ContainsKey(skill.Name))
|
||||
{
|
||||
RemoveSkill(skill.Name);
|
||||
}
|
||||
}
|
||||
|
||||
public void ToggleSkillActivation(SkillData skill)
|
||||
|
||||
103
CONTEXT.md
Normal file
103
CONTEXT.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Mr. Brick Adventures
|
||||
|
||||
A 2D platformer. This context covers **player state and progression** — what the player has, what they have done,
|
||||
and at what point each of those becomes permanent. Gameplay behaviour (movement, combat, hazards) lives in the
|
||||
composable components and is deliberately outside this context.
|
||||
|
||||
## Language
|
||||
|
||||
### Scopes
|
||||
|
||||
Three lifetimes. Every fact in this context belongs to exactly one.
|
||||
|
||||
**Attempt**:
|
||||
One try at a level, from entering it to either completing it or losing a life. Holds a ledger of everything that
|
||||
happened during the try — coins found, coins spent, skills bought. Discarded whole if the player dies.
|
||||
_Avoid_: Session, run, life
|
||||
|
||||
**Playthrough**:
|
||||
One game from New Game until game over, identified by a `PlaythroughId`. Holds lives, banked coins, owned skills
|
||||
and their levels, campaign position, and which levels it has cleared. Several exist at once and the player picks
|
||||
between them.
|
||||
_Avoid_: Save, save slot, progress, game, run
|
||||
|
||||
**Profile**:
|
||||
Everything shared across all Playthroughs, one per installation. Achievements, best time per Level, and lifetime
|
||||
statistics. Never reset by starting a new Playthrough.
|
||||
_Avoid_: Global state, meta, persistent data, account
|
||||
|
||||
### Operations
|
||||
|
||||
**Settle**:
|
||||
Resolving an ended Attempt against the Playthrough — Commit it on a First Clear, Discard it otherwise. Settling is
|
||||
the only way an Attempt affects a Playthrough, and it decides First Clear versus Replay itself rather than being
|
||||
told which one happened.
|
||||
_Avoid_: Apply, resolve, finalise
|
||||
|
||||
**Commit**:
|
||||
Applying an Attempt's ledger to the Playthrough. Only a First Clear commits — nothing else, ever.
|
||||
_Avoid_: Save, persist, flush, sync
|
||||
|
||||
**First Clear**:
|
||||
Completing a Level this Playthrough has not cleared before. The only event that commits an Attempt and advances
|
||||
campaign position.
|
||||
_Avoid_: Level complete, finish (both ambiguous — they also describe a Replay)
|
||||
|
||||
**Replay**:
|
||||
Completing a Level this Playthrough has already cleared. Records Best Time, achievements and statistics to the
|
||||
Profile; commits nothing and never advances campaign position.
|
||||
_Avoid_: Practice, retry, second run
|
||||
|
||||
**Bank**:
|
||||
The coins a Playthrough holds, as opposed to coins found in the current Attempt. Coins are banked by committing,
|
||||
never on pickup.
|
||||
_Avoid_: Total coins, saved coins
|
||||
|
||||
**Discard**:
|
||||
Throwing away an Attempt's ledger — on death, or at the end of a Replay. Purchases made during the Attempt are
|
||||
undone by discarding, not refunded; under the ledger they never happened.
|
||||
_Avoid_: Reset, rollback, revert
|
||||
|
||||
### Things
|
||||
|
||||
**Level**:
|
||||
One playable stage, identified by a stable `LevelId` — never by position in a list. Knows its scene and display
|
||||
name. Ids must survive inserting and reordering levels, because Playthroughs and Best Times both reference them.
|
||||
_Avoid_: Stage, scene, level index
|
||||
|
||||
**Campaign**:
|
||||
The authored ordering of Levels that defines progression — which Level is first, and what follows a given one. One
|
||||
Campaign is shared by every Playthrough: it is authored data, not per-Playthrough state, and it carries no scene
|
||||
or presentation detail.
|
||||
_Avoid_: Level list, level order, world, chapter
|
||||
|
||||
**Cleared Levels**:
|
||||
The Levels a Playthrough has completed at least once. Gates both campaign progression and level select; whether a
|
||||
Level is reachable is computed from this, never stored alongside it.
|
||||
_Avoid_: Unlocked levels, completed levels, available levels
|
||||
|
||||
**Skill**:
|
||||
An ability the player can own and upgrade. `SkillData` is the authored asset describing one (name, icon, upgrade
|
||||
table) and is immutable at runtime; the level the player has reached in it belongs to the Playthrough.
|
||||
_Avoid_: Ability, power-up, upgrade (for the skill itself)
|
||||
|
||||
**Movement Ability**:
|
||||
A composable behaviour attached to the player controller (jump, wall-jump, gravity). Unrelated to Skill —
|
||||
authored per movement preset, not owned, not upgraded, not persisted.
|
||||
_Avoid_: Skill, movement skill
|
||||
|
||||
**Best Time**:
|
||||
The fastest recorded completion of a Level, from a First Clear or a Replay alike. Belongs to the Profile, so it
|
||||
survives New Game and is shared by every Playthrough. Stored alongside, but distinct from, the ghost replay
|
||||
frames that produced it.
|
||||
_Avoid_: Personal best, high score, record
|
||||
|
||||
## Rules
|
||||
|
||||
- Reads may cross scopes freely — affordability spans Attempt and Playthrough. Writes must not.
|
||||
- Derived facts are computed, never stored. Level reachability is derived from Cleared Levels; total coins from
|
||||
Attempt plus Bank. Storing either alongside its source creates the second source of truth this context exists
|
||||
to prevent.
|
||||
- Achievements are mirrored to Steam, which is per-account and cannot be un-earned. That is why they are Profile
|
||||
and not Playthrough: a per-Playthrough achievement list would contradict Steam permanently.
|
||||
- Nothing in this context may reference Godot. Its correctness must be checkable without a scene tree.
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Godot.NET.Sdk/4.6.1">
|
||||
<Project Sdk="Godot.NET.Sdk/4.7.1">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||
@@ -9,4 +9,17 @@
|
||||
<PackageReference Include="Facepunch.Steamworks.Dlls" Version="2.3.2" />
|
||||
<PackageReference Include="Facepunch.Steamworks.Library" Version="2.3.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Godot.NET.Sdk globs **/*.cs, which would compile the progression projects into this
|
||||
assembly as well as their own. They are separate projects; exclude them here. -->
|
||||
<ItemGroup>
|
||||
<Compile Remove="progression\**" />
|
||||
<Compile Remove="progression.tests\**" />
|
||||
<Compile Remove="progression.adapters\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="progression\Mr.BrickAdventures.Progression.csproj" />
|
||||
<ProjectReference Include="progression.adapters\Mr.BrickAdventures.Progression.Adapters.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,19 +1,139 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio 2012
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr. Brick Adventures", "Mr. Brick Adventures.csproj", "{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "progression", "progression", "{73CE8E92-AE0D-34EB-F3CD-A6392CC7A1AA}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr.BrickAdventures.Progression", "progression\Mr.BrickAdventures.Progression.csproj", "{4F115C37-24F6-4933-9086-ED5925EE5106}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "progression.tests", "progression.tests", "{B756B2BA-9E41-468E-DBBE-5BA8CC0A1E41}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr.BrickAdventures.Progression.Tests", "progression.tests\Mr.BrickAdventures.Progression.Tests.csproj", "{A4660883-E42A-4D04-99A3-770F36E9D861}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "progression.adapters", "progression.adapters", "{9D5C8041-6C54-0A01-82AE-FC7E0DE079DA}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr.BrickAdventures.Progression.Adapters", "progression.adapters\Mr.BrickAdventures.Progression.Adapters.csproj", "{045D9BF4-8E63-4F14-84F4-F8C55073244C}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
ExportDebug|Any CPU = ExportDebug|Any CPU
|
||||
ExportRelease|Any CPU = ExportRelease|Any CPU
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
ExportDebug|Any CPU = ExportDebug|Any CPU
|
||||
ExportDebug|x64 = ExportDebug|x64
|
||||
ExportDebug|x86 = ExportDebug|x86
|
||||
ExportRelease|Any CPU = ExportRelease|Any CPU
|
||||
ExportRelease|x64 = ExportRelease|x64
|
||||
ExportRelease|x86 = ExportRelease|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|Any CPU.ActiveCfg = ExportDebug|Any CPU
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|Any CPU.Build.0 = ExportDebug|Any CPU
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|x64.ActiveCfg = ExportDebug|Any CPU
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|x64.Build.0 = ExportDebug|Any CPU
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|x86.ActiveCfg = ExportDebug|Any CPU
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|x86.Build.0 = ExportDebug|Any CPU
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|Any CPU.ActiveCfg = ExportRelease|Any CPU
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|Any CPU.Build.0 = ExportRelease|Any CPU
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|x64.ActiveCfg = ExportRelease|Any CPU
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|x64.Build.0 = ExportRelease|Any CPU
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|x86.ActiveCfg = ExportRelease|Any CPU
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|x86.Build.0 = ExportRelease|Any CPU
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|x86.Build.0 = Release|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|x64.Build.0 = Debug|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|x86.Build.0 = Debug|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|x64.ActiveCfg = Debug|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|x64.Build.0 = Debug|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|x86.ActiveCfg = Debug|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|x86.Build.0 = Debug|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|x64.Build.0 = Debug|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|x86.Build.0 = Debug|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|x64.ActiveCfg = Debug|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|x64.Build.0 = Debug|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|x86.ActiveCfg = Debug|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|x86.Build.0 = Debug|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|x86.Build.0 = Release|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|x64.ActiveCfg = Debug|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|x64.Build.0 = Debug|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|x86.ActiveCfg = Debug|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|x86.Build.0 = Debug|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|Any CPU.Build.0 = Debug|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|x64.ActiveCfg = Debug|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|x64.Build.0 = Debug|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|x86.ActiveCfg = Debug|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|x86.Build.0 = Debug|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{4F115C37-24F6-4933-9086-ED5925EE5106} = {73CE8E92-AE0D-34EB-F3CD-A6392CC7A1AA}
|
||||
{A4660883-E42A-4D04-99A3-770F36E9D861} = {B756B2BA-9E41-468E-DBBE-5BA8CC0A1E41}
|
||||
{045D9BF4-8E63-4F14-84F4-F8C55073244C} = {9D5C8041-6C54-0A01-82AE-FC7E0DE079DA}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
131
addons/ca_level_generator/CaGenerator.cs
Normal file
131
addons/ca_level_generator/CaGenerator.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
|
||||
namespace Mr.BrickAdventures.Tools.CaLevelGenerator;
|
||||
|
||||
public static class CaGenerator
|
||||
{
|
||||
public static bool[,] Generate(CaGeneratorSettings s)
|
||||
{
|
||||
var rng = new Random(s.Seed);
|
||||
return s.Mode switch
|
||||
{
|
||||
CaMode.Cave => GenerateCave(s, rng),
|
||||
CaMode.Platform => GeneratePlatform(s, rng),
|
||||
CaMode.Terrain => GenerateTerrain(s, rng),
|
||||
_ => GenerateCave(s, rng),
|
||||
};
|
||||
}
|
||||
|
||||
public static bool[,] Smooth(bool[,] grid) => SmoothPass(grid, 5);
|
||||
|
||||
// ── Cave ──────────────────────────────────────────────────────────────
|
||||
private static bool[,] GenerateCave(CaGeneratorSettings s, Random rng)
|
||||
{
|
||||
var grid = RandomFill(s.Width, s.Height, s.FillDensity, rng);
|
||||
if (s.BorderWalls) EnforceBorder(grid);
|
||||
for (int i = 0; i < s.SmoothingPasses; i++)
|
||||
{
|
||||
grid = SmoothPass(grid, 5);
|
||||
if (s.BorderWalls) EnforceBorder(grid);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
// ── Platform ──────────────────────────────────────────────────────────
|
||||
private static bool[,] GeneratePlatform(CaGeneratorSettings s, Random rng)
|
||||
{
|
||||
int w = s.Width, h = s.Height;
|
||||
var grid = new bool[w, h];
|
||||
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
grid[x, h - 1] = true;
|
||||
grid[x, h - 2] = true;
|
||||
|
||||
for (int y = 0; y < h - 2; y++)
|
||||
{
|
||||
// Density linearly increases from 10% at top to 60% at bottom,
|
||||
// scaled by user's FillDensity relative to default 0.48.
|
||||
float t = (float)y / (h - 3);
|
||||
float rowDensity = (0.1f + t * 0.5f) * (s.FillDensity / 0.48f);
|
||||
grid[x, y] = rng.NextDouble() < rowDensity;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < s.SmoothingPasses; i++)
|
||||
{
|
||||
// Threshold 4 (not 5): horizontal platform groups survive, isolated
|
||||
// vertical pixels collapse into flat platforms.
|
||||
grid = SmoothPass(grid, 4);
|
||||
for (int x = 0; x < w; x++) { grid[x, h - 1] = true; grid[x, h - 2] = true; }
|
||||
}
|
||||
|
||||
return grid;
|
||||
}
|
||||
|
||||
// ── Terrain ───────────────────────────────────────────────────────────
|
||||
private static bool[,] GenerateTerrain(CaGeneratorSettings s, Random rng)
|
||||
{
|
||||
int w = s.Width, h = s.Height;
|
||||
var grid = new bool[w, h];
|
||||
|
||||
// Start terrain surface at ~60% from bottom (40% from top)
|
||||
int surfaceRow = (int)(h * 0.4f);
|
||||
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int delta = rng.Next(-2, 3);
|
||||
surfaceRow = Math.Clamp(surfaceRow + delta, h / 6, h - 2);
|
||||
|
||||
for (int y = surfaceRow; y < h; y++)
|
||||
grid[x, y] = true;
|
||||
}
|
||||
|
||||
for (int i = 0; i < s.SmoothingPasses; i++)
|
||||
grid = SmoothPass(grid, 5);
|
||||
|
||||
return grid;
|
||||
}
|
||||
|
||||
// ── Shared helpers ────────────────────────────────────────────────
|
||||
private static bool[,] RandomFill(int w, int h, float density, Random rng)
|
||||
{
|
||||
var grid = new bool[w, h];
|
||||
for (int x = 0; x < w; x++)
|
||||
for (int y = 0; y < h; y++)
|
||||
grid[x, y] = rng.NextDouble() < density;
|
||||
return grid;
|
||||
}
|
||||
|
||||
private static bool[,] SmoothPass(bool[,] grid, int threshold)
|
||||
{
|
||||
int w = grid.GetLength(0), h = grid.GetLength(1);
|
||||
var next = new bool[w, h];
|
||||
for (int x = 0; x < w; x++)
|
||||
for (int y = 0; y < h; y++)
|
||||
next[x, y] = CountSolidNeighbors(grid, x, y) >= threshold;
|
||||
return next;
|
||||
}
|
||||
|
||||
private static int CountSolidNeighbors(bool[,] grid, int cx, int cy)
|
||||
{
|
||||
int w = grid.GetLength(0), h = grid.GetLength(1), count = 0;
|
||||
for (int dx = -1; dx <= 1; dx++)
|
||||
for (int dy = -1; dy <= 1; dy++)
|
||||
{
|
||||
if (dx == 0 && dy == 0) continue;
|
||||
int nx = cx + dx, ny = cy + dy;
|
||||
// Out-of-bounds counts as solid (closes cave edges naturally)
|
||||
if (nx < 0 || nx >= w || ny < 0 || ny >= h) count++;
|
||||
else if (grid[nx, ny]) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static void EnforceBorder(bool[,] grid)
|
||||
{
|
||||
int w = grid.GetLength(0), h = grid.GetLength(1);
|
||||
for (int x = 0; x < w; x++) { grid[x, 0] = true; grid[x, h - 1] = true; }
|
||||
for (int y = 0; y < h; y++) { grid[0, y] = true; grid[w - 1, y] = true; }
|
||||
}
|
||||
}
|
||||
1
addons/ca_level_generator/CaGenerator.cs.uid
Normal file
1
addons/ca_level_generator/CaGenerator.cs.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://exr10b7nmkgn
|
||||
23
addons/ca_level_generator/CaGeneratorSettings.cs
Normal file
23
addons/ca_level_generator/CaGeneratorSettings.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using Godot;
|
||||
|
||||
namespace Mr.BrickAdventures.Tools.CaLevelGenerator;
|
||||
|
||||
public enum CaMode { Cave, Platform, Terrain }
|
||||
|
||||
public class CaGeneratorSettings
|
||||
{
|
||||
public CaMode Mode { get; set; } = CaMode.Cave;
|
||||
public int Width { get; set; } = 40;
|
||||
public int Height { get; set; } = 22;
|
||||
public Vector2I Offset { get; set; } = Vector2I.Zero;
|
||||
public float FillDensity { get; set; } = 0.48f;
|
||||
public int SmoothingPasses { get; set; } = 4;
|
||||
public bool BorderWalls { get; set; } = true;
|
||||
public int Seed { get; set; } = 12345;
|
||||
// TerrainSet == -1 → raw tile mode
|
||||
public int TerrainSet { get; set; } = 0;
|
||||
public int Terrain { get; set; } = 0;
|
||||
// Used only when TerrainSet == -1
|
||||
public int SourceId { get; set; } = 0;
|
||||
public Vector2I AtlasCoords { get; set; } = Vector2I.Zero;
|
||||
}
|
||||
1
addons/ca_level_generator/CaGeneratorSettings.cs.uid
Normal file
1
addons/ca_level_generator/CaGeneratorSettings.cs.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://isnscgr1thvc
|
||||
79
addons/ca_level_generator/CaGeneratorTestRunner.cs
Normal file
79
addons/ca_level_generator/CaGeneratorTestRunner.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
#if TOOLS
|
||||
using Godot;
|
||||
|
||||
namespace Mr.BrickAdventures.Tools.CaLevelGenerator;
|
||||
|
||||
[Tool]
|
||||
public partial class CaGeneratorTestRunner : Node
|
||||
{
|
||||
public override void _Ready()
|
||||
{
|
||||
int pass = 0, fail = 0;
|
||||
|
||||
void Assert(bool cond, string msg)
|
||||
{
|
||||
if (cond) { GD.Print($" PASS: {msg}"); pass++; }
|
||||
else { GD.PrintErr($" FAIL: {msg}"); fail++; }
|
||||
}
|
||||
|
||||
GD.Print("=== CaGenerator Tests ===");
|
||||
|
||||
// Grid dimensions
|
||||
var s = new CaGeneratorSettings { Width = 20, Height = 15, SmoothingPasses = 0, Seed = 42 };
|
||||
var g = CaGenerator.Generate(s);
|
||||
Assert(g.GetLength(0) == 20, "Width == 20");
|
||||
Assert(g.GetLength(1) == 15, "Height == 15");
|
||||
|
||||
// Determinism
|
||||
var g2 = CaGenerator.Generate(s);
|
||||
bool det = true;
|
||||
for (int x = 0; x < 20 && det; x++)
|
||||
for (int y = 0; y < 15 && det; y++)
|
||||
if (g[x, y] != g2[x, y]) det = false;
|
||||
Assert(det, "Same seed → same output");
|
||||
|
||||
// Different seeds → different output
|
||||
var s2 = new CaGeneratorSettings { Width = 20, Height = 15, SmoothingPasses = 0, Seed = 99 };
|
||||
var g3 = CaGenerator.Generate(s2);
|
||||
bool diff = false;
|
||||
for (int x = 0; x < 20 && !diff; x++)
|
||||
for (int y = 0; y < 15 && !diff; y++)
|
||||
if (g[x, y] != g3[x, y]) diff = true;
|
||||
Assert(diff, "Different seeds → different output");
|
||||
|
||||
// Cave border walls
|
||||
var sb = new CaGeneratorSettings { Width = 20, Height = 15, BorderWalls = true, SmoothingPasses = 0, Seed = 42 };
|
||||
var gb = CaGenerator.Generate(sb);
|
||||
bool border = true;
|
||||
for (int x = 0; x < 20; x++) if (!gb[x, 0] || !gb[x, 14]) { border = false; break; }
|
||||
for (int y = 0; y < 15; y++) if (!gb[0, y] || !gb[19, y]) { border = false; break; }
|
||||
Assert(border, "Cave: border cells all solid");
|
||||
|
||||
// Platform floor
|
||||
var sp = new CaGeneratorSettings { Mode = CaMode.Platform, Width = 20, Height = 15, SmoothingPasses = 0, Seed = 42 };
|
||||
var gp = CaGenerator.Generate(sp);
|
||||
bool floor = true;
|
||||
for (int x = 0; x < 20; x++) if (!gp[x, 14] || !gp[x, 13]) { floor = false; break; }
|
||||
Assert(floor, "Platform: bottom 2 rows solid");
|
||||
|
||||
// Platform: top row not all solid (mostly empty at top)
|
||||
int topSolid = 0;
|
||||
for (int x = 0; x < 20; x++) if (gp[x, 0]) topSolid++;
|
||||
Assert(topSolid < 15, "Platform: top row mostly empty");
|
||||
|
||||
// Terrain: bottom always solid
|
||||
var st = new CaGeneratorSettings { Mode = CaMode.Terrain, Width = 20, Height = 15, SmoothingPasses = 0, Seed = 42 };
|
||||
var gt = CaGenerator.Generate(st);
|
||||
bool terrBottom = true;
|
||||
for (int x = 0; x < 20; x++) if (!gt[x, 14]) { terrBottom = false; break; }
|
||||
Assert(terrBottom, "Terrain: bottom row always solid");
|
||||
|
||||
// Smooth: output same dimensions
|
||||
var smoothed = CaGenerator.Smooth(g);
|
||||
Assert(smoothed.GetLength(0) == 20, "Smooth: width preserved");
|
||||
Assert(smoothed.GetLength(1) == 15, "Smooth: height preserved");
|
||||
|
||||
GD.Print($"=== Results: {pass} passed, {fail} failed ===");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
1
addons/ca_level_generator/CaGeneratorTestRunner.cs.uid
Normal file
1
addons/ca_level_generator/CaGeneratorTestRunner.cs.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://6y4snwf31boh
|
||||
360
addons/ca_level_generator/CaLevelGeneratorDock.cs
Normal file
360
addons/ca_level_generator/CaLevelGeneratorDock.cs
Normal file
@@ -0,0 +1,360 @@
|
||||
#if TOOLS
|
||||
using Godot;
|
||||
|
||||
namespace Mr.BrickAdventures.Tools.CaLevelGenerator;
|
||||
|
||||
[Tool]
|
||||
public partial class CaLevelGeneratorDock : Control
|
||||
{
|
||||
public CaLevelGeneratorPlugin Plugin { get; set; }
|
||||
|
||||
// Controls referenced in Task 8 wiring
|
||||
private OptionButton _layerDropdown;
|
||||
private OptionButton _terrainDropdown;
|
||||
private HBoxContainer _rawTileRow;
|
||||
private SpinBox _sourceIdSpin;
|
||||
private SpinBox _atlasXSpin;
|
||||
private SpinBox _atlasYSpin;
|
||||
private Button _caveBtn;
|
||||
private Button _platformBtn;
|
||||
private Button _terrainBtn;
|
||||
private SpinBox _widthSpin;
|
||||
private SpinBox _heightSpin;
|
||||
private SpinBox _offsetXSpin;
|
||||
private SpinBox _offsetYSpin;
|
||||
private HSlider _densitySlider;
|
||||
private Label _densityLabel;
|
||||
private HSlider _smoothPassSlider;
|
||||
private Label _smoothPassLabel;
|
||||
private CheckBox _borderCheck;
|
||||
private SpinBox _seedSpin;
|
||||
private Button _genBtn;
|
||||
private Button _smoothBtn;
|
||||
private Button _clearBtn;
|
||||
|
||||
private CaMode _mode = CaMode.Cave;
|
||||
|
||||
private System.Collections.Generic.List<TileMapLayer> _layers = new();
|
||||
private TileMapLayer _selectedLayer;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
CustomMinimumSize = new Vector2(200, 0);
|
||||
var scroll = new ScrollContainer();
|
||||
scroll.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||
scroll.SizeFlagsVertical = SizeFlags.ExpandFill;
|
||||
AddChild(scroll);
|
||||
|
||||
var vbox = new VBoxContainer();
|
||||
vbox.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||
scroll.AddChild(vbox);
|
||||
|
||||
// Target Layer
|
||||
vbox.AddChild(new Label { Text = "Target Layer" });
|
||||
_layerDropdown = new OptionButton();
|
||||
_layerDropdown.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||
vbox.AddChild(_layerDropdown);
|
||||
|
||||
// Terrain
|
||||
vbox.AddChild(new Label { Text = "Terrain" });
|
||||
_terrainDropdown = new OptionButton();
|
||||
_terrainDropdown.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||
vbox.AddChild(_terrainDropdown);
|
||||
|
||||
// Raw tile row (hidden when terrain is available)
|
||||
_rawTileRow = new HBoxContainer();
|
||||
_rawTileRow.Visible = false;
|
||||
_rawTileRow.AddChild(new Label { Text = "Src" });
|
||||
_sourceIdSpin = new SpinBox { MinValue = 0, MaxValue = 99, Value = 0 };
|
||||
_rawTileRow.AddChild(_sourceIdSpin);
|
||||
_rawTileRow.AddChild(new Label { Text = "X" });
|
||||
_atlasXSpin = new SpinBox { MinValue = 0, MaxValue = 99, Value = 0 };
|
||||
_rawTileRow.AddChild(_atlasXSpin);
|
||||
_rawTileRow.AddChild(new Label { Text = "Y" });
|
||||
_atlasYSpin = new SpinBox { MinValue = 0, MaxValue = 99, Value = 0 };
|
||||
_rawTileRow.AddChild(_atlasYSpin);
|
||||
vbox.AddChild(_rawTileRow);
|
||||
|
||||
// Mode toggle
|
||||
vbox.AddChild(new Label { Text = "Mode" });
|
||||
var modeRow = new HBoxContainer();
|
||||
modeRow.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||
_caveBtn = MakeModeButton("Cave", () => SetMode(CaMode.Cave));
|
||||
_platformBtn = MakeModeButton("Platform", () => SetMode(CaMode.Platform));
|
||||
_terrainBtn = MakeModeButton("Terrain", () => SetMode(CaMode.Terrain));
|
||||
_caveBtn.ButtonPressed = true;
|
||||
modeRow.AddChild(_caveBtn);
|
||||
modeRow.AddChild(_platformBtn);
|
||||
modeRow.AddChild(_terrainBtn);
|
||||
vbox.AddChild(modeRow);
|
||||
|
||||
vbox.AddChild(new HSeparator());
|
||||
|
||||
// Width / Height
|
||||
var dimRow = new HBoxContainer();
|
||||
dimRow.AddChild(MakeSpinColumn("Width", out _widthSpin, 5, 500, 40));
|
||||
dimRow.AddChild(MakeSpinColumn("Height", out _heightSpin, 5, 500, 22));
|
||||
vbox.AddChild(dimRow);
|
||||
|
||||
// Offset X / Y
|
||||
var offRow = new HBoxContainer();
|
||||
offRow.AddChild(MakeSpinColumn("Offset X", out _offsetXSpin, -9999, 9999, 0));
|
||||
offRow.AddChild(MakeSpinColumn("Offset Y", out _offsetYSpin, -9999, 9999, 0));
|
||||
vbox.AddChild(offRow);
|
||||
|
||||
// Fill Density
|
||||
var densHeader = new HBoxContainer();
|
||||
densHeader.AddChild(new Label { Text = "Fill Density", SizeFlagsHorizontal = SizeFlags.ExpandFill });
|
||||
_densityLabel = new Label { Text = "48%" };
|
||||
densHeader.AddChild(_densityLabel);
|
||||
vbox.AddChild(densHeader);
|
||||
_densitySlider = new HSlider { MinValue = 0, MaxValue = 100, Value = 48, Step = 1 };
|
||||
_densitySlider.ValueChanged += v => _densityLabel.Text = $"{(int)v}%";
|
||||
vbox.AddChild(_densitySlider);
|
||||
|
||||
// Smoothing Passes
|
||||
var smoothHeader = new HBoxContainer();
|
||||
smoothHeader.AddChild(new Label { Text = "Smoothing Passes", SizeFlagsHorizontal = SizeFlags.ExpandFill });
|
||||
_smoothPassLabel = new Label { Text = "4" };
|
||||
smoothHeader.AddChild(_smoothPassLabel);
|
||||
vbox.AddChild(smoothHeader);
|
||||
_smoothPassSlider = new HSlider { MinValue = 0, MaxValue = 10, Value = 4, Step = 1 };
|
||||
_smoothPassSlider.ValueChanged += v => _smoothPassLabel.Text = $"{(int)v}";
|
||||
vbox.AddChild(_smoothPassSlider);
|
||||
|
||||
// Border walls
|
||||
_borderCheck = new CheckBox { Text = "Solid border walls", ButtonPressed = true };
|
||||
vbox.AddChild(_borderCheck);
|
||||
|
||||
// Seed
|
||||
vbox.AddChild(new Label { Text = "Seed" });
|
||||
var seedRow = new HBoxContainer();
|
||||
_seedSpin = new SpinBox { MinValue = 0, MaxValue = int.MaxValue, Value = 12345,
|
||||
AllowGreater = true, SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||
var randBtn = new Button { Text = "🎲" };
|
||||
randBtn.Pressed += () => _seedSpin.Value = GD.Randi();
|
||||
seedRow.AddChild(_seedSpin);
|
||||
seedRow.AddChild(randBtn);
|
||||
vbox.AddChild(seedRow);
|
||||
|
||||
vbox.AddChild(new HSeparator());
|
||||
|
||||
// Action buttons
|
||||
_genBtn = new Button { Text = "▶ Generate", SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||
vbox.AddChild(_genBtn);
|
||||
|
||||
var actionRow = new HBoxContainer();
|
||||
actionRow.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||
_smoothBtn = new Button { Text = "✦ Smooth", SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||
_clearBtn = new Button { Text = "✕ Clear", SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||
actionRow.AddChild(_smoothBtn);
|
||||
actionRow.AddChild(_clearBtn);
|
||||
vbox.AddChild(actionRow);
|
||||
|
||||
_layerDropdown.ItemSelected += OnLayerSelected;
|
||||
_terrainDropdown.ItemSelected += _ => SaveSettings();
|
||||
_genBtn.Pressed += OnGenerate;
|
||||
_smoothBtn.Pressed += OnSmooth;
|
||||
_clearBtn.Pressed += OnClear;
|
||||
|
||||
LoadSettings();
|
||||
}
|
||||
|
||||
public void OnSceneChanged(Node sceneRoot)
|
||||
{
|
||||
ScanLayers(sceneRoot);
|
||||
RestoreLastLayer(sceneRoot?.SceneFilePath ?? "");
|
||||
}
|
||||
|
||||
private void SetMode(CaMode mode)
|
||||
{
|
||||
_mode = mode;
|
||||
_caveBtn.ButtonPressed = mode == CaMode.Cave;
|
||||
_platformBtn.ButtonPressed = mode == CaMode.Platform;
|
||||
_terrainBtn.ButtonPressed = mode == CaMode.Terrain;
|
||||
}
|
||||
|
||||
private static Button MakeModeButton(string text, System.Action onPressed)
|
||||
{
|
||||
var btn = new Button { Text = text, ToggleMode = true,
|
||||
SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||
btn.Pressed += onPressed;
|
||||
return btn;
|
||||
}
|
||||
|
||||
private static Control MakeSpinColumn(string label, out SpinBox spin,
|
||||
double min, double max, double value)
|
||||
{
|
||||
var col = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||
col.AddChild(new Label { Text = label });
|
||||
spin = new SpinBox { MinValue = min, MaxValue = max, Value = value,
|
||||
SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||
col.AddChild(spin);
|
||||
return col;
|
||||
}
|
||||
|
||||
private void ScanLayers(Node root)
|
||||
{
|
||||
_layers.Clear();
|
||||
_layerDropdown.Clear();
|
||||
if (root == null) return;
|
||||
CollectLayers(root);
|
||||
foreach (var layer in _layers)
|
||||
_layerDropdown.AddItem(layer.Name);
|
||||
if (_layers.Count > 0) OnLayerSelected(0);
|
||||
}
|
||||
|
||||
private void CollectLayers(Node node)
|
||||
{
|
||||
if (node is TileMapLayer layer) _layers.Add(layer);
|
||||
foreach (Node child in node.GetChildren()) CollectLayers(child);
|
||||
}
|
||||
|
||||
private void OnLayerSelected(long index)
|
||||
{
|
||||
if (index < 0 || index >= _layers.Count) return;
|
||||
_selectedLayer = _layers[(int)index];
|
||||
PopulateTerrainDropdown(_selectedLayer);
|
||||
|
||||
var sceneRoot = EditorInterface.Singleton.GetEditedSceneRoot();
|
||||
if (sceneRoot != null)
|
||||
{
|
||||
var key = $"ca_generator/last_layer_{sceneRoot.SceneFilePath}";
|
||||
EditorInterface.Singleton.GetEditorSettings()
|
||||
.SetSetting(key, _selectedLayer.GetPath().ToString());
|
||||
}
|
||||
SaveSettings();
|
||||
}
|
||||
|
||||
private void RestoreLastLayer(string sceneFilePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sceneFilePath) || _layers.Count == 0) return;
|
||||
var key = $"ca_generator/last_layer_{sceneFilePath}";
|
||||
var es = EditorInterface.Singleton.GetEditorSettings();
|
||||
if (!es.HasSetting(key)) return;
|
||||
var path = es.GetSetting(key).AsString();
|
||||
for (int i = 0; i < _layers.Count; i++)
|
||||
{
|
||||
if (_layers[i].GetPath().ToString() == path)
|
||||
{
|
||||
_layerDropdown.Select(i);
|
||||
OnLayerSelected(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void PopulateTerrainDropdown(TileMapLayer layer)
|
||||
{
|
||||
_terrainDropdown.Clear();
|
||||
var tileSet = layer.TileSet;
|
||||
bool hasTerrains = false;
|
||||
|
||||
if (tileSet != null)
|
||||
{
|
||||
for (int ts = 0; ts < tileSet.GetTerrainSetsCount(); ts++)
|
||||
{
|
||||
for (int t = 0; t < tileSet.GetTerrainsCount(ts); t++)
|
||||
{
|
||||
_terrainDropdown.AddItem(tileSet.GetTerrainName(ts, t));
|
||||
_terrainDropdown.SetItemMetadata(
|
||||
_terrainDropdown.ItemCount - 1,
|
||||
Variant.From((ts << 16) | t));
|
||||
hasTerrains = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_terrainDropdown.Visible = hasTerrains;
|
||||
_rawTileRow.Visible = !hasTerrains;
|
||||
}
|
||||
|
||||
private CaGeneratorSettings BuildSettings()
|
||||
{
|
||||
int terrainSet = -1, terrain = 0;
|
||||
if (_terrainDropdown.Visible && _terrainDropdown.ItemCount > 0)
|
||||
{
|
||||
int packed = _terrainDropdown.GetSelectedMetadata().AsInt32();
|
||||
terrainSet = packed >> 16;
|
||||
terrain = packed & 0xFFFF;
|
||||
}
|
||||
|
||||
return new CaGeneratorSettings
|
||||
{
|
||||
Mode = _mode,
|
||||
Width = (int)_widthSpin.Value,
|
||||
Height = (int)_heightSpin.Value,
|
||||
Offset = new Vector2I((int)_offsetXSpin.Value, (int)_offsetYSpin.Value),
|
||||
FillDensity = (float)_densitySlider.Value / 100f,
|
||||
SmoothingPasses = (int)_smoothPassSlider.Value,
|
||||
BorderWalls = _borderCheck.ButtonPressed,
|
||||
Seed = (int)_seedSpin.Value,
|
||||
TerrainSet = terrainSet,
|
||||
Terrain = terrain,
|
||||
SourceId = (int)_sourceIdSpin.Value,
|
||||
AtlasCoords = new Vector2I((int)_atlasXSpin.Value, (int)_atlasYSpin.Value),
|
||||
};
|
||||
}
|
||||
|
||||
private void OnGenerate()
|
||||
{
|
||||
if (_selectedLayer == null) return;
|
||||
var settings = BuildSettings();
|
||||
var grid = CaGenerator.Generate(settings);
|
||||
TilemapPainter.Paint(_selectedLayer, grid, settings, Plugin.UndoRedo);
|
||||
SaveSettings();
|
||||
}
|
||||
|
||||
private void OnSmooth()
|
||||
{
|
||||
if (_selectedLayer == null) return;
|
||||
TilemapPainter.Smooth(_selectedLayer, BuildSettings(), Plugin.UndoRedo);
|
||||
}
|
||||
|
||||
private void OnClear()
|
||||
{
|
||||
if (_selectedLayer == null) return;
|
||||
TilemapPainter.Clear(_selectedLayer, BuildSettings(), Plugin.UndoRedo);
|
||||
}
|
||||
|
||||
private void SaveSettings()
|
||||
{
|
||||
var es = EditorInterface.Singleton.GetEditorSettings();
|
||||
es.SetSetting("ca_generator/mode", (int)_mode);
|
||||
es.SetSetting("ca_generator/width", (int)_widthSpin.Value);
|
||||
es.SetSetting("ca_generator/height", (int)_heightSpin.Value);
|
||||
es.SetSetting("ca_generator/offset_x", (int)_offsetXSpin.Value);
|
||||
es.SetSetting("ca_generator/offset_y", (int)_offsetYSpin.Value);
|
||||
es.SetSetting("ca_generator/fill_density", _densitySlider.Value);
|
||||
es.SetSetting("ca_generator/smoothing_passes", (int)_smoothPassSlider.Value);
|
||||
es.SetSetting("ca_generator/border_walls", _borderCheck.ButtonPressed);
|
||||
es.SetSetting("ca_generator/seed", (int)_seedSpin.Value);
|
||||
es.SetSetting("ca_generator/source_id", (int)_sourceIdSpin.Value);
|
||||
es.SetSetting("ca_generator/atlas_x", (int)_atlasXSpin.Value);
|
||||
es.SetSetting("ca_generator/atlas_y", (int)_atlasYSpin.Value);
|
||||
}
|
||||
|
||||
private void LoadSettings()
|
||||
{
|
||||
var es = EditorInterface.Singleton.GetEditorSettings();
|
||||
if (es == null) return;
|
||||
|
||||
SetMode((CaMode)(es.HasSetting("ca_generator/mode") ? es.GetSetting("ca_generator/mode").AsInt32() : 0));
|
||||
_widthSpin.Value = es.HasSetting("ca_generator/width") ? es.GetSetting("ca_generator/width").AsDouble() : 40.0;
|
||||
_heightSpin.Value = es.HasSetting("ca_generator/height") ? es.GetSetting("ca_generator/height").AsDouble() : 22.0;
|
||||
_offsetXSpin.Value = es.HasSetting("ca_generator/offset_x") ? es.GetSetting("ca_generator/offset_x").AsDouble() : 0.0;
|
||||
_offsetYSpin.Value = es.HasSetting("ca_generator/offset_y") ? es.GetSetting("ca_generator/offset_y").AsDouble() : 0.0;
|
||||
_densitySlider.Value = es.HasSetting("ca_generator/fill_density") ? es.GetSetting("ca_generator/fill_density").AsDouble() : 48.0;
|
||||
_smoothPassSlider.Value = es.HasSetting("ca_generator/smoothing_passes") ? es.GetSetting("ca_generator/smoothing_passes").AsDouble() : 4.0;
|
||||
_borderCheck.ButtonPressed = es.HasSetting("ca_generator/border_walls") ? es.GetSetting("ca_generator/border_walls").AsBool() : true;
|
||||
_seedSpin.Value = es.HasSetting("ca_generator/seed") ? es.GetSetting("ca_generator/seed").AsDouble() : 12345.0;
|
||||
|
||||
_sourceIdSpin.Value = es.HasSetting("ca_generator/source_id") ? es.GetSetting("ca_generator/source_id").AsDouble() : 0.0;
|
||||
_atlasXSpin.Value = es.HasSetting("ca_generator/atlas_x") ? es.GetSetting("ca_generator/atlas_x").AsDouble() : 0.0;
|
||||
_atlasYSpin.Value = es.HasSetting("ca_generator/atlas_y") ? es.GetSetting("ca_generator/atlas_y").AsDouble() : 0.0;
|
||||
|
||||
_densityLabel.Text = $"{(int)_densitySlider.Value}%";
|
||||
_smoothPassLabel.Text = $"{(int)_smoothPassSlider.Value}";
|
||||
}
|
||||
}
|
||||
#endif
|
||||
1
addons/ca_level_generator/CaLevelGeneratorDock.cs.uid
Normal file
1
addons/ca_level_generator/CaLevelGeneratorDock.cs.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dnuvfv3rgyywd
|
||||
37
addons/ca_level_generator/CaLevelGeneratorPlugin.cs
Normal file
37
addons/ca_level_generator/CaLevelGeneratorPlugin.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
#if TOOLS
|
||||
using Godot;
|
||||
|
||||
namespace Mr.BrickAdventures.Tools.CaLevelGenerator;
|
||||
|
||||
[Tool]
|
||||
public partial class CaLevelGeneratorPlugin : EditorPlugin
|
||||
{
|
||||
private EditorDock _editorDock;
|
||||
private CaLevelGeneratorDock _dock;
|
||||
|
||||
public override void _EnterTree()
|
||||
{
|
||||
_dock = new CaLevelGeneratorDock { Plugin = this };
|
||||
_editorDock = new EditorDock { Title = "CA Level Generator" };
|
||||
_editorDock.AddChild(_dock);
|
||||
AddDock(_editorDock);
|
||||
SceneChanged += OnSceneChanged;
|
||||
}
|
||||
|
||||
public override void _ExitTree()
|
||||
{
|
||||
SceneChanged -= OnSceneChanged;
|
||||
RemoveDock(_editorDock);
|
||||
_editorDock.QueueFree();
|
||||
_editorDock = null;
|
||||
_dock = null;
|
||||
}
|
||||
|
||||
private void OnSceneChanged(Node sceneRoot)
|
||||
{
|
||||
_dock?.OnSceneChanged(sceneRoot);
|
||||
}
|
||||
|
||||
public EditorUndoRedoManager UndoRedo => GetUndoRedo();
|
||||
}
|
||||
#endif
|
||||
1
addons/ca_level_generator/CaLevelGeneratorPlugin.cs.uid
Normal file
1
addons/ca_level_generator/CaLevelGeneratorPlugin.cs.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cojnhxpcgepi2
|
||||
100
addons/ca_level_generator/TilemapPainter.cs
Normal file
100
addons/ca_level_generator/TilemapPainter.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
#if TOOLS
|
||||
using Godot;
|
||||
using Godot.Collections;
|
||||
|
||||
namespace Mr.BrickAdventures.Tools.CaLevelGenerator;
|
||||
|
||||
public static class TilemapPainter
|
||||
{
|
||||
public static void Paint(
|
||||
TileMapLayer layer,
|
||||
bool[,] grid,
|
||||
CaGeneratorSettings settings,
|
||||
EditorUndoRedoManager undoRedo)
|
||||
{
|
||||
var before = layer.TileMapData;
|
||||
ApplyGrid(layer, grid, settings);
|
||||
var after = layer.TileMapData;
|
||||
RegisterUndo(layer, before, after, undoRedo, "CA Generate");
|
||||
}
|
||||
|
||||
public static void Smooth(
|
||||
TileMapLayer layer,
|
||||
CaGeneratorSettings settings,
|
||||
EditorUndoRedoManager undoRedo)
|
||||
{
|
||||
var grid = ReadGrid(layer, settings.Offset, new Vector2I(settings.Width, settings.Height));
|
||||
grid = CaGenerator.Smooth(grid);
|
||||
var before = layer.TileMapData;
|
||||
ApplyGrid(layer, grid, settings);
|
||||
var after = layer.TileMapData;
|
||||
RegisterUndo(layer, before, after, undoRedo, "CA Smooth");
|
||||
}
|
||||
|
||||
public static void Clear(
|
||||
TileMapLayer layer,
|
||||
CaGeneratorSettings settings,
|
||||
EditorUndoRedoManager undoRedo)
|
||||
{
|
||||
var before = layer.TileMapData;
|
||||
for (int x = 0; x < settings.Width; x++)
|
||||
for (int y = 0; y < settings.Height; y++)
|
||||
layer.EraseCell(settings.Offset + new Vector2I(x, y));
|
||||
var after = layer.TileMapData;
|
||||
RegisterUndo(layer, before, after, undoRedo, "CA Clear");
|
||||
}
|
||||
|
||||
private static bool[,] ReadGrid(TileMapLayer layer, Vector2I offset, Vector2I size)
|
||||
{
|
||||
var grid = new bool[size.X, size.Y];
|
||||
for (int x = 0; x < size.X; x++)
|
||||
for (int y = 0; y < size.Y; y++)
|
||||
grid[x, y] = layer.GetCellSourceId(offset + new Vector2I(x, y)) != -1;
|
||||
return grid;
|
||||
}
|
||||
|
||||
private static void ApplyGrid(TileMapLayer layer, bool[,] grid, CaGeneratorSettings settings)
|
||||
{
|
||||
int w = grid.GetLength(0), h = grid.GetLength(1);
|
||||
|
||||
for (int x = 0; x < w; x++)
|
||||
for (int y = 0; y < h; y++)
|
||||
layer.EraseCell(settings.Offset + new Vector2I(x, y));
|
||||
|
||||
if (settings.TerrainSet >= 0)
|
||||
{
|
||||
var solidCells = new Array<Vector2I>();
|
||||
for (int x = 0; x < w; x++)
|
||||
for (int y = 0; y < h; y++)
|
||||
if (grid[x, y])
|
||||
solidCells.Add(settings.Offset + new Vector2I(x, y));
|
||||
|
||||
if (solidCells.Count > 0)
|
||||
layer.SetCellsTerrainConnect(solidCells, settings.TerrainSet, settings.Terrain);
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int x = 0; x < w; x++)
|
||||
for (int y = 0; y < h; y++)
|
||||
if (grid[x, y])
|
||||
layer.SetCell(
|
||||
settings.Offset + new Vector2I(x, y),
|
||||
settings.SourceId,
|
||||
settings.AtlasCoords);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RegisterUndo(
|
||||
TileMapLayer layer,
|
||||
byte[] before,
|
||||
byte[] after,
|
||||
EditorUndoRedoManager undoRedo,
|
||||
string actionName)
|
||||
{
|
||||
undoRedo.CreateAction(actionName);
|
||||
undoRedo.AddUndoProperty(layer, "tile_map_data", before);
|
||||
undoRedo.AddDoProperty(layer, "tile_map_data", after);
|
||||
undoRedo.CommitAction(false);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
1
addons/ca_level_generator/TilemapPainter.cs.uid
Normal file
1
addons/ca_level_generator/TilemapPainter.cs.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://y55l4iw35tih
|
||||
6
addons/ca_level_generator/ca_generator_tests.tscn
Normal file
6
addons/ca_level_generator/ca_generator_tests.tscn
Normal file
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://test_ca_generator"]
|
||||
|
||||
[ext_resource type="Script" path="res://addons/ca_level_generator/CaGeneratorTestRunner.cs" id="1_catest"]
|
||||
|
||||
[node name="CaGeneratorTestRunner" type="Node"]
|
||||
script = ExtResource("1_catest")
|
||||
6
addons/ca_level_generator/plugin.cfg
Normal file
6
addons/ca_level_generator/plugin.cfg
Normal file
@@ -0,0 +1,6 @@
|
||||
[plugin]
|
||||
name="CA Level Generator"
|
||||
description="Cellular automata level blockout generator"
|
||||
author="Gabriel Kaszewski"
|
||||
version="1.0"
|
||||
script="CaLevelGeneratorPlugin.cs"
|
||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/banner.png-7e9e6a304eef850602c8d5afb80df9c3.c
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/icon.svg-17eb5d3e2a3cfbe59852220758c5b7bd.cte
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -26,6 +28,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/responses_menu.svg-87cf63ca685d53616205049572
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -26,6 +28,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/update.svg-f1628866ed4eb2e13e3b81f75443687e.c
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -21,6 +21,7 @@ msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=1
|
||||
subpixel_positioning=1
|
||||
keep_rounding_remainders=true
|
||||
|
||||
@@ -21,6 +21,7 @@ msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=1
|
||||
subpixel_positioning=1
|
||||
keep_rounding_remainders=true
|
||||
|
||||
@@ -21,6 +21,7 @@ msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=1
|
||||
subpixel_positioning=1
|
||||
keep_rounding_remainders=true
|
||||
|
||||
@@ -21,6 +21,7 @@ msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=1
|
||||
subpixel_positioning=1
|
||||
keep_rounding_remainders=true
|
||||
|
||||
@@ -21,6 +21,7 @@ msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=1
|
||||
subpixel_positioning=1
|
||||
keep_rounding_remainders=true
|
||||
|
||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/PhantomCameraBtnPrimaryDefault.png-fcf3696b58
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/PhantomCameraBtnPrimaryHover.png-3d2e4d225f6a
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/inventory_container.png-12241277f279bfc4bf7d5
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/level_spritesheet.png-26a44dd21a040a5480d5ccb
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_2d_sprite.png-deab230b83ae03ae
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/player_sprite.svg-8862ecb19e12152eb892607676f
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/sign_prompt.png-18d451127e1cd1a16367acd23cec4
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/checker_pattern_dark.png-70cedad2d3abf4ad6166
|
||||
compress/mode=2
|
||||
compress/high_quality=true
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -26,6 +28,10 @@ mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/target.png-878c5e8d057c8a9a4c2322d4ab88e9ef.s
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -26,6 +28,10 @@ mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -21,6 +21,7 @@ msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=1
|
||||
subpixel_positioning=1
|
||||
keep_rounding_remainders=true
|
||||
|
||||
@@ -21,6 +21,7 @@ msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=1
|
||||
subpixel_positioning=1
|
||||
keep_rounding_remainders=true
|
||||
|
||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/PriorityOverride.svg-e76e07f4bbd98169f119e17f
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_2d.svg-e5483cbc858fc5f95f7210b
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_3d.svg-41ed612e834470377fb56ee
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_camera_3d_resource.svg-f8bf8d1
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/phantom_camera_gizmo.svg-ba1aacb9b1c5f4ef401d
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -26,6 +28,10 @@ mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_glow_logo.png-078f944973b55b32
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/phantom_camera_host.svg-3150f8f2d82ca9ecab9a3
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -26,6 +28,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_logo.png-8b8d347b5e4800c86cd80
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_noise_emitter_2d.svg-1b3d37fe3
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/phantom_camera_noise_emitter_3d.svg-9b90fe54a
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -26,6 +28,10 @@ mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/phantom_camera_noise_emitter_gizmo.svg-9a5938
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -26,6 +28,10 @@ mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_noise_resource.svg-a81ed223714
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/phantom_camera_tween.svg-16faced08ef4a5f34582
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -26,6 +28,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_updater_panel_icon.svg-19823e6
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/Camera2DIcon.svg-300e6f57281180711c5ecf391104
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/Camera3DIcon.svg-4805c46004db1c89cc9443dd7406
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/SceneTypesIcon.svg-66e2255bd3398007bec03a5cbf
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/Select.svg-cdf90b8b400d3b91a023b70c6a823894.c
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/warning.svg-c1b21c265e0842bbdc9ed10735995366.
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[gd_resource type="Theme" load_steps=12 format=3 uid="uid://bhppejri5dbsf"]
|
||||
[gd_resource type="Theme" format=3 uid="uid://bhppejri5dbsf"]
|
||||
|
||||
[ext_resource type="FontFile" uid="uid://dve7mgsjik4dg" path="res://addons/phantom_camera/fonts/Nunito-Regular.ttf" id="1_5rtjh"]
|
||||
[ext_resource type="StyleBox" uid="uid://5weqvkjsfso3" path="res://addons/phantom_camera/themes/button_hover.tres" id="2_du6h5"]
|
||||
|
||||
15
docs/adr/0001-three-progression-scopes.md
Normal file
15
docs/adr/0001-three-progression-scopes.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Player progression has three scopes, not two
|
||||
|
||||
Progression state was two scopes — `PlayerState` (persisted) and `SessionState` (transient) — with achievements
|
||||
and best times leaking out as special cases: `PlayerState.Reset()` deliberately skipped `UnlockedAchievements`,
|
||||
and best times lived in the ghost replay files rather than the save. We split progression into **Attempt**,
|
||||
**Playthrough** and **Profile** (defined in `CONTEXT.md`), because introducing save slots makes the third scope
|
||||
mandatory rather than a preference: Steam achievements are per-account and cannot be un-earned, so a
|
||||
per-Playthrough achievement list would permanently contradict Steam the moment a second slot exists. Best times
|
||||
follow the same argument — a completionist's record is not something starting a new game should reset.
|
||||
|
||||
## Considered options
|
||||
|
||||
Keeping one persisted root with a hand-maintained list of fields that survive New Game. Rejected: that is exactly
|
||||
what the old `Reset()` did, and it had already drifted — achievements survived by omission rather than by design,
|
||||
and nothing would have caught the next missed field.
|
||||
16
docs/adr/0002-attempt-is-a-ledger.md
Normal file
16
docs/adr/0002-attempt-is-a-ledger.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Attempt is a separate aggregate holding a ledger
|
||||
|
||||
**Playthrough** and **Attempt** are separate aggregate roots rather than one root containing the other, and an
|
||||
Attempt records a *ledger* — coins found, coins spent, skills bought — rather than just a purse of coins found.
|
||||
Separate roots keeps each aggregate small and gives the Attempt its own lifecycle, since it is never persisted.
|
||||
The ledger exists so that the rule "nothing survives an Attempt unless you complete the level" applies to
|
||||
purchases too: under the previous model `RemoveCoins` drained session coins and then debited banked coins
|
||||
immediately, so buying a skill mid-level and then dying lost both the coins and the skill.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Affordability is a cross-aggregate **read** (`attempt.CoinsFound + playthrough.Bank >= cost`). Reads may cross
|
||||
scopes; writes may not.
|
||||
- Purchasing writes only the Attempt. The only operation touching both roots is Commit, which has exactly one
|
||||
call site.
|
||||
- Discarding an Attempt undoes purchases rather than refunding them — under the ledger they never happened.
|
||||
15
docs/adr/0003-only-first-clear-commits.md
Normal file
15
docs/adr/0003-only-first-clear-commits.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Only a First Clear commits an Attempt
|
||||
|
||||
The game is moving from a purely sequential campaign to one where completionists can select and replay levels
|
||||
they have already beaten. Coins and skills bank only on a **First Clear** — the first completion of a Level
|
||||
within a Playthrough — while a **Replay** records Best Time, achievements and statistics to the Profile and
|
||||
discards the Attempt. We chose this over committing on every completion because grinding the easiest level would
|
||||
otherwise be the optimal way to buy every skill.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A Replay must never advance campaign position. Sharing one code path between First Clear and Replay without an
|
||||
explicit branch is the obvious way to reintroduce this bug.
|
||||
- `ClearedLevels` answers "is this a First Clear?" for free, since it already deduplicates.
|
||||
- Coins found during a Replay are lost on completion, not just on death. This is intentional and worth surfacing
|
||||
in the UI.
|
||||
21
docs/adr/0004-domain-does-not-reference-godot.md
Normal file
21
docs/adr/0004-domain-does-not-reference-godot.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# The progression domain does not reference Godot
|
||||
|
||||
The progression domain lives in its own assembly with no Godot reference. Aggregates raise plain C# event records
|
||||
as part of their operations, and a single adapter republishes those as `EventBus` signals. This is the only
|
||||
enforceable version of the goal — correctness checkable without a scene tree, with the compiler enforcing the rule
|
||||
rather than discipline. The leak it removes was already visible: `PlayerState` documented itself as a POCO "for
|
||||
predictable state management" while holding `List<SkillData>`, and `SkillData : Resource`, which is why saves had
|
||||
to store skill *names* and re-resolve them through `SkillManager` on load.
|
||||
|
||||
Republishing rather than replacing `EventBus` keeps existing scene wiring and subscribers intact, and makes
|
||||
"state changed ⇒ signal fired" un-bypassable — the original defect, where `ConsoleManager` mutated state directly
|
||||
and re-emitted signals by hand, sometimes without the invariants the store's own operations applied.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `GameStateStore` stops being a `Node`. The autoload becomes a composition root that owns the aggregates, so the
|
||||
aggregates' lifetime is no longer tied to the scene tree and most `Store?.` null-guards disappear.
|
||||
- Skill progression cannot live on `SkillData`. A `Resource` is one cached instance per process, so with save
|
||||
slots it would leak levels between Playthroughs.
|
||||
- Godot types (`Vector2`, `Mathf`, `GD.Print`) are not available in the domain; where they appear today they are
|
||||
replaced by domain values or moved to adapters.
|
||||
28
docs/adr/0005-playthrough-settles-attempts.md
Normal file
28
docs/adr/0005-playthrough-settles-attempts.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Playthrough has no LoseLife; it settles Attempts
|
||||
|
||||
Playthrough and Attempt are separate aggregate roots (ADR-0002), but "one death costs exactly one life" requires
|
||||
ending the Attempt and decrementing lives *together*. Rather than expose bare mutators and rely on the caller to
|
||||
sequence them, Playthrough exposes only what happened — `Commit(attempt)`, `SettleDeath(attempt)`,
|
||||
`SettleReplay(attempt)` — each of which calls the Attempt's own `TryEnd…` internally and does nothing when it
|
||||
returns false. Every operation is therefore atomic and idempotent, and there is no `LoseLife` to call from
|
||||
somewhere else.
|
||||
|
||||
The alternative shape, `if (attempt.TryEndInDeath()) playthrough.LoseLife();`, puts the idempotency in the domain
|
||||
but leaves the sequencing to the caller. A public `LoseLife` is an invitation: the code being replaced has two
|
||||
paths to exactly that operation — `LivesStateHandler.RemoveLife` and `ConsoleManager` writing `Player.Lives`
|
||||
directly, unclamped — because the operation existed to be called.
|
||||
|
||||
## Considered options
|
||||
|
||||
Collapsing the two into one aggregate with Attempt as an inner entity, making the atomicity structural. Rejected:
|
||||
`Commit(Attempt)` already gave the shape we needed, and passing one root into another's method is a transient
|
||||
reference rather than a stored association, so ADR-0002 stands unchanged.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Overlapping hazards in a kill zone, or a hazard re-firing during the two seconds before `DeathScreen` reloads,
|
||||
cost one life rather than two.
|
||||
- `ExitDoorComponent` and `ConsoleManager.GoToNextLevelCommand` can both request completion; only the first banks.
|
||||
- The application layer holds the active Attempt and passes it in. The Attempt carries its own `LevelId`, so
|
||||
`Commit` decides First Clear versus Replay from the Attempt itself rather than trusting a level supplied
|
||||
alongside it — finishing a Replay stops being a way to corrupt campaign position.
|
||||
322
docs/plans/phase-a-progression-domain.md
Normal file
322
docs/plans/phase-a-progression-domain.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# Phase A — Progression domain
|
||||
|
||||
Replace the current state layer with the model in [`CONTEXT.md`](../../CONTEXT.md), governed by
|
||||
[ADR-0001..0004](../adr/). **No new player-facing features.** Save files are wiped once; the game is unreleased so
|
||||
this costs nothing (ADR context).
|
||||
|
||||
Phase B — slot picker UI, level select, Replay surfacing, best times out of the ghost files — is out of scope
|
||||
here. Phase A does put the *persistence format* in place for slots, so Phase B is UI work rather than another
|
||||
model change.
|
||||
|
||||
## Two kinds of slice
|
||||
|
||||
| | verified by | `/tdd` |
|
||||
|---|---|---|
|
||||
| **Slices 1–6** — pure domain, no Godot | `dotnet test` | yes — red/green/refactor throughout |
|
||||
| **Slices 7–13** — adapters and wiring | compiler + playing the game | no — write the test where one is possible, otherwise verify by hand |
|
||||
|
||||
Don't try to TDD slices 7–13. You can't red-green an autoload swap or a `.tscn` connection deletion, and forcing
|
||||
it produces tests that assert the mock rather than the behaviour. The value of slices 1–6 is that they absorb
|
||||
*all* the logic worth testing, so the untestable slices become mechanical.
|
||||
|
||||
Each slice ends with the build green and the game runnable. Don't start the next one until it is.
|
||||
|
||||
---
|
||||
|
||||
## Slice 0 — scaffolding
|
||||
|
||||
No tests. Pure setup.
|
||||
|
||||
- `progression/Mr.BrickAdventures.Progression.csproj` — plain `net8.0` library, **no Godot reference**, root
|
||||
namespace `Mr.BrickAdventures.Progression`.
|
||||
- `progression.tests/Mr.BrickAdventures.Progression.Tests.csproj` — xunit (NUnit is equally fine), references the
|
||||
domain library **only**.
|
||||
- Add both to `Mr. Brick Adventures.sln`. Add a `ProjectReference` from the game project to the domain library.
|
||||
- Set `<EnableDynamicLoading>true</EnableDynamicLoading>` in the domain library too, not just the game project —
|
||||
Godot needs it on referenced assemblies for hot reload, and omitting it produces confusing load failures.
|
||||
|
||||
**Done when:** `dotnet test` runs and reports zero tests; Godot still builds; the game still launches and plays.
|
||||
|
||||
**Guard rail for the whole phase:**
|
||||
|
||||
```sh
|
||||
# must print nothing — anchored to real using-directives
|
||||
grep -rnE "^\s*(global\s+)?using\s+Godot" progression progression.adapters progression.tests --include=*.cs
|
||||
# stronger, and cannot be fooled by wording: no project may resolve the engine assembly at all
|
||||
grep -c GodotSharp progression/obj/project.assets.json progression.adapters/obj/project.assets.json
|
||||
```
|
||||
|
||||
That check is what ADR-0004 buys you. Match using-directives **anchored to the start of a line**: a bare
|
||||
`grep -r "using Godot"` also hits `.csproj` comments and doc comments that legitimately mention the engine in prose,
|
||||
and reports a false violation. The `project.assets.json` check is the one that cannot be fooled by wording.
|
||||
|
||||
---
|
||||
|
||||
## Slice 1 — value objects · TDD
|
||||
|
||||
`Coins`, `Lives`, `LevelId`, `SkillId`, `SkillLevel`, `StatName`, `LevelTime`, `PlaythroughId`.
|
||||
|
||||
Invariants to drive out with tests:
|
||||
|
||||
- `Coins` cannot be negative. Subtraction that would go negative is a failure the caller must handle, not a
|
||||
silent clamp — the current `Mathf.Max(0, ...)` clamp in one path and absence of it in another is exactly the
|
||||
bug this removes.
|
||||
- `Lives` floors at zero, and reaching zero is observable.
|
||||
- `LevelId` is a non-empty stable string. Not an int, not an index.
|
||||
- `SkillLevel` starts at 1 and has no zero value — "unowned" is represented by *absence*, never by level 0. See
|
||||
the `SkillData.Level = 1` ambiguity this replaces.
|
||||
- `LevelTime` is positive; comparison is what "best" means.
|
||||
- `StatName` is a closed set (the five in `StatNames` today), so a typo cannot invent a statistic.
|
||||
|
||||
**Done when:** no value object can hold invalid state, and every clamp/guard currently scattered across
|
||||
`GameStateStore` and `ConsoleManager` exists in exactly one place.
|
||||
|
||||
---
|
||||
|
||||
## Slice 2 — Attempt · TDD
|
||||
|
||||
The ledger from ADR-0002: `CoinsFound`, `CoinsSpent`, `SkillsBought`, plus an outcome.
|
||||
|
||||
- `Attempt(LevelId level)` and `LevelId Level` — the Attempt knows which Level it is an attempt at, so
|
||||
`Commit` decides First Clear versus Replay from the Attempt rather than from a level the caller passes alongside
|
||||
- `Outcome` — `InProgress | Died | Cleared`
|
||||
- `void RecordCoinsFound(Coins)` — silently ignores late coins once ended
|
||||
- `bool TryRecordPurchase(SkillId, SkillLevel resulting, Coins cost)` — records where the skill ended up, since
|
||||
one Attempt can both unlock and upgrade the same skill
|
||||
- `bool TryEndInDeath()` / `bool TryEndInCompletion()` — return false if the Attempt already ended
|
||||
- No `Discard` method: discarding is throwing the object away.
|
||||
|
||||
The recording methods are deliberately asymmetric. A coin pickup firing after death is a real event — the player
|
||||
node survives for two seconds before `DeathScreen` reloads — and its caller has nothing useful to do with a
|
||||
`false`, so it returns nothing. A purchase's caller does care.
|
||||
|
||||
`CoinsFound` and `CoinsSpent` are both non-negative. There is no signed net value — settlement is
|
||||
`bank.Add(found).TrySubtract(spent)` in `Playthrough.Commit`, which in that order cannot go negative given
|
||||
affordability was checked at purchase time.
|
||||
|
||||
> Corrects an earlier draft of this plan, which had `NetCoins` "may be negative". That would have forced `Coins`
|
||||
> to permit negatives and defeated the point of the type.
|
||||
|
||||
**The outcome is the idempotency guard, and it is the reason this slice matters.** An Attempt is the thing that
|
||||
dies or is cleared, and it can only do either once. `Lives` flooring at zero does not solve this: losing two lives
|
||||
from a single death takes you from 3 to 1, which no clamp catches.
|
||||
|
||||
Tests worth writing:
|
||||
|
||||
- **An Attempt cannot end twice.** Second `TryEndInDeath` returns false and changes nothing.
|
||||
- **An Attempt that died cannot then be cleared**, and vice versa.
|
||||
- Spending more than found is representable — the shortfall is settled at Commit, not rejected here.
|
||||
- Recording anything after the Attempt ended is refused.
|
||||
|
||||
Two real call sites motivate this. Death: overlapping hazards in a kill zone, or a hazard re-triggering during the
|
||||
two seconds before `DeathScreen` reloads the scene — the player node still exists with zero health. Completion:
|
||||
`ExitDoorComponent:46` and `ConsoleManager.GoToNextLevelCommand:99` both call `OnLevelComplete()`.
|
||||
|
||||
`HealthComponent.ApplyHealthChange` currently absorbs repeat deaths via its `delta == 0` early return, but that is
|
||||
incidental and `Health` is a public `[Export]` setter that bypasses it. Do not rely on the engine side.
|
||||
|
||||
---
|
||||
|
||||
## Slice 3 — Playthrough · TDD
|
||||
|
||||
Aggregate root. `PlaythroughId`, `Lives`, `Bank`, `OwnedSkills : SkillId → SkillLevel`, `CurrentLevel : LevelId`,
|
||||
`ClearedLevels`.
|
||||
|
||||
Operations: `SettleDeath(Attempt)`, `SettleCompletion(Attempt, Campaign)`, `CanAfford(Attempt, Coins)`,
|
||||
`HasCleared(LevelId)`, `ReachableLevels(Campaign)`, `IsRunOver`.
|
||||
|
||||
**There is no `LoseLife`** — see ADR-0005. Each settle operation calls the Attempt's `TryEnd…` internally and does
|
||||
nothing when it returns false, so it is atomic and idempotent, and no bare mutator exists to be called from a
|
||||
second place.
|
||||
|
||||
**Two settle operations, not three.** An earlier draft had `Commit` and `SettleReplay` as separate calls, which
|
||||
made the *caller* decide whether a completion was a First Clear or a Replay — precisely the ADR-0003 regression.
|
||||
`SettleCompletion` branches on `HasCleared(attempt.Level)` itself, so it cannot be selected wrongly.
|
||||
|
||||
**`Campaign`** is a domain value object holding the authored ordering of `LevelId`s (`First`, `Next`, `Contains`).
|
||||
`ILevelCatalogue` stays on the adapter side resolving a `LevelId` to a scene handle and display name; the ordering
|
||||
is pure data handed in as a `Campaign`, so the aggregate never touches a port.
|
||||
|
||||
**`Commit` does not validate skill levels against the upgrade cap.** Max level comes from
|
||||
`SkillData.Upgrades.Count`, which is authored data the domain cannot see. The cap is a purchase-time rule enforced
|
||||
in slice 6's `UpgradeSkill`, which is also where `TryUpgradeSkill`'s current `Upgrades[skill.Level]` overrun gets a
|
||||
test. `Commit` applies what the ledger recorded.
|
||||
|
||||
Events leave via `IReadOnlyList<ProgressionEvent> DrainEvents()`. The composition root drains once after every use
|
||||
case and republishes (ADR-0004), so operations stay `void` and the "forgot to drain" risk collapses to one line in
|
||||
one place.
|
||||
|
||||
Raises: `LifeLost(remaining)`, `RunEnded`, `BankChanged(total)`, `SkillSetChanged`.
|
||||
|
||||
This is where the report's bugs become tests. Write these first, watch them fail:
|
||||
|
||||
- **Losing the last life sets lives to zero *and* raises `RunEnded` in one operation.** This is the last-life
|
||||
soft-lock — today two screens read `Lives` before the handler writes it. Make it one atomic operation and the
|
||||
race cannot be expressed.
|
||||
- **Two deaths in one Attempt cost one life.** Pairs with slice 2's outcome guard; assert it here at the
|
||||
Playthrough level too, because this is the invariant a player would actually notice.
|
||||
- **`Commit` on an already-cleared level does not advance and does not bank** (ADR-0003).
|
||||
- **`Commit` settles as `bank.Add(found).TrySubtract(spent)`**, in that order, so an Attempt that overspent
|
||||
reduces the Bank correctly and can never drive it negative. A discarded Attempt leaves the Bank untouched —
|
||||
the "buy mid-level, die, lose coins *and* skill" bug.
|
||||
- **`ReachableLevels` is computed** from `ClearedLevels` plus level order, never stored. `UnlockedLevels` and
|
||||
`IsLevelUnlocked` are deleted, not ported.
|
||||
- **`Bank` cannot go negative** even through Commit.
|
||||
|
||||
---
|
||||
|
||||
## Slice 4 — Profile · TDD
|
||||
|
||||
Aggregate root, one per installation. `Achievements`, `BestTimes : LevelId → LevelTime`, `Statistics`.
|
||||
|
||||
- `RecordTime(LevelId, LevelTime)` — only replaces a slower time. Recording a worse time is a no-op, not an error.
|
||||
- `UnlockAchievement(id)` — idempotent, raises `AchievementUnlocked` only on the transition.
|
||||
- `Record(StatName, int)`.
|
||||
- `RelockAchievement(id)` exists for the console only, and is documented as **not** reaching Steam — that
|
||||
asymmetry is unavoidable (Steam achievements cannot be un-earned) so make it explicit rather than surprising.
|
||||
|
||||
---
|
||||
|
||||
## Slice 5 — ports and in-memory adapters · TDD
|
||||
|
||||
Interfaces in the domain: `IPlaythroughStore`, `IProfileStore`, `ILevelCatalogue`, `IGhostStore`.
|
||||
|
||||
In-memory implementations live in the **test project**. That's what makes the seam real rather than hypothetical —
|
||||
two adapters, one of which ships and one of which doesn't.
|
||||
|
||||
- Round-trip: save a Playthrough, load it, assert equality. Same for Profile.
|
||||
- Loading an absent Playthrough and loading a *corrupt* one are different outcomes. Today both surface as
|
||||
`false` from `LoadGame`, which is why a version bump silently overwrites saves.
|
||||
- `ILevelCatalogue` returns ordered `LevelId`s and resolves one to a scene reference — as an opaque handle, since
|
||||
the domain can't name `PackedScene`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 6 — use cases · TDD
|
||||
|
||||
`StartPlaythrough`, `LoadPlaythrough`, `EnterLevel`, `FinishLevel`, `HandlePlayerDeath`, `PurchaseSkill`,
|
||||
`UpgradeSkill`.
|
||||
|
||||
Four of these carry the orchestration that is currently spread across `GameManager`, the `Events/` handlers and
|
||||
the UI. Each of the remaining report bugs gets a test here:
|
||||
|
||||
- **`EnterLevel` always announces `LevelStarted`.** Today only `TryToGoToNextLevel` emits it, so ghost recording
|
||||
never starts on the first level or after Continue, and a continued run records completion times of zero.
|
||||
- **`FinishLevel` branches First Clear vs Replay** and a Replay never advances `CurrentLevel` (ADR-0003 flags this
|
||||
as the obvious regression).
|
||||
- **`HandlePlayerDeath` loses a life and discards the Attempt as one operation.**
|
||||
- **`PurchaseSkill` writes only the Attempt**, checks affordability across Attempt + Bank, and rejects rather than
|
||||
throwing when short.
|
||||
- **`UpgradeSkill` reads the level from the Playthrough**, and cannot index past the upgrade table.
|
||||
|
||||
**Done when:** every bug listed in the architecture review has a test that failed before the fix and passes after.
|
||||
That is the deliverable of Phase A — the point where "hidden bugs" stops being a category.
|
||||
|
||||
---
|
||||
|
||||
> Everything above is pure C# with no Godot. Everything below touches the engine. Switch verification mode here.
|
||||
|
||||
---
|
||||
|
||||
## Slice 7 — level catalogue adapter
|
||||
|
||||
`resources/levels/` already holds six `LevelResource` files (`village_1..5`, `forest_1`) but there are eight level
|
||||
scenes and only two scenes reference a resource at all. Complete and adopt them.
|
||||
|
||||
- Add `LevelId` and explicit order to `LevelResource`; create the two missing entries (`tutorial_1`,
|
||||
`village_6`).
|
||||
- A catalogue resource lists them in order. Adapter implements `ILevelCatalogue`.
|
||||
- Delete `GameManager.LevelScenes` and all array-index addressing.
|
||||
- `LevelResource.ScenePath` is currently never read — either it becomes how the catalogue resolves scenes, or it
|
||||
goes. Don't leave it.
|
||||
|
||||
**Verify:** game boots into the first level; order matches the catalogue; `DeathScreen`'s level-name label still
|
||||
populates.
|
||||
|
||||
## Slice 8 — JSON store adapters
|
||||
|
||||
- `user://profile.json` and `user://playthrough_{id}.json`. The slot format lands now even though the UI only
|
||||
ever creates one; Phase B then adds a picker and nothing about the format changes.
|
||||
- Write to a temp file and rename, so a crash mid-save can't corrupt a slot. With N slots this stops being
|
||||
theoretical.
|
||||
- Keep a version field, but replace the equality check with a migration hook that can return
|
||||
"unmigratable" — distinct from "absent".
|
||||
|
||||
**Verify:** play, quit, continue. Then hand-corrupt a file and confirm you get an error rather than a silent new
|
||||
game.
|
||||
|
||||
## Slice 9 — composition root and republisher
|
||||
|
||||
- New autoload owns the `Profile` and the active `Playthrough`. It is the only `Node` in this area.
|
||||
- Republisher subscribes to domain events and emits the existing `EventBus` signals (ADR-0004), so `Hud`,
|
||||
`DeathScreen` and the rest keep working unchanged.
|
||||
- Delete `GameStateStore`. Register the new autoload in `project.godot` in its place.
|
||||
|
||||
**Verify:** HUD shows coins and lives; a coin pickup updates them.
|
||||
|
||||
## Slice 10 — swap the call sites
|
||||
|
||||
Mechanical and compiler-driven — sealing the aggregates turns every old write into a compile error, which is the
|
||||
cheapest way to find them all.
|
||||
|
||||
- `Events/` handlers become thin adapters that call use cases. `CoinStateHandler` already has this shape.
|
||||
- `ConsoleManager` calls use cases instead of writing fields and hand-re-emitting signals.
|
||||
- `AchievementManager` reads and writes the `Profile` instead of reaching into `PlayerState`.
|
||||
- `SkillManager` reconciles `ActiveComponents` from `SkillSetChanged` — `ApplyUnlockedSkills` becomes the only
|
||||
path, so the four two-call pairs collapse to one call each and a skill can no longer end up owned but inert.
|
||||
- Delete `SaveSystem`, `PlayerState`, `SessionState`, `SaveDataDto`.
|
||||
|
||||
**Verify:** compiler first, then play one level start to finish including a purchase.
|
||||
|
||||
## Slice 11 — skill level off the asset
|
||||
|
||||
- `ISkill.Initialize(Node owner, SkillData data, int level)`. Update `BrickThrowComponent:90`,
|
||||
`MagneticSkillComponent:97`, `BrickArmorSkillComponent:25` to use the passed level rather than `Data.Level`.
|
||||
`BrickArmorSkillComponent` indexes `Upgrades[Level - 1]` unguarded — fix while you're there.
|
||||
- Delete `SkillData.Level`. `SkillData` becomes immutable authored data (CONTEXT.md).
|
||||
- `MarketplaceButton` and `Marketplace` read the level from the Playthrough.
|
||||
|
||||
**Verify:** buy a skill, upgrade it, quit, continue — level persists. Game over, New Game — level is gone. Both
|
||||
are currently wrong.
|
||||
|
||||
**Landmine to note:** five skills (`brick_armor`, `brick_shield`, `double_jump`, `ground_pound_skill`,
|
||||
`wall_jump`) have no `upgrades/` entries, so `Upgrades.Count == 0`. `TryUnlockSkill` would throw on
|
||||
`Upgrades[0].Cost`; it's currently unreachable only because `MarketplaceButton` hides zero-upgrade skills. Decide
|
||||
whether those skills are purchasable at all and make the domain say so.
|
||||
|
||||
## Slice 12 — one death path
|
||||
|
||||
- Delete the 18 `Death` → `OnPlayerDeath` connections across `objects/level/base_level.tscn` and the eight level
|
||||
scenes.
|
||||
- `DeathScreen` and `GameOverScreen` subscribe to republished `LivesChanged` / `RunEnded`.
|
||||
- `DeathScreen` currently reads `Player.Lives` directly *and* subscribes to `LivesChanged` — it keeps only the
|
||||
subscription.
|
||||
|
||||
**Verify:** die with lives remaining → scene reloads. Die on the last life → game over appears. The second case
|
||||
does not work today.
|
||||
|
||||
## Slice 13 — delete the dead surface
|
||||
|
||||
- The four never-emitted signals (`LevelRestarted`, `EnemyDamaged`, `SkillActivated`, `SkillDeactivated`).
|
||||
- Of the eleven emitted-but-unheard: keep the ones the republisher now feeds, delete the speculative ones.
|
||||
- Rename `EventBus.SkillUnlocked` — it collides with `SkillUnlockerComponent.SkillUnlocked`, and every apparent
|
||||
subscriber is bound to the component's.
|
||||
- `SaveSystem.DeleteSave`, `SpeedRunManager.StopTimer`, `AchievementManager.IsAchievementUnlocked`,
|
||||
`Constants.CoinsGroup` — zero callers each.
|
||||
- `SpeedRunManager`: `StopTimer` carries a "save personal best" comment for logic that never existed. Best times
|
||||
are the Profile's job now; delete the comment and the dead method.
|
||||
|
||||
---
|
||||
|
||||
## Order dependencies
|
||||
|
||||
```
|
||||
0 ──> 1 ──> 2 ──┐
|
||||
3 ──┼──> 5 ──> 6 ──> 9 ──> 10 ──> 11 ──> 12 ──> 13
|
||||
4 ──┘ ↑
|
||||
7 ───┘
|
||||
8 ───┘
|
||||
```
|
||||
|
||||
2, 3 and 4 are independent of each other after 1. 7 and 8 can be done any time after 5, and both must precede 9.
|
||||
11, 12 and 13 each require 10.
|
||||
@@ -21,6 +21,7 @@ msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=1
|
||||
subpixel_positioning=1
|
||||
keep_rounding_remainders=true
|
||||
|
||||
@@ -21,6 +21,7 @@ msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=1
|
||||
subpixel_positioning=1
|
||||
keep_rounding_remainders=true
|
||||
|
||||
@@ -21,6 +21,7 @@ msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=1
|
||||
subpixel_positioning=1
|
||||
keep_rounding_remainders=true
|
||||
|
||||
@@ -21,6 +21,7 @@ msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=1
|
||||
subpixel_positioning=1
|
||||
keep_rounding_remainders=true
|
||||
|
||||
@@ -4,12 +4,8 @@
|
||||
[ext_resource type="Resource" uid="uid://vgutbpovj8hc" path="res://resources/movement_presets/platform_movement.tres" id="2_7til7"]
|
||||
[ext_resource type="Shader" uid="uid://bs4xvm4qkurpr" path="res://shaders/hit_flash.tres" id="2_lgb3u"]
|
||||
[ext_resource type="Texture2D" uid="uid://jl1gwqchhpdc" path="res://sprites/left_eye.png" id="3_2srrh"]
|
||||
[ext_resource type="Script" uid="uid://bf4yclropol43" path="res://scripts/components/Movement/GroundMovementAbility.cs" id="4_7til7"]
|
||||
[ext_resource type="Texture2D" uid="uid://iiawtnwmeny3" path="res://sprites/right_eye.png" id="4_ccn81"]
|
||||
[ext_resource type="Texture2D" uid="uid://0l454rfplmqg" path="res://sprites/MrBrick_base-sheet.png" id="5_yysbb"]
|
||||
[ext_resource type="Script" uid="uid://chgw53qwt7rt8" path="res://scripts/components/Movement/GravityAbility.cs" id="6_xuhvf"]
|
||||
[ext_resource type="Script" uid="uid://ccksp2e76s7sr" path="res://scripts/components/Movement/VariableJumpAbility.cs" id="7_bl1gx"]
|
||||
[ext_resource type="Script" uid="uid://ck6kmnbwhsttt" path="res://scripts/components/Movement/OneWayPlatformAbility.cs" id="7_uno3u"]
|
||||
[ext_resource type="Texture2D" uid="uid://dhkwyv6ayb5qb" path="res://sprites/flying_ship.png" id="8_6lsog"]
|
||||
[ext_resource type="Script" uid="uid://dy78ak8eykw6e" path="res://scripts/components/FlipComponent.cs" id="9_yysbb"]
|
||||
[ext_resource type="Script" uid="uid://mnjg3p0aw1ow" path="res://scripts/components/CanPickUpComponent.cs" id="10_yysbb"]
|
||||
@@ -69,7 +65,7 @@ point_count = 2
|
||||
[sub_resource type="CurveTexture" id="CurveTexture_xoue7"]
|
||||
curve = SubResource("Curve_82d6e")
|
||||
|
||||
[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_e5pae"]
|
||||
[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_oxudy"]
|
||||
resource_local_to_scene = true
|
||||
lifetime_randomness = 1.0
|
||||
particle_flag_disable_z = true
|
||||
@@ -96,25 +92,6 @@ metadata/_custom_type_script = "uid://csel4s0e4g5uf"
|
||||
|
||||
[node name="Movements" type="Node" parent="." unique_id=1545001]
|
||||
|
||||
[node name="GroundMovementAbility" type="Node" parent="Movements" unique_id=1025594633]
|
||||
script = ExtResource("4_7til7")
|
||||
MaxSpeed = 376.0
|
||||
Friction = 2500.0
|
||||
metadata/_custom_type_script = "uid://bf4yclropol43"
|
||||
|
||||
[node name="GravityAbility" type="Node" parent="Movements" unique_id=1144662059]
|
||||
script = ExtResource("6_xuhvf")
|
||||
metadata/_custom_type_script = "uid://chgw53qwt7rt8"
|
||||
|
||||
[node name="VariableJumpAbility" type="Node" parent="Movements" unique_id=632375899]
|
||||
script = ExtResource("7_bl1gx")
|
||||
JumpCutMultiplier = 0.507
|
||||
metadata/_custom_type_script = "uid://ccksp2e76s7sr"
|
||||
|
||||
[node name="OneWayPlatformAbility" type="Node" parent="Movements" unique_id=800681205]
|
||||
script = ExtResource("7_uno3u")
|
||||
metadata/_custom_type_script = "uid://ck6kmnbwhsttt"
|
||||
|
||||
[node name="Graphics" type="Node2D" parent="." unique_id=271317654]
|
||||
|
||||
[node name="Root" type="Node2D" parent="Graphics" unique_id=2012260442]
|
||||
@@ -247,7 +224,7 @@ offset_right = 23.0
|
||||
offset_bottom = -20.0
|
||||
|
||||
[node name="HitParticles" parent="." unique_id=1322585720 instance=ExtResource("28_jh5m0")]
|
||||
process_material = SubResource("ParticleProcessMaterial_e5pae")
|
||||
process_material = SubResource("ParticleProcessMaterial_oxudy")
|
||||
|
||||
[node name="ShipShooter" type="Node" parent="." unique_id=1147013800 node_paths=PackedStringArray("BulletSpawn", "ShootSfx")]
|
||||
script = ExtResource("30_usc1p")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[gd_scene load_steps=5 format=3 uid="uid://c40r76qqacqie"]
|
||||
[gd_scene format=3 uid="uid://c40r76qqacqie"]
|
||||
|
||||
[ext_resource type="Texture2D" uid="uid://cw42lvnqxubq2" path="res://sprites/PS_Tileset_10_nes.png" id="1_jmsgb"]
|
||||
[ext_resource type="Texture2D" uid="uid://cysarpu6snb2y" path="res://sprites/ppc-tileset.png" id="1_jmsgb"]
|
||||
[ext_resource type="Script" uid="uid://xqhrb1c7f6y4" path="res://scripts/components/CollapsableComponent.cs" id="2_jmsgb"]
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_l44jt"]
|
||||
@@ -9,30 +9,30 @@ size = Vector2(16, 16)
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_sfbe2"]
|
||||
size = Vector2(16, 2)
|
||||
|
||||
[node name="Collapsing Block" type="StaticBody2D"]
|
||||
[node name="Collapsing Block" type="StaticBody2D" unique_id=1834310310]
|
||||
|
||||
[node name="Sprite2D" type="Sprite2D" parent="."]
|
||||
[node name="Sprite2D" type="Sprite2D" parent="." unique_id=2120289038]
|
||||
texture = ExtResource("1_jmsgb")
|
||||
hframes = 16
|
||||
vframes = 12
|
||||
frame = 182
|
||||
hframes = 64
|
||||
vframes = 64
|
||||
frame = 710
|
||||
|
||||
[node name="StaticCollisionShape2D" type="CollisionShape2D" parent="."]
|
||||
[node name="StaticCollisionShape2D" type="CollisionShape2D" parent="." unique_id=196070485]
|
||||
shape = SubResource("RectangleShape2D_l44jt")
|
||||
|
||||
[node name="Collapsable detector" type="Area2D" parent="."]
|
||||
[node name="Collapsable detector" type="Area2D" parent="." unique_id=1910035087]
|
||||
collision_mask = 4
|
||||
|
||||
[node name="AreaCollisionShape2D" type="CollisionShape2D" parent="Collapsable detector"]
|
||||
[node name="AreaCollisionShape2D" type="CollisionShape2D" parent="Collapsable detector" unique_id=1903927379]
|
||||
position = Vector2(0, -8)
|
||||
shape = SubResource("RectangleShape2D_sfbe2")
|
||||
|
||||
[node name="ToCollapseTimer" type="Timer" parent="."]
|
||||
[node name="ToCollapseTimer" type="Timer" parent="." unique_id=2109761872]
|
||||
wait_time = 0.5
|
||||
|
||||
[node name="ResetTimer" type="Timer" parent="."]
|
||||
[node name="ResetTimer" type="Timer" parent="." unique_id=1072118129]
|
||||
|
||||
[node name="CollapsableComponent" type="Node" parent="." node_paths=PackedStringArray("ToCollapseTimer", "ResetTimer", "Sprite2D", "CollisionShape")]
|
||||
[node name="CollapsableComponent" type="Node" parent="." unique_id=1696909259 node_paths=PackedStringArray("ToCollapseTimer", "ResetTimer", "Sprite2D", "CollisionShape")]
|
||||
script = ExtResource("2_jmsgb")
|
||||
ToCollapseTimer = NodePath("../ToCollapseTimer")
|
||||
ResetTimer = NodePath("../ResetTimer")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=4 format=3 uid="uid://cm3rixnnev1pg"]
|
||||
[gd_scene format=3 uid="uid://cm3rixnnev1pg"]
|
||||
|
||||
[ext_resource type="Texture2D" uid="uid://djifxc5x0dyrw" path="res://sprites/ppc_tileset.png" id="1_ctugi"]
|
||||
[ext_resource type="Script" uid="uid://bgbnof7aeydmq" path="res://scripts/components/JumpPadComponent.cs" id="2_huktk"]
|
||||
@@ -6,23 +6,23 @@
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_ci3ij"]
|
||||
size = Vector2(16, 6)
|
||||
|
||||
[node name="Jump pad" type="Area2D"]
|
||||
[node name="Jump pad" type="Area2D" unique_id=986549607]
|
||||
collision_layer = 0
|
||||
collision_mask = 4
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=1755302504]
|
||||
position = Vector2(0, 5)
|
||||
shape = SubResource("RectangleShape2D_ci3ij")
|
||||
|
||||
[node name="Sprite2D" type="Sprite2D" parent="."]
|
||||
[node name="Sprite2D" type="Sprite2D" parent="." unique_id=1150967280]
|
||||
texture = ExtResource("1_ctugi")
|
||||
hframes = 12
|
||||
vframes = 12
|
||||
frame = 120
|
||||
|
||||
[node name="JumpPadComponent" type="Node" parent="." node_paths=PackedStringArray("Area", "Sprite")]
|
||||
[node name="JumpPadComponent" type="Node" parent="." unique_id=1295810804 node_paths=PackedStringArray("Area", "Sprite")]
|
||||
script = ExtResource("2_huktk")
|
||||
JumpForce = 1110.0
|
||||
JumpForce = 1210.0
|
||||
Area = NodePath("..")
|
||||
Sprite = NodePath("../Sprite2D")
|
||||
StartAnimationIndex = 120
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
[gd_scene format=3 uid="uid://b4eifkc31jsun"]
|
||||
[gd_scene load_steps=3 format=3 uid="uid://b4eifkc31jsun"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://c6eoi3ymefc0x" path="res://Autoloads/GameManager.cs" id="1_t2tr6"]
|
||||
[ext_resource type="PackedScene" uid="uid://dwr3avefuwnpy" path="res://scenes/level_tutorial_1.tscn" id="2_7rb6w"]
|
||||
[ext_resource type="PackedScene" uid="uid://bol7g83v2accs" path="res://scenes/level_village_1.tscn" id="2_bentb"]
|
||||
[ext_resource type="PackedScene" uid="uid://chqb11pfoqmeb" path="res://scenes/level_village_2.tscn" id="3_ajlkg"]
|
||||
[ext_resource type="PackedScene" uid="uid://h60obxmju6mo" path="res://scenes/level_village_3.tscn" id="4_se5tb"]
|
||||
[ext_resource type="PackedScene" uid="uid://bhad760x3vvco" path="res://scenes/level_village_4.tscn" id="5_mnosh"]
|
||||
[ext_resource type="PackedScene" uid="uid://bbwef3n2gjkt8" path="res://scenes/level_village_5.tscn" id="6_7rb6w"]
|
||||
[ext_resource type="Script" uid="uid://chrhjch4ymfvr" path="res://scripts/Screenshot.cs" id="6_bbtu1"]
|
||||
|
||||
[node name="GameManager" type="Node" unique_id=2076336659]
|
||||
script = ExtResource("1_t2tr6")
|
||||
LevelScenes = Array[PackedScene]([ExtResource("2_7rb6w"), ExtResource("2_bentb"), ExtResource("3_ajlkg"), ExtResource("4_se5tb"), ExtResource("5_mnosh"), ExtResource("6_7rb6w")])
|
||||
|
||||
[node name="Screenshot" type="Node" parent="." unique_id=812485936]
|
||||
script = ExtResource("6_bbtu1")
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -4,6 +4,6 @@
|
||||
|
||||
[node name="VariableJumpAbility" type="Node" unique_id=735694377]
|
||||
script = ExtResource("1_y30i5")
|
||||
JumpHeight = 120.0
|
||||
JumpHeight = 112.0
|
||||
JumpCutMultiplier = 0.507
|
||||
metadata/_custom_type_script = "uid://ccksp2e76s7sr"
|
||||
|
||||
9
objects/progression_root.tscn
Normal file
9
objects/progression_root.tscn
Normal file
@@ -0,0 +1,9 @@
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://Autoloads/ProgressionRoot.cs" id="1_script"]
|
||||
[ext_resource type="Resource" path="res://resources/levels/level_catalogue.tres" id="2_catalogue"]
|
||||
|
||||
[node name="ProgressionRoot" type="Node"]
|
||||
script = ExtResource("1_script")
|
||||
Catalogue = ExtResource("2_catalogue")
|
||||
StartingLives = 3
|
||||
@@ -1,12 +1,12 @@
|
||||
[gd_scene load_steps=4 format=3 uid="uid://cvfsbiy5ggrpg"]
|
||||
[gd_scene format=3 uid="uid://cvfsbiy5ggrpg"]
|
||||
|
||||
[ext_resource type="PackedScene" uid="uid://bxpr4m7lq7clh" path="res://objects/ui/input_button.tscn" id="1_h8s4o"]
|
||||
[ext_resource type="Script" uid="uid://input_settings_script" path="res://scripts/UI/InputSettings.cs" id="2_inputs"]
|
||||
[ext_resource type="Script" uid="uid://dqefxy5hke5a" path="res://scripts/UI/InputSettings.cs" id="2_inputs"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_se25o"]
|
||||
bg_color = Color(0, 0, 0, 1)
|
||||
|
||||
[node name="Input Settings" type="Control" node_paths=PackedStringArray("ActionsContainer", "ResetButton", "InputSettingsControl")]
|
||||
[node name="Input Settings" type="Control" unique_id=1872609318 node_paths=PackedStringArray("ActionsContainer", "ResetButton", "InputSettingsControl")]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
@@ -19,7 +19,7 @@ ResetButton = NodePath("PanelContainer/MarginContainer/VBoxContainer/Reset to de
|
||||
InputSettingsControl = NodePath(".")
|
||||
InputButtonScene = ExtResource("1_h8s4o")
|
||||
|
||||
[node name="PanelContainer" type="PanelContainer" parent="."]
|
||||
[node name="PanelContainer" type="PanelContainer" parent="." unique_id=287000043]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
@@ -28,33 +28,33 @@ grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_se25o")
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="PanelContainer"]
|
||||
[node name="MarginContainer" type="MarginContainer" parent="PanelContainer" unique_id=64621467]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 8
|
||||
theme_override_constants/margin_top = 16
|
||||
theme_override_constants/margin_right = 8
|
||||
theme_override_constants/margin_bottom = 16
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer"]
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer" unique_id=2057149310]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Label" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
||||
[node name="Label" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer" unique_id=1153692820]
|
||||
layout_mode = 2
|
||||
text = "INPUT"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
uppercase = true
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="PanelContainer/MarginContainer/VBoxContainer" unique_id=1654052120]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="Actions" type="VBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/ScrollContainer"]
|
||||
[node name="Actions" type="VBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/ScrollContainer" unique_id=484943157]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="Reset to default Button" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
||||
[node name="Reset to default Button" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer" unique_id=337239596]
|
||||
layout_mode = 2
|
||||
text = "RESET_TO_DEFAULT_BUTTON"
|
||||
flat = true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=4 format=3 uid="uid://i6mnjbjcoqe5"]
|
||||
[gd_scene format=3 uid="uid://i6mnjbjcoqe5"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://cakgxndurgfa3" path="res://scripts/UI/PauseMenu.cs" id="1_h4pd5"]
|
||||
[ext_resource type="PackedScene" uid="uid://cl00e2ocomk3m" path="res://scenes/main_menu.tscn" id="2_h4pd5"]
|
||||
@@ -6,7 +6,7 @@
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_g4ivv"]
|
||||
bg_color = Color(0, 0, 0, 1)
|
||||
|
||||
[node name="Pause menu" type="Control" node_paths=PackedStringArray("PauseMenuControl", "ResumeButton", "MainMenuButton", "QuitButton", "SettingsButton")]
|
||||
[node name="Pause menu" type="Control" unique_id=923659871 node_paths=PackedStringArray("PauseMenuControl", "ResumeButton", "MainMenuButton", "QuitButton", "SettingsButton")]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
@@ -23,7 +23,7 @@ QuitButton = NodePath("PanelContainer/MarginContainer/VBoxContainer/Quit game Bu
|
||||
SettingsButton = NodePath("PanelContainer/MarginContainer/VBoxContainer/Settings Button")
|
||||
MainMenuScene = ExtResource("2_h4pd5")
|
||||
|
||||
[node name="PanelContainer" type="PanelContainer" parent="."]
|
||||
[node name="PanelContainer" type="PanelContainer" parent="." unique_id=1782689001]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
@@ -32,46 +32,52 @@ grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_g4ivv")
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="PanelContainer"]
|
||||
[node name="MarginContainer" type="MarginContainer" parent="PanelContainer" unique_id=824088919]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 8
|
||||
theme_override_constants/margin_top = 8
|
||||
theme_override_constants/margin_right = 8
|
||||
theme_override_constants/margin_bottom = 8
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer"]
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer" unique_id=1826945529]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 4
|
||||
size_flags_vertical = 4
|
||||
|
||||
[node name="Pause" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
||||
[node name="Pause" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer" unique_id=1706564131]
|
||||
layout_mode = 2
|
||||
text = "PAUSE"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
uppercase = true
|
||||
|
||||
[node name="Spacer" type="Control" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
||||
[node name="Spacer" type="Control" parent="PanelContainer/MarginContainer/VBoxContainer" unique_id=868621584]
|
||||
custom_minimum_size = Vector2(0, 32)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="Resume Button" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
||||
[node name="Resume Button" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer" unique_id=826636099]
|
||||
layout_mode = 2
|
||||
focus_next = NodePath("../Settings Button")
|
||||
text = "RESUME_BUTTON"
|
||||
flat = true
|
||||
|
||||
[node name="Settings Button" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
||||
[node name="Settings Button" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer" unique_id=334350065]
|
||||
layout_mode = 2
|
||||
focus_next = NodePath("../Exit to menu Button")
|
||||
focus_previous = NodePath("../Resume Button")
|
||||
text = "SETTINGS_BUTTON"
|
||||
flat = true
|
||||
|
||||
[node name="Exit to menu Button" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
||||
[node name="Exit to menu Button" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer" unique_id=683937353]
|
||||
layout_mode = 2
|
||||
focus_next = NodePath("../Quit game Button")
|
||||
focus_previous = NodePath("../Settings Button")
|
||||
text = "EXIT_TO_MENU_BUTTON"
|
||||
flat = true
|
||||
|
||||
[node name="Quit game Button" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
||||
[node name="Quit game Button" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer" unique_id=2008979602]
|
||||
layout_mode = 2
|
||||
focus_previous = NodePath("../Exit to menu Button")
|
||||
text = "QUIT_BUTTON"
|
||||
flat = true
|
||||
|
||||
0
progression.adapters/.gdignore
Normal file
0
progression.adapters/.gdignore
Normal file
77
progression.adapters/JsonFiles.cs
Normal file
77
progression.adapters/JsonFiles.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Mr.BrickAdventures.Progression.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Reading and writing versioned save files. Shared by the Playthrough and Profile stores, which
|
||||
/// differ only in what they hold and where they live.
|
||||
/// </summary>
|
||||
internal static class JsonFiles
|
||||
{
|
||||
internal static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Writes to a temporary file and then renames it over the destination. A crash mid-write leaves
|
||||
/// the previous file intact rather than a half-written one — which matters more once there are
|
||||
/// several slots to lose.
|
||||
/// </summary>
|
||||
internal static void WriteAtomic<T>(string path, int version, T data)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
var temp = path + ".tmp";
|
||||
File.WriteAllText(temp, JsonSerializer.Serialize(new SaveEnvelope<T>(version, data), Options));
|
||||
File.Move(temp, path, overwrite: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a save file. Absent when there is no file; Corrupt when there is one but it cannot be
|
||||
/// turned into <typeparamref name="TAggregate"/> — unreadable JSON, an unknown version, or a
|
||||
/// snapshot the domain rejects. Never conflates the two: that conflation is what let a version
|
||||
/// bump silently overwrite a player's save.
|
||||
/// </summary>
|
||||
internal static LoadResult<TAggregate> Read<TSnapshot, TAggregate>(
|
||||
string path,
|
||||
int currentVersion,
|
||||
Func<TSnapshot, TAggregate> restore)
|
||||
where TSnapshot : class
|
||||
where TAggregate : class
|
||||
{
|
||||
if (!File.Exists(path)) return LoadResult<TAggregate>.Absent();
|
||||
|
||||
SaveEnvelope<TSnapshot>? envelope;
|
||||
try
|
||||
{
|
||||
envelope = JsonSerializer.Deserialize<SaveEnvelope<TSnapshot>>(File.ReadAllText(path), Options);
|
||||
}
|
||||
catch (JsonException e)
|
||||
{
|
||||
return LoadResult<TAggregate>.Corrupt($"unreadable json: {e.Message}");
|
||||
}
|
||||
|
||||
if (envelope?.Data is null)
|
||||
return LoadResult<TAggregate>.Corrupt("file held no save data");
|
||||
|
||||
if (envelope.Version > currentVersion)
|
||||
return LoadResult<TAggregate>.Corrupt(
|
||||
$"saved by a newer build (version {envelope.Version}, this build reads {currentVersion})");
|
||||
|
||||
if (envelope.Version < currentVersion)
|
||||
return LoadResult<TAggregate>.Corrupt(
|
||||
$"version {envelope.Version} has no migration to {currentVersion}");
|
||||
|
||||
try
|
||||
{
|
||||
return LoadResult<TAggregate>.Loaded(restore(envelope.Data));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return LoadResult<TAggregate>.Corrupt($"snapshot rejected by the domain: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
30
progression.adapters/JsonPlaythroughStore.cs
Normal file
30
progression.adapters/JsonPlaythroughStore.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
namespace Mr.BrickAdventures.Progression.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps each Playthrough in its own JSON file inside a directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Takes a directory path rather than using Godot's FileAccess. Godot resolves <c>user://</c> to an
|
||||
/// absolute path with <c>ProjectSettings.GlobalizePath</c> and passes it in, so this whole class has
|
||||
/// no engine dependency and can be tested against a real temp directory.
|
||||
/// </remarks>
|
||||
public sealed class JsonPlaythroughStore : IPlaythroughStore
|
||||
{
|
||||
/// <summary>Bump only alongside a migration for the version being replaced.</summary>
|
||||
private const int CurrentVersion = 1;
|
||||
|
||||
private readonly string _directory;
|
||||
|
||||
public JsonPlaythroughStore(string directory) => _directory = directory;
|
||||
|
||||
/// <summary>One file per slot, so saving a run never rewrites the Profile or another slot.</summary>
|
||||
private string PathFor(PlaythroughId id) =>
|
||||
Path.Combine(_directory, $"playthrough_{id.Value:N}.json");
|
||||
|
||||
public void Save(Playthrough playthrough) =>
|
||||
JsonFiles.WriteAtomic(PathFor(playthrough.Id), CurrentVersion, playthrough.Export());
|
||||
|
||||
public LoadResult<Playthrough> Load(PlaythroughId id) =>
|
||||
JsonFiles.Read<PlaythroughSnapshot, Playthrough>(
|
||||
PathFor(id), CurrentVersion, Playthrough.Restore);
|
||||
}
|
||||
20
progression.adapters/JsonProfileStore.cs
Normal file
20
progression.adapters/JsonProfileStore.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace Mr.BrickAdventures.Progression.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the Profile in its own JSON file, beside the Playthrough files but written independently.
|
||||
/// </summary>
|
||||
public sealed class JsonProfileStore : IProfileStore
|
||||
{
|
||||
/// <summary>Versioned separately from Playthroughs — the two shapes drift at their own pace.</summary>
|
||||
private const int CurrentVersion = 1;
|
||||
|
||||
private readonly string _path;
|
||||
|
||||
public JsonProfileStore(string directory) => _path = Path.Combine(directory, "profile.json");
|
||||
|
||||
public void Save(Profile profile) =>
|
||||
JsonFiles.WriteAtomic(_path, CurrentVersion, profile.Export());
|
||||
|
||||
public LoadResult<Profile> Load() =>
|
||||
JsonFiles.Read<ProfileSnapshot, Profile>(_path, CurrentVersion, Profile.Restore);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\progression\Mr.BrickAdventures.Progression.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Mr.BrickAdventures.Progression.Adapters</RootNamespace>
|
||||
|
||||
<!-- Required on assemblies referenced by a Godot project. -->
|
||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Deliberately no engine reference. These adapters take a plain directory path; Godot resolves
|
||||
user:// to an absolute path and passes it in, which is what keeps them testable. -->
|
||||
|
||||
|
||||
</Project>
|
||||
10
progression.adapters/SaveEnvelope.cs
Normal file
10
progression.adapters/SaveEnvelope.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Mr.BrickAdventures.Progression.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// What actually goes in a save file: a version alongside the snapshot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The version lives here rather than in the snapshot record so the domain only ever knows the
|
||||
/// current shape. Migration is entirely this layer's problem.
|
||||
/// </remarks>
|
||||
internal sealed record SaveEnvelope<T>(int Version, T Data);
|
||||
0
progression.tests/.gdignore
Normal file
0
progression.tests/.gdignore
Normal file
98
progression.tests/AttemptTests.cs
Normal file
98
progression.tests/AttemptTests.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
namespace Mr.BrickAdventures.Progression.Tests;
|
||||
|
||||
public class AttemptTests
|
||||
{
|
||||
[Fact]
|
||||
public void An_attempt_cannot_die_twice()
|
||||
{
|
||||
// Overlapping hazards in a kill zone, or a hazard re-firing during the two seconds before
|
||||
// DeathScreen reloads the scene, must not cost two lives. The Attempt refuses the second
|
||||
// death, so no caller has to remember to guard against it.
|
||||
var attempt = new Attempt(LevelId.Of("village_1"));
|
||||
|
||||
Assert.True(attempt.TryEndInDeath());
|
||||
Assert.False(attempt.TryEndInDeath());
|
||||
Assert.Equal(AttemptOutcome.Died, attempt.Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_attempt_that_died_cannot_then_be_cleared()
|
||||
{
|
||||
var attempt = new Attempt(LevelId.Of("village_1"));
|
||||
attempt.TryEndInDeath();
|
||||
|
||||
Assert.False(attempt.TryEndInCompletion());
|
||||
Assert.Equal(AttemptOutcome.Died, attempt.Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_attempt_that_was_cleared_cannot_then_die()
|
||||
{
|
||||
// The reverse of the above. Together these say a terminal outcome is final, which is what
|
||||
// lets both ExitDoorComponent and the console request completion without banking twice.
|
||||
var attempt = new Attempt(LevelId.Of("village_1"));
|
||||
attempt.TryEndInCompletion();
|
||||
|
||||
Assert.False(attempt.TryEndInDeath());
|
||||
Assert.Equal(AttemptOutcome.Cleared, attempt.Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Coins_found_after_the_attempt_ended_do_not_count()
|
||||
{
|
||||
// The player node survives for two seconds after death while DeathScreen counts down, so a
|
||||
// coin's Area2D can still fire. That is a real event rather than an error, and it must not
|
||||
// enlarge a ledger that is already settled.
|
||||
var attempt = new Attempt(LevelId.Of("village_1"));
|
||||
attempt.RecordCoinsFound(Coins.Of(3));
|
||||
attempt.RecordCoinsFound(Coins.Of(2));
|
||||
attempt.TryEndInDeath();
|
||||
|
||||
attempt.RecordCoinsFound(Coins.Of(100));
|
||||
|
||||
Assert.Equal(Coins.Of(5), attempt.CoinsFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_purchase_after_the_attempt_ended_is_refused()
|
||||
{
|
||||
var attempt = new Attempt(LevelId.Of("village_1"));
|
||||
attempt.TryEndInDeath();
|
||||
|
||||
var recorded = attempt.TryRecordPurchase(SkillId.Of("brick_throw"), SkillLevel.Of(1), Coins.Of(10));
|
||||
|
||||
Assert.False(recorded);
|
||||
Assert.Equal(Coins.Zero, attempt.CoinsSpent);
|
||||
Assert.Empty(attempt.SkillsBought);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Spending_more_than_was_found_this_attempt_is_recorded_not_rejected()
|
||||
{
|
||||
// The shortfall is drawn from the Bank when the Attempt is committed. Rejecting it here
|
||||
// would make the ledger enforce a rule it cannot see — affordability spans both scopes.
|
||||
var attempt = new Attempt(LevelId.Of("village_1"));
|
||||
attempt.RecordCoinsFound(Coins.Of(10));
|
||||
|
||||
var recorded = attempt.TryRecordPurchase(SkillId.Of("magnetic"), SkillLevel.Of(1), Coins.Of(30));
|
||||
|
||||
Assert.True(recorded);
|
||||
Assert.Equal(Coins.Of(10), attempt.CoinsFound);
|
||||
Assert.Equal(Coins.Of(30), attempt.CoinsSpent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unlocking_then_upgrading_one_skill_records_the_level_it_ended_at()
|
||||
{
|
||||
// Both happen in the marketplace mid-Attempt. Commit applies one level per skill, so the
|
||||
// ledger keeps where it ended up, while the costs accumulate.
|
||||
var brickThrow = SkillId.Of("brick_throw");
|
||||
var attempt = new Attempt(LevelId.Of("village_1"));
|
||||
|
||||
attempt.TryRecordPurchase(brickThrow, SkillLevel.Of(1), Coins.Of(10));
|
||||
attempt.TryRecordPurchase(brickThrow, SkillLevel.Of(2), Coins.Of(25));
|
||||
|
||||
Assert.Equal(SkillLevel.Of(2), attempt.SkillsBought[brickThrow]);
|
||||
Assert.Equal(Coins.Of(35), attempt.CoinsSpent);
|
||||
}
|
||||
}
|
||||
23
progression.tests/CoinsTests.cs
Normal file
23
progression.tests/CoinsTests.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace Mr.BrickAdventures.Progression.Tests;
|
||||
|
||||
public class CoinsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Subtracting_more_than_held_fails_and_leaves_the_amount_untouched()
|
||||
{
|
||||
var held = Coins.Of(10);
|
||||
|
||||
var succeeded = held.TrySubtract(Coins.Of(30), out var remaining);
|
||||
|
||||
Assert.False(succeeded);
|
||||
// Not clamped to zero. A caller that ignores the bool sees coins un-spent,
|
||||
// which is the safe outcome; clamping would silently consume what wasn't there.
|
||||
Assert.Equal(Coins.Of(10), remaining);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_negative_quantity_of_coins_cannot_exist()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => Coins.Of(-1));
|
||||
}
|
||||
}
|
||||
1
progression.tests/GlobalUsings.cs
Normal file
1
progression.tests/GlobalUsings.cs
Normal file
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
34
progression.tests/InMemoryPlaythroughStore.cs
Normal file
34
progression.tests/InMemoryPlaythroughStore.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
namespace Mr.BrickAdventures.Progression.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The test-side adapter for <see cref="IPlaythroughStore"/>. Its existence alongside the JSON store
|
||||
/// is what makes the port a real seam rather than a hypothetical one.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryPlaythroughStore : IPlaythroughStore
|
||||
{
|
||||
private readonly Dictionary<Guid, PlaythroughSnapshot> _saved = new();
|
||||
private readonly HashSet<Guid> _corrupt = new();
|
||||
|
||||
public void Save(Playthrough playthrough) => _saved[playthrough.Id.Value] = playthrough.Export();
|
||||
|
||||
/// <summary>Simulates a file that exists but cannot be read back into a valid snapshot.</summary>
|
||||
public void Corrupt(PlaythroughId id) => _corrupt.Add(id.Value);
|
||||
|
||||
public LoadResult<Playthrough> Load(PlaythroughId id)
|
||||
{
|
||||
if (_corrupt.Contains(id.Value))
|
||||
return LoadResult<Playthrough>.Corrupt("snapshot could not be read");
|
||||
|
||||
if (!_saved.TryGetValue(id.Value, out var snapshot))
|
||||
return LoadResult<Playthrough>.Absent();
|
||||
|
||||
try
|
||||
{
|
||||
return LoadResult<Playthrough>.Loaded(Playthrough.Restore(snapshot));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return LoadResult<Playthrough>.Corrupt(e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
26
progression.tests/InMemoryProfileStore.cs
Normal file
26
progression.tests/InMemoryProfileStore.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace Mr.BrickAdventures.Progression.Tests;
|
||||
|
||||
internal sealed class InMemoryProfileStore : IProfileStore
|
||||
{
|
||||
private ProfileSnapshot? _saved;
|
||||
private bool _corrupt;
|
||||
|
||||
public void Save(Profile profile) => _saved = profile.Export();
|
||||
|
||||
public void Corrupt() => _corrupt = true;
|
||||
|
||||
public LoadResult<Profile> Load()
|
||||
{
|
||||
if (_corrupt) return LoadResult<Profile>.Corrupt("snapshot could not be read");
|
||||
if (_saved is null) return LoadResult<Profile>.Absent();
|
||||
|
||||
try
|
||||
{
|
||||
return LoadResult<Profile>.Loaded(Profile.Restore(_saved));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return LoadResult<Profile>.Corrupt(e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
121
progression.tests/JsonPlaythroughStoreTests.cs
Normal file
121
progression.tests/JsonPlaythroughStoreTests.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using Mr.BrickAdventures.Progression.Adapters;
|
||||
|
||||
namespace Mr.BrickAdventures.Progression.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// These run against a real directory on disk. The store takes a path rather than using Godot's
|
||||
/// FileAccess, which is the whole reason this is testable at all.
|
||||
/// </summary>
|
||||
public sealed class JsonPlaythroughStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _directory =
|
||||
Path.Combine(Path.GetTempPath(), "ppc-progression-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
private static readonly LevelId First = LevelId.Of("village_1");
|
||||
private static readonly LevelId Second = LevelId.Of("village_2");
|
||||
|
||||
private static readonly PlaythroughId Id =
|
||||
PlaythroughId.Of(new Guid("55555555-5555-5555-5555-555555555555"));
|
||||
|
||||
private static Campaign TwoLevels() => Campaign.Of(new[] { First, Second });
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_playthrough_survives_a_round_trip_through_a_real_file()
|
||||
{
|
||||
var store = new JsonPlaythroughStore(_directory);
|
||||
var campaign = TwoLevels();
|
||||
var playthrough = Playthrough.StartNew(Id, campaign, Lives.Of(3));
|
||||
var attempt = new Attempt(First);
|
||||
attempt.RecordCoinsFound(Coins.Of(50));
|
||||
attempt.TryRecordPurchase(SkillId.Of("brick_throw"), SkillLevel.Of(2), Coins.Of(30));
|
||||
playthrough.SettleCompletion(attempt, campaign);
|
||||
|
||||
store.Save(playthrough);
|
||||
var result = store.Load(Id);
|
||||
|
||||
Assert.Equal(LoadOutcome.Loaded, result.Outcome);
|
||||
Assert.Equal(Coins.Of(20), result.Value!.Bank);
|
||||
Assert.Equal(Second, result.Value.CurrentLevel);
|
||||
Assert.Equal(SkillLevel.Of(2), result.Value.OwnedSkills[SkillId.Of("brick_throw")]);
|
||||
Assert.True(result.Value.HasCleared(First));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_file_means_absent()
|
||||
{
|
||||
Assert.Equal(LoadOutcome.Absent, new JsonPlaythroughStore(_directory).Load(Id).Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unreadable_json_is_corrupt_not_absent()
|
||||
{
|
||||
var store = new JsonPlaythroughStore(_directory);
|
||||
store.Save(Playthrough.StartNew(Id, TwoLevels(), Lives.Of(3)));
|
||||
File.WriteAllText(Directory.GetFiles(_directory, "*.json").Single(), "{ this is not json");
|
||||
|
||||
var result = store.Load(Id);
|
||||
|
||||
Assert.Equal(LoadOutcome.Corrupt, result.Outcome);
|
||||
Assert.NotNull(result.Problem);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_save_from_a_newer_build_is_corrupt_rather_than_treated_as_no_save()
|
||||
{
|
||||
// The bug this replaces, exactly: SaveSystem compared versions with != and returned false,
|
||||
// ContinueGame read false as "no save", called StartNewGame, and overwrote the file. Here the
|
||||
// caller can tell the difference and say so instead.
|
||||
var store = new JsonPlaythroughStore(_directory);
|
||||
store.Save(Playthrough.StartNew(Id, TwoLevels(), Lives.Of(3)));
|
||||
var file = Directory.GetFiles(_directory, "*.json").Single();
|
||||
File.WriteAllText(file, File.ReadAllText(file).Replace("\"version\": 1", "\"version\": 99"));
|
||||
|
||||
var result = store.Load(Id);
|
||||
|
||||
Assert.Equal(LoadOutcome.Corrupt, result.Outcome);
|
||||
Assert.Contains("newer build", result.Problem);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_snapshot_the_domain_rejects_is_corrupt()
|
||||
{
|
||||
var store = new JsonPlaythroughStore(_directory);
|
||||
store.Save(Playthrough.StartNew(Id, TwoLevels(), Lives.Of(3)));
|
||||
var file = Directory.GetFiles(_directory, "*.json").Single();
|
||||
File.WriteAllText(file, File.ReadAllText(file).Replace("\"lives\": 3", "\"lives\": -1"));
|
||||
|
||||
Assert.Equal(LoadOutcome.Corrupt, store.Load(Id).Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Saving_leaves_no_temporary_file_behind()
|
||||
{
|
||||
var store = new JsonPlaythroughStore(_directory);
|
||||
store.Save(Playthrough.StartNew(Id, TwoLevels(), Lives.Of(3)));
|
||||
store.Save(Playthrough.StartNew(Id, TwoLevels(), Lives.Of(2)));
|
||||
|
||||
Assert.Empty(Directory.GetFiles(_directory, "*.tmp"));
|
||||
Assert.Single(Directory.GetFiles(_directory, "*.json"));
|
||||
Assert.Equal(2, store.Load(Id).Value!.Lives.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Each_playthrough_gets_its_own_file_so_slots_do_not_disturb_each_other()
|
||||
{
|
||||
// The reason for a flat record per aggregate rather than one combined document.
|
||||
var other = PlaythroughId.Of(new Guid("66666666-6666-6666-6666-666666666666"));
|
||||
var store = new JsonPlaythroughStore(_directory);
|
||||
|
||||
store.Save(Playthrough.StartNew(Id, TwoLevels(), Lives.Of(3)));
|
||||
store.Save(Playthrough.StartNew(other, TwoLevels(), Lives.Of(1)));
|
||||
|
||||
Assert.Equal(2, Directory.GetFiles(_directory, "*.json").Length);
|
||||
Assert.Equal(3, store.Load(Id).Value!.Lives.Value);
|
||||
Assert.Equal(1, store.Load(other).Value!.Lives.Value);
|
||||
}
|
||||
}
|
||||
65
progression.tests/JsonProfileStoreTests.cs
Normal file
65
progression.tests/JsonProfileStoreTests.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using Mr.BrickAdventures.Progression.Adapters;
|
||||
|
||||
namespace Mr.BrickAdventures.Progression.Tests;
|
||||
|
||||
public sealed class JsonProfileStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _directory =
|
||||
Path.Combine(Path.GetTempPath(), "ppc-progression-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
private static readonly LevelId Level = LevelId.Of("village_1");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_profile_survives_a_round_trip_through_a_real_file()
|
||||
{
|
||||
var store = new JsonProfileStore(_directory);
|
||||
var profile = Profile.Empty();
|
||||
profile.RecordTime(Level, LevelTime.Of(31.25));
|
||||
profile.UnlockAchievement(AchievementId.Of("rescue_ten_kids"));
|
||||
profile.Record(StatName.Deaths, 7);
|
||||
|
||||
store.Save(profile);
|
||||
var result = store.Load();
|
||||
|
||||
Assert.Equal(LoadOutcome.Loaded, result.Outcome);
|
||||
Assert.Equal(LevelTime.Of(31.25), result.Value!.BestTimeFor(Level));
|
||||
Assert.True(result.Value.HasAchievement(AchievementId.Of("rescue_ten_kids")));
|
||||
Assert.Equal(7, result.Value.ReadStat(StatName.Deaths));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void On_a_first_run_there_is_no_profile_file_and_that_is_absent()
|
||||
{
|
||||
Assert.Equal(LoadOutcome.Absent, new JsonProfileStore(_directory).Load().Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Saving_a_playthrough_does_not_touch_the_profile_file()
|
||||
{
|
||||
// The point of one file per aggregate. A run being saved on every level completion must not
|
||||
// rewrite achievements and best times, and a corrupt slot must not cost them.
|
||||
var profiles = new JsonProfileStore(_directory);
|
||||
var playthroughs = new JsonPlaythroughStore(_directory);
|
||||
var profile = Profile.Empty();
|
||||
profile.RecordTime(Level, LevelTime.Of(31.25));
|
||||
profiles.Save(profile);
|
||||
|
||||
var profilePath = Directory.GetFiles(_directory, "profile*.json").Single();
|
||||
var before = File.GetLastWriteTimeUtc(profilePath);
|
||||
var contentBefore = File.ReadAllText(profilePath);
|
||||
|
||||
playthroughs.Save(Playthrough.StartNew(
|
||||
PlaythroughId.Of(Guid.NewGuid()),
|
||||
Campaign.Of(new[] { Level }),
|
||||
Lives.Of(3)));
|
||||
|
||||
Assert.Equal(before, File.GetLastWriteTimeUtc(profilePath));
|
||||
Assert.Equal(contentBefore, File.ReadAllText(profilePath));
|
||||
Assert.Equal(2, Directory.GetFiles(_directory, "*.json").Length);
|
||||
}
|
||||
}
|
||||
14
progression.tests/LevelIdTests.cs
Normal file
14
progression.tests/LevelIdTests.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace Mr.BrickAdventures.Progression.Tests;
|
||||
|
||||
public class LevelIdTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void A_blank_id_is_not_a_level(string value)
|
||||
{
|
||||
// Levels are addressed by a stable authored id, never by position in a list. An unset
|
||||
// field in a .tres would otherwise become a valid id that silently collides with itself.
|
||||
Assert.Throws<ArgumentException>(() => LevelId.Of(value));
|
||||
}
|
||||
}
|
||||
23
progression.tests/LevelTimeTests.cs
Normal file
23
progression.tests/LevelTimeTests.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace Mr.BrickAdventures.Progression.Tests;
|
||||
|
||||
public class LevelTimeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Zero_is_not_a_completion_time()
|
||||
{
|
||||
// GetCurrentLevelTime() returns 0 whenever the speedrun timer was never started, which
|
||||
// today is every continued run — so zero-second "best times" get written to the ghost
|
||||
// files. Rejecting zero makes that unrepresentable rather than merely unlikely.
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => LevelTime.Of(0));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(double.NaN)]
|
||||
[InlineData(double.PositiveInfinity)]
|
||||
public void A_time_that_is_not_a_finite_number_is_rejected(double seconds)
|
||||
{
|
||||
// Times are derived from subtracting tick counts, so a stale or unset start time can
|
||||
// produce NaN or infinity rather than a plausible-but-wrong number.
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => LevelTime.Of(seconds));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user