Compare commits

61 Commits

Author SHA1 Message Date
4e6d5cf6f9 no idea what is it 2026-11-05 19:19:37 +01:00
e8d1da8f89 feat: lives and death move to the new domain (commit C of the swap)
fixes the regression commit B introduced: coins and skills lived in the
Attempt ledger, but nothing discarded it, because LivesStateHandler still
called the old store and HandlePlayerDeath was never invoked. Dying kept
everything earned that level. Discard-on-death is the ledger's purpose,
so it could not be separated from it.

new AttemptFailed(remaining, runOver) event, raised after the life is
spent and the ledger discarded. DeathScreen and GameOverScreen react to
that instead of being wired to HealthComponent.Death by scene connections
which fired *before* the life was charged - on the last life game-over
saw one life left and showed nothing, then the death screen saw zero and
refused to reload. 18 scene connections deleted; the race is now
unrepresentable rather than fixed.

DeathScreen also had two read paths for lives - a signal and a direct
store read - because neither was reliable alone. Now one.

Hud reads both facts from signals; only health still polls, and that
belongs to the component rather than to progression. GameManager no
longer touches GameStateStore or SaveSystem at all.
2026-07-28 20:19:35 +02:00
360f73ba24 feat: coins and skills move to the new domain (commit B of the swap)
atomic pair - purchasing couples them, so neither moves alone.

SkillData.Level is gone. It was an [Export] on a cached Resource: one
instance per process, so the level never persisted, leaked between save
slots, and made "unowned" indistinguishable from "level 1". Level now
comes from the Playthrough and is passed to ISkill.Initialize.

SkillManager reconciles from SkillSetChanged instead of being told by
each caller. The old shape was "record it, then tell SkillManager" as two
calls at four sites; AddSkill's early return when no player is registered
left the skill owned but inert. Reconciliation also tracks the level each
component was built at, so an upgrade rebuilds it rather than being
patched in from outside.

SkillUnlockerComponent collapses to one TryBuy - unlock and upgrade were
two methods each doing affordability, debit, level mutation and manager
notification in order.

The guard three components copy-pasted around Upgrades[Level - 1] - and
which BrickArmor omitted - now lives once in SkillComponentBase.

Hud reads coins from CoinsChanged instead of rebuilding translated
strings every frame. Lives still polls; it moves with the death path.
2026-07-28 20:13:24 +02:00
b6a8448743 feat(progression): capabilities the adapters need (commit A of the swap)
no behaviour change - additive only.

GrantSkill: pickups are free unlocks, and collecting one already owned is
a no-op rather than a free upgrade.
RevokeSkill: for skills that consume themselves (brick shield).
AbandonAttempt: leaving a level without dying, so no life is charged.

ApplyCheat rewrites the Playthrough through its snapshot rather than via
SetLives/AddCoins mutators, which ADR-0005 rules out - a bare mutator is
an invitation, and the old code had two paths to exactly that. Every
value is re-validated on the way back in and a rejected cheat leaves the
Playthrough untouched, which is asserted.

73 tests.
2026-07-28 20:07:47 +02:00
f4411e05a3 fix: SpeedRunHud never detached from the autoload's signal
every level load left another handler attached to a freed HUD.
SpeedRunManager outlives the scene, so it kept calling them and each one
wrote to a disposed Label - thousands of ObjectDisposedException per
frame, growing with each load.

Latent all along: StartTimer was only ever called from StartNewGame, so
after a Continue the timer never ticked and the dead handlers never ran.
Calling StartTimer on Continue (needed for splits and best times) made
it visible.

Also stop the timer when leaving to the menu - StopTimer existed and had
no callers, so the run kept ticking in the main menu.
2026-07-28 20:00:55 +02:00
a1c9042652 feat: level progression moves to the new domain (slice 10b, step 1)
narrowest safe step: only level identity and progression move. Coins,
lives, skills and death stay entirely on GameStateStore and behave as
before, so nothing is tracked in two places.

GameManager addresses levels by stable LevelId via the catalogue;
LevelScenes is gone, so reordering levels no longer invalidates saves.
Every level entry goes through EnterLevel, which fixes LevelStarted never
firing on the first level or after Continue - ghost recording and splits
have been dead outside mid-campaign.

GoTo announces after a frame, once the incoming scene is actually
current. ChangeSceneToPacked is deferred, so the old code handed
GhostEventHandler the scene being torn down.

FinishLevel takes LevelTime? - GetCurrentLevelTime can legitimately be 0
and inventing a value is exactly the bad data LevelTime refuses.

Continue distinguishes absent from corrupt and refuses to overwrite a
corrupt save. Statistics move to the Profile. 69 tests.
2026-07-28 19:57:01 +02:00
ca54bf0f82 feat(progression): read surface and change announcements (slice 10a)
reads for the UI: TotalCoins, Lives, IsRunOver, CurrentLevel,
LevelOf/IsOwned/CanAfford, HasCleared. The read half of candidate 01 -
callers get a narrow surface instead of reaching into state.

new domain events LivesChanged / TotalCoinsChanged / SkillSetChanged,
announced by comparing against the last announced value, so an operation
can neither forget to announce nor announce something unchanged.

HandlePlayerDeath now clears the spent attempt - it was still counting
towards TotalCoins after its ledger had been discarded.

republisher and EventBus extended; SkillSetChanged is what SkillManager
will reconcile against instead of the non-atomic call pairs. 65 tests.
2026-07-28 19:50:05 +02:00
c1c3e7b020 chore: editor artifacts - catalogue typed array, script uids, compiled translations 2026-07-28 19:46:30 +02:00
627900b2e8 feat(progression): composition root and event republisher (slice 9)
ProgressionRoot autoload builds the JSON stores from
GlobalizePath("user://"), loads the Profile, reads the authored upgrade
tables into a SkillCatalogue, and owns the ProgressionService. Every
mutating operation is wrapped so draining and republishing cannot be
forgotten - the one risk of the DrainEvents design, now confined to one
file.

ProgressionEventRepublisher turns domain records into EventBus signals
(ADR-0004), so existing subscribers and scene wiring keep working. New
RunEnded signal: GameOverScreen reads Lives directly today, which is the
last-life soft-lock.

Registered after SkillManager, since the skill catalogue is built from
AvailableSkills. GameStateStore stays until slice 10 moves the callers.

Transitional: LevelStarted is republished with a catalogue index because
GhostManager names files ghost_level_{index}.json.
2026-07-28 19:43:04 +02:00
f85c16c641 fix(levels): ScenePath must be a string, not a PackedScene
slice 7 regression. level_village_2..5.tscn each reference their own
LevelResource for the death screen label, so pointing the resource back
at the scene forms a cycle Godot cannot resolve - "Parse Error: Busy"
and the level fails to load entirely.

ScenePath was a string for exactly this reason. Reverted, and the rot it
allowed (forest_1 aimed at a nonexistent scene) is now caught by
LevelCatalogue.Validate checking every path resolves, plus a repo-level
structural check - fixed by validation rather than by typing.

project.godot: restores the C# feature flag and dialogue_manager
uses_dotnet, which `godot --headless --import` strips on every run.
2026-07-28 19:40:02 +02:00
7a69292066 feat(progression): json stores on a real filesystem (slice 8)
new progression.adapters project, no engine reference. Stores take a
directory path; Godot resolves user:// with GlobalizePath and passes it
in, which is what makes them testable at all - originally I'd written
this slice off as untestable.

version lives in a file envelope, not the snapshot, so the domain only
knows the current shape. A newer-build save reports Corrupt with a
reason instead of masquerading as "no save" and getting overwritten.

atomic write via temp + rename. One file per aggregate: saving a run
leaves the profile file byte-identical, asserted.

guard rail fixed - it was matching prose in doc comments, now anchored
to using-directives plus a project.assets.json check. 63 tests.
2026-07-28 19:35:43 +02:00
f991bb99fc feat(levels): level catalogue with stable ids (slice 7)
LevelResource gains a stable Id and swaps the string ScenePath for an
editor-tracked PackedScene. The old path field was never read and had
rotted - forest_1 pointed at level_forest_5.tscn, which doesn't exist,
and borrowed LEVEL_5_NAME so it would have displayed "VILLAGE - 5".

LevelCatalogue lists levels in campaign order and produces the domain's
Campaign; an authoring mistake fails at boot because Campaign.Of rejects
blank and duplicate ids. Not a domain port - nothing in the domain
resolves a level to a scene.

All 8 scenes now have a resource; campaign keeps the existing 6
(tutorial_1, village_1..5). village_6 and forest_1 are catalogued but
deliberately out of the campaign.

GameManager.LevelScenes stays until slice 10 rewrites GameManager.
2026-07-28 19:28:56 +02:00
330e10880c feat(progression): use cases (slice 6)
ProgressionService holds the loaded Profile, active Playthrough and
active Attempt, so "exactly one active Attempt" has a testable home.

EnterLevel always announces LevelStarted - the reason ghost recording
and splits never worked on level 1 or after a Continue. Refuses
unreachable levels. Does not touch campaign position, so a Replay
leaves it alone.

SkillCatalogue returns null for a skill with no authored upgrades,
killing the Upgrades[0].Cost overrun. PurchaseSkill reads the current
level from the ledger first, so one Attempt can unlock then upgrade.
HandlePlayerDeath now persists - death was free if you quit.

SkillLevel.First/Next driven out here by a real caller. 53 tests.
2026-07-28 19:10:06 +02:00
003ec4b010 feat(progression): snapshots, ports, in-memory adapters (slice 5)
flat snapshot record per aggregate - separate files mean saving a run
doesn't rewrite the Profile, deleting a slot is deleting a file, and a
corrupt slot doesn't cost achievements or best times.

snapshots hold primitives so Restore re-runs the value-object factories;
a serialiser building LevelId directly would bypass every guard.

LoadResult has three outcomes: Absent != Corrupt. Collapsing them is the
bug where a version bump made ContinueGame overwrite the save.

Profile.Restore writes collections directly - replaying UnlockAchievement
would re-fire popups and Steam pushes on every load. 44 tests.
2026-07-28 19:02:41 +02:00
05149bb4a9 feat(progression): Profile aggregate (slice 4)
best times only improve; achievement unlock announces on the transition
only, so popup/Steam subscribers cannot double-fire; statistics are
increase-only (IncrementStat took any int).

RelockAchievement is console-only and documented as not reaching Steam -
a Steam achievement cannot be un-earned, so the disagreement is
permanent and deliberate.

refactor: extract EventLog, composed by both aggregates now that the
duplication actually exists. 34 tests.
2026-07-28 18:56:58 +02:00
ffa2b986ae feat(progression): Playthrough aggregate (slice 3)
no LoseLife (ADR-0005). SettleDeath / SettleCompletion defer to the
Attempt's terminal outcome, so both are atomic and idempotent.
SettleCompletion branches on HasCleared itself - a Replay cannot bank
or move campaign position backwards (ADR-0003).

Campaign value object holds authored level order; ReachableLevels is
derived, replacing the stored UnlockedLevels. Coins.IsAtLeast driven
out by cross-scope affordability.

29 tests. CONTEXT.md: Campaign, Settle.
2026-07-28 18:52:20 +02:00
40bd5c1d86 feat(progression): Attempt ledger with terminal-outcome guard (slice 2)
Attempt carries its LevelId so settlement decides First Clear vs Replay
without trusting the caller. Outcome is terminal and refuses to change:
one death costs one life, one completion banks once.

Coins.Zero/Add driven out by the ledger rather than added up front.
21 tests. ADR-0005: Playthrough settles Attempts, has no LoseLife.
2026-07-28 18:40:20 +02:00
9767941c65 feat(progression): remaining slice 1 value objects
Lives (floors at zero, exhaustion observable), LevelId, SkillId,
PlaythroughId (never self-generates, keeps tests deterministic),
StatName enum, LevelTime rejects NaN/infinity.

14 tests. Arithmetic/comparison members deferred to the slices whose
behaviour needs them rather than added speculatively.
2026-07-28 18:27:26 +02:00
313a5315b1 feat(progression): Coins, LevelTime, SkillLevel core invariants (slice 1)
three bug-encoding invariants, one red/green cycle each:
- Coins.TrySubtract fails instead of clamping
- LevelTime rejects zero (continued-run zero best-times)
- SkillLevel has no zero (unowned is absence, not Level=1)

plan: drop signed NetCoins; add Attempt outcome as idempotency guard
2026-07-28 18:22:46 +02:00
c3bbf3282a chore: scaffold progression domain + test projects (slice 0)
net8.0 classlib, no engine ref, EnableDynamicLoading; xunit test project
referencing domain only; excluded from Godot's **/*.cs glob; .gdignore both
2026-07-28 18:14:24 +02:00
dd113af786 docs: progression CONTEXT, ADRs 0001-0004, phase A plan
ignore .idea/ .superpowers/ bin/ obj/; drop 3 identical csproj backups
2026-07-28 18:11:01 +02:00
6b784303b4 fix: update author name in plugin configuration 2026-04-26 05:09:18 +02:00
562be1049d fix: replace deprecated AddControlToDock with AddDock(EditorDock) 2026-04-26 05:07:13 +02:00
b2a406ca2b add cellular automata generator 2026-04-26 05:02:03 +02:00
7f0869ffa4 fix: update unique IDs and texture path in collapsing_block scene 2026-04-26 03:36:23 +02:00
7f1b4b5e0e levels update 2026-04-26 03:15:44 +02:00
8769308c85 feat: add 'down' action to remappable actions and update unique IDs in input settings 2026-04-26 02:43:50 +02:00
4b63368286 fix: update Godot.NET.Sdk version to 4.6.2 2026-04-24 11:44:03 +02:00
cbf625d2ff fix: update scene files for unique IDs and adjust JumpForce in JumpPadComponent 2026-03-19 11:32:48 +01:00
057cf7b368 feat: add UID files for InputDeviceManager, SettingsManager, DisplaySettings, GameplaySettings, and InputSettings 2026-03-19 03:53:49 +01:00
829d081eab fix: unsubscribe VisibilityChanged in _ExitTree, use IsActionReleased for ui_cancel, expose ApplyGamepadDeadzone public 2026-03-19 03:51:01 +01:00
38c86f1a67 feat: gameplay settings screen (deadzone + sensitivity) 2026-03-19 03:47:15 +01:00
e647cd7b29 feat: marketplace keyboard/gamepad navigation 2026-03-19 03:44:35 +01:00
9043cfa63c fix: InputSettings PhysicalKeycode, visibility guard, dead code, delegate save 2026-03-19 03:42:47 +01:00
dc46769c11 feat: input remapping screen with conflict detection and config persistence 2026-03-19 03:39:19 +01:00
47600be867 fix: route resolution change through SettingsManager.ApplyDisplaySettings 2026-03-19 03:36:29 +01:00
3e74959bb9 fix: DisplaySettings focus mode, remove duplicate ApplyWindowMode 2026-03-19 03:35:28 +01:00
8cce1866b1 feat: display settings screen with window mode and resolution picker 2026-03-19 03:32:40 +01:00
0105698c33 fix: guard GrabFirstFocus on pointerless, unsub VisibilityChanged in _ExitTree 2026-03-19 03:29:36 +01:00
3b18e328b2 feat: gamepad/keyboard UI navigation on all menus 2026-03-19 03:26:50 +01:00
2bc0b76050 fix: joypad drift threshold + move InputDevice enum inside class 2026-03-19 03:24:19 +01:00
d83e3b4d82 feat: add InputDeviceManager autoload for input device tracking 2026-03-19 03:22:17 +01:00
42d7df27ae fix: SettingsManager null guard, ui_ filter, double cast for sensitivity/deadzone 2026-03-19 03:21:04 +01:00
fd54e16bf4 fix: make GamepadDeadzone/Sensitivity/WindowMode/Resolution setters public 2026-03-19 03:18:46 +01:00
92a3c4f79a feat: add SettingsManager autoload for display, gamepad and input binding settings 2026-03-19 03:17:26 +01:00
654a8b7ea7 fix: add .worktrees/ to gitignore 2026-03-19 03:15:09 +01:00
53ebbbbac3 feat: add movement presets for platform and spaceship, update abilities in scenes 2026-03-19 02:55:03 +01:00
551dc09485 feat: add unique identifier for MovementPreset 2026-03-19 02:46:55 +01:00
814d9736d0 refactor: movement system - MovementPreset, decouple abilities, fix timing 2026-03-19 02:45:26 +01:00
adaeb35fdd chore: ignore .worktrees dir 2026-03-19 02:43:41 +01:00
eeefca4d4e refactor: enhance GameStateStore integration and improve skill management 2026-03-19 02:33:07 +01:00
3e36e48e97 refactor: update collision shape disabling method for improved performance 2026-03-19 02:11:28 +01:00
1e9814a9d8 refactor: replace direct manager instances with lazy accessors for improved performance 2026-03-19 02:10:36 +01:00
470b0c3a8c refactor: implement singleton pattern for key managers and improve resource access 2026-03-19 02:00:56 +01:00
cfef49fbc7 refactor: standardization round 2
- ConsoleManager: lazy GameManager/AchievementManager via Instance (fixes NullRef on console commands)
- AchievementManager, GhostManager: add static Instance property
- GhostEventHandler: use GhostManager.Instance, add _ExitTree unsubscription
- SpeedRunManager: remove unused IsVisible guard; TimeUpdated now emits when running
- SpeedRunHud: use SpeedRunManager.Instance, remove dead IsVisible binding
- SaveDataDto: moved to scripts/State/SaveDataDto.cs
- GameManager.AddCoins: XML doc clarifying permanent vs session coins
2026-03-19 01:50:20 +01:00
321905e68e refactor: fix bugs and improve architecture
- Fix double-execution bug in LevelStateHandler (coins/skills were committed twice per level)
- Fix DamageComponent to track multiple targets via HashSet instead of single node
- Fix HealthComponent: update health immediately, decouple from PlayerController via signal wiring in PlayerController
- Remove dead loop in SkillManager.RemoveSkill; simplify O(n²) throw skill loop
- Replace GetNode<T>(path) with .Instance across managers; add Instance to StatisticsManager/SpeedRunManager
- GameManager.GetPlayer() now uses EventBus.PlayerSpawned instead of scanning all scene nodes
- UIManager.UiStack: remove [Export], use private List<Control>
- PlayerState: extract DefaultLives constant, simplify CreateDefault()
2026-03-19 01:41:14 +01:00
427bef6509 Add EventBus to project settings 2026-02-02 12:48:17 +01:00
3616f4ba79 Update collectable resources and improve FloatingTextManager integration 2026-02-02 12:45:48 +01:00
2bf903728b Add CollectableType.None and update project settings for Godot 4.6 2026-02-01 13:11:14 +01:00
bfe951939d refactor (#6)
Reviewed-on: #6
2026-02-01 11:47:40 +00:00
dde3eaa52e godot-4.5 (#5)
Reviewed-on: #5
2026-01-31 13:06:43 +00:00
372 changed files with 14615 additions and 2933 deletions

9
.gitignore vendored
View File

@@ -2,3 +2,12 @@
.godot/ .godot/
/android/ /android/
builds/ builds/
.worktrees/
# IDE / local tooling
.idea/
.superpowers/
# .NET build output
bin/
obj/

View File

@@ -1,23 +1,30 @@
using Godot; using Godot;
using Godot.Collections;
using Mr.BrickAdventures.scripts.Resources; using Mr.BrickAdventures.scripts.Resources;
using Mr.BrickAdventures.scripts.State;
namespace Mr.BrickAdventures.Autoloads; namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Manages achievements using GameStateStore.
/// </summary>
public partial class AchievementManager : Node public partial class AchievementManager : Node
{ {
public static AchievementManager Instance { get; private set; }
[Export] private string AchievementsFolderPath = "res://achievements/"; [Export] private string AchievementsFolderPath = "res://achievements/";
[Export] private PackedScene AchievementPopupScene { get; set; } [Export] private PackedScene AchievementPopupScene { get; set; }
private System.Collections.Generic.Dictionary<string, AchievementResource> _achievements = new(); private System.Collections.Generic.Dictionary<string, AchievementResource> _achievements = new();
private Array<string> _unlockedAchievementIds = [];
private GameManager _gameManager;
public override void _Ready() public override void _Ready()
{ {
_gameManager = GetNode<GameManager>("/root/GameManager"); Instance = this;
LoadAchievementsFromFolder(); LoadAchievementsFromFolder();
LoadUnlockedAchievements(); }
public override void _ExitTree()
{
if (Instance == this) Instance = null;
} }
private void LoadAchievementsFromFolder() private void LoadAchievementsFromFolder()
@@ -45,6 +52,14 @@ public partial class AchievementManager : Node
} }
} }
/// <summary>
/// Gets the list of unlocked achievement IDs from the store.
/// </summary>
private System.Collections.Generic.List<string> GetUnlockedIds()
{
return GameStateStore.Instance?.Player.UnlockedAchievements ?? new System.Collections.Generic.List<string>();
}
public void UnlockAchievement(string achievementId) public void UnlockAchievement(string achievementId)
{ {
if (!_achievements.TryGetValue(achievementId, out var achievement)) if (!_achievements.TryGetValue(achievementId, out var achievement))
@@ -53,14 +68,16 @@ public partial class AchievementManager : Node
return; return;
} }
if (_unlockedAchievementIds.Contains(achievementId)) var unlockedIds = GetUnlockedIds();
if (unlockedIds.Contains(achievementId))
{ {
return; // Already unlocked return; // Already unlocked
} }
// 1. Mark as unlocked internally // 1. Mark as unlocked
_unlockedAchievementIds.Add(achievementId); unlockedIds.Add(achievementId);
GD.Print($"Achievement Unlocked: {achievement.DisplayName}"); GD.Print($"Achievement Unlocked: {achievement.DisplayName}");
EventBus.EmitAchievementUnlocked(achievementId);
// 2. Show the UI popup // 2. Show the UI popup
if (AchievementPopupScene != null) if (AchievementPopupScene != null)
@@ -75,31 +92,19 @@ public partial class AchievementManager : Node
{ {
SteamManager.UnlockAchievement(achievement.Id); SteamManager.UnlockAchievement(achievement.Id);
} }
// 4. Save progress
SaveUnlockedAchievements();
} }
public void LockAchievement(string achievementId) public void LockAchievement(string achievementId)
{ {
if (_unlockedAchievementIds.Contains(achievementId)) var unlockedIds = GetUnlockedIds();
if (unlockedIds.Contains(achievementId))
{ {
_unlockedAchievementIds.Remove(achievementId); unlockedIds.Remove(achievementId);
SaveUnlockedAchievements();
} }
} }
private void SaveUnlockedAchievements() public bool IsAchievementUnlocked(string achievementId)
{ {
_gameManager.PlayerState["unlocked_achievements"] = _unlockedAchievementIds; return GetUnlockedIds().Contains(achievementId);
// You might want to trigger a save game here, depending on your SaveSystem
}
private void LoadUnlockedAchievements()
{
if (_gameManager.PlayerState.TryGetValue("unlocked_achievements", out var unlocked))
{
_unlockedAchievementIds = (Array<string>)unlocked;
}
} }
} }

View File

@@ -4,6 +4,8 @@ namespace Mr.BrickAdventures.Autoloads;
public partial class ConfigFileHandler : Node public partial class ConfigFileHandler : Node
{ {
public static ConfigFileHandler Instance { get; private set; }
private ConfigFile _settingsConfig = new(); private ConfigFile _settingsConfig = new();
public const string SettingsPath = "user://settings.ini"; public const string SettingsPath = "user://settings.ini";
@@ -11,6 +13,7 @@ public partial class ConfigFileHandler : Node
public override void _Ready() public override void _Ready()
{ {
Instance = this;
if (!FileAccess.FileExists(SettingsPath)) if (!FileAccess.FileExists(SettingsPath))
{ {
var err = _settingsConfig.Save(SettingsPath); var err = _settingsConfig.Save(SettingsPath);
@@ -24,4 +27,9 @@ public partial class ConfigFileHandler : Node
GD.PushError($"Failed to load settings file at {SettingsPath}: {err}"); GD.PushError($"Failed to load settings file at {SettingsPath}: {err}");
} }
} }
public override void _ExitTree()
{
if (Instance == this) Instance = null;
}
} }

View File

@@ -1,173 +1,120 @@
using Godot; using Godot;
using Limbo.Console.Sharp;
using Mr.BrickAdventures.scripts;
using Mr.BrickAdventures.scripts.components; using Mr.BrickAdventures.scripts.components;
namespace Mr.BrickAdventures.Autoloads; namespace Mr.BrickAdventures.Autoloads;
public partial class ConsoleManager : Node public partial class ConsoleManager : Node
{ {
private GameManager _gameManager; private GameStateStore Store => GameStateStore.Instance;
private GameManager GameManager => GameManager.Instance;
private AchievementManager AchievementManager => AchievementManager.Instance;
private SkillManager _skillManager; private SkillManager _skillManager;
private SkillUnlockerComponent _skillUnlockerComponent; private SkillUnlockerComponent _skillUnlockerComponent;
private AchievementManager _achievementManager;
public override void _Ready() public override void _Ready()
{ {
_gameManager = GetNode<GameManager>("/root/GameManager"); _skillManager = SkillManager.Instance;
_achievementManager = GetNode<AchievementManager>("/root/AchievementManager");
_skillManager = GetNode<SkillManager>("/root/SkillManager");
RegisterConsoleCommands();
} }
public override void _ExitTree()
{
UnregisterConsoleCommands();
}
[ConsoleCommand("add_coins", "Adds a specified amount of coins to the player's total.")]
private void AddCoinsCommand(int amount) private void AddCoinsCommand(int amount)
{ {
_gameManager.AddCoins(amount); if (Store == null) return;
LimboConsole.Info($"Increased coins by {amount}. Total coins: {_gameManager.GetCoins()}"); Store.Player.Coins += amount;
EventBus.EmitCoinsChanged(Store.GetTotalCoins());
} }
[ConsoleCommand("set_coins", "Sets the player's total coins to a specified amount.")]
private void SetCoinsCommand(int amount) private void SetCoinsCommand(int amount)
{ {
_gameManager.SetCoins(amount); if (Store == null) return;
LimboConsole.Info($"Set coins to {amount}. Total coins: {_gameManager.GetCoins()}"); Store.Player.Coins = Mathf.Max(0, amount);
EventBus.EmitCoinsChanged(Store.GetTotalCoins());
} }
[ConsoleCommand("set_lives", "Sets the player's total lives to a specified amount.")]
private void SetLivesCommand(int amount) private void SetLivesCommand(int amount)
{ {
_gameManager.SetLives(amount); if (Store == null) return;
LimboConsole.Info($"Set lives to {amount}."); Store.Player.Lives = amount;
EventBus.EmitLivesChanged(amount);
} }
[ConsoleCommand("add_lives", "Adds a specified amount of lives to the player's total.")]
private void AddLivesCommand(int amount) private void AddLivesCommand(int amount)
{ {
_gameManager.AddLives(amount); Store?.AddLives(amount);
LimboConsole.Info($"Increased lives by {amount}. Total lives: {_gameManager.GetLives()}");
} }
[ConsoleCommand("set_health", "Sets the player's health to a specified amount.")]
private void SetHealthCommand(float amount) private void SetHealthCommand(float amount)
{ {
var playerHealthComponent = _gameManager.Player.GetNode<HealthComponent>("HealthComponent"); var playerHealthComponent = GameManager?.Player?.GetNode<HealthComponent>("HealthComponent");
if (playerHealthComponent != null) if (playerHealthComponent != null)
{
playerHealthComponent.Health = amount; playerHealthComponent.Health = amount;
LimboConsole.Info($"Set player health to {amount}.");
}
else
{
LimboConsole.Warn("Player HealthComponent not found.");
}
} }
[ConsoleCommand("reset_session", "Resets the current session state.")]
private void ResetSessionCommand() private void ResetSessionCommand()
{ {
_gameManager.ResetCurrentSessionState(); Store?.ResetSession();
LimboConsole.Info("Current session state has been reset.");
} }
[ConsoleCommand("unlock_skill", "Unlocks and activates a skill by its name.")]
private void UnlockSkillCommand(string skillName) private void UnlockSkillCommand(string skillName)
{ {
if (!GetSkillManagement()) return; if (!EnsureSkillManagement()) return;
var skill = _skillManager.GetSkillByName(skillName); var skill = _skillManager.GetSkillByName(skillName);
if (skill == null) if (skill == null) return;
{
LimboConsole.Warn($"Skill '{skillName}' not found.");
return;
}
_gameManager.UnlockSkill(skill); Store?.UnlockSkillPermanently(skill);
_skillManager.ActivateSkill(skill); _skillManager.ActivateSkill(skill);
_skillUnlockerComponent.EmitSignal(SkillUnlockerComponent.SignalName.SkillUnlocked, skill); _skillUnlockerComponent.EmitSignal(SkillUnlockerComponent.SignalName.SkillUnlocked, skill);
LimboConsole.Info($"Skill '{skillName}' has been unlocked and activated.");
} }
private bool GetSkillManagement()
{
var player = _gameManager.Player;
if (player == null || !IsInstanceValid(player))
{
LimboConsole.Warn("Player node not found or is invalid.");
return false;
}
_skillUnlockerComponent ??= player.GetNode<SkillUnlockerComponent>("SkillUnlockerComponent");
if (_skillManager != null && _skillUnlockerComponent != null) return true;
LimboConsole.Warn("SkillManager or SkillUnlockerComponent not found on the player.");
return false;
}
[ConsoleCommand("unlock_all_skills", "Unlocks and activates all available skills.")]
private void UnlockAllSkillsCommand() private void UnlockAllSkillsCommand()
{ {
if (!GetSkillManagement()) return; if (!EnsureSkillManagement()) return;
_skillUnlockerComponent.UnlockAllSkills(); _skillUnlockerComponent.UnlockAllSkills();
LimboConsole.Info("All skills have been unlocked and activated.");
} }
[ConsoleCommand("remove_skill", "Deactivates and removes a skill by its name.")]
private void RemoveSkillCommand(string skillName) private void RemoveSkillCommand(string skillName)
{ {
if (!GetSkillManagement()) return; if (!EnsureSkillManagement()) return;
var skill = _skillManager.GetSkillByName(skillName); var skill = _skillManager.GetSkillByName(skillName);
if (skill == null) if (skill == null) return;
{
LimboConsole.Warn($"Skill '{skillName}' not found.");
return;
}
_gameManager.RemoveSkill(skill.Name); Store?.RemoveUnlockedSkill(skill.Name);
_skillManager.DeactivateSkill(skill); _skillManager.DeactivateSkill(skill);
LimboConsole.Info($"Skill '{skillName}' has been deactivated.");
} }
[ConsoleCommand("remove_all_skills", "Deactivates and removes all skills.")]
private void RemoveAllSkillsCommand() private void RemoveAllSkillsCommand()
{ {
if (!GetSkillManagement()) return; if (!EnsureSkillManagement()) return;
foreach (var skill in _skillManager.AvailableSkills) foreach (var skill in _skillManager.AvailableSkills)
{ {
_gameManager.RemoveSkill(skill.Name); Store?.RemoveUnlockedSkill(skill.Name);
_skillManager.DeactivateSkill(skill); _skillManager.DeactivateSkill(skill);
} }
LimboConsole.Info("All skills have been deactivated.");
} }
[ConsoleCommand("next_level", "Advances the game to the next level.")]
private void GoToNextLevelCommand() private void GoToNextLevelCommand()
{ {
_gameManager.OnLevelComplete(); GameManager?.OnLevelComplete();
} }
[ConsoleCommand("unlock_achievement", "Unlocks an achievement by its ID.")]
private void UnlockAchievementCommand(string achievementId) private void UnlockAchievementCommand(string achievementId)
{ {
_achievementManager.UnlockAchievement(achievementId); AchievementManager?.UnlockAchievement(achievementId);
LimboConsole.Info($"Attempted to unlock achievement '{achievementId}'.");
} }
[ConsoleCommand("reset_achievement", "Resets (locks) an achievement by its ID.")]
private void ResetAchievementCommand(string achievementId) private void ResetAchievementCommand(string achievementId)
{ {
_achievementManager.LockAchievement(achievementId); AchievementManager?.LockAchievement(achievementId);
} }
private bool EnsureSkillManagement()
{
var player = GameManager?.Player;
if (player == null || !IsInstanceValid(player)) return false;
_skillUnlockerComponent ??= player.GetNode<SkillUnlockerComponent>("SkillUnlockerComponent");
return _skillManager != null && _skillUnlockerComponent != null;
}
} }

View File

@@ -1,9 +1,194 @@
using Godot; using Godot;
using Mr.BrickAdventures.scripts.components;
using Mr.BrickAdventures.scripts.Resources;
namespace Mr.BrickAdventures.Autoloads; namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Global event bus for decoupled communication between game systems.
/// Use the static Instance property for easy access from anywhere.
/// </summary>
public partial class EventBus : Node public partial class EventBus : Node
{ {
/// <summary>
/// Singleton instance. Available after the autoload is initialized.
/// </summary>
public static EventBus Instance { get; private set; }
public override void _Ready()
{
Instance = this;
}
public override void _ExitTree()
{
if (Instance == this)
Instance = null;
}
#region Level Events
[Signal] public delegate void LevelStartedEventHandler(int levelIndex, Node currentScene); [Signal] public delegate void LevelStartedEventHandler(int levelIndex, Node currentScene);
[Signal] public delegate void LevelCompletedEventHandler(int levelIndex, Node currentScene, double completionTime); [Signal] public delegate void LevelCompletedEventHandler(int levelIndex, Node currentScene, double completionTime);
[Signal] public delegate void LevelRestartedEventHandler(int levelIndex);
public static void EmitLevelStarted(int levelIndex, Node currentScene)
=> Instance?.EmitSignal(SignalName.LevelStarted, levelIndex, currentScene);
public static void EmitLevelCompleted(int levelIndex, Node currentScene, double completionTime)
=> Instance?.EmitSignal(SignalName.LevelCompleted, levelIndex, currentScene, completionTime);
public static void EmitLevelRestarted(int levelIndex)
=> Instance?.EmitSignal(SignalName.LevelRestarted, levelIndex);
#endregion
#region Player Events
[Signal] public delegate void PlayerSpawnedEventHandler(PlayerController player);
[Signal] public delegate void PlayerDiedEventHandler(Vector2 position);
[Signal] public delegate void PlayerDamagedEventHandler(float damage, float remainingHealth, Vector2 position);
[Signal] public delegate void PlayerHealedEventHandler(float amount, float newHealth, Vector2 position);
public static void EmitPlayerSpawned(PlayerController player)
=> Instance?.EmitSignal(SignalName.PlayerSpawned, player);
public static void EmitPlayerDied(Vector2 position)
=> Instance?.EmitSignal(SignalName.PlayerDied, position);
public static void EmitPlayerDamaged(float damage, float remainingHealth, Vector2 position)
=> Instance?.EmitSignal(SignalName.PlayerDamaged, damage, remainingHealth, position);
public static void EmitPlayerHealed(float amount, float newHealth, Vector2 position)
=> Instance?.EmitSignal(SignalName.PlayerHealed, amount, newHealth, position);
#endregion
#region Combat Events
[Signal] public delegate void EnemyDefeatedEventHandler(Node enemy, Vector2 position);
[Signal] public delegate void EnemyDamagedEventHandler(Node enemy, float damage, Vector2 position);
public static void EmitEnemyDefeated(Node enemy, Vector2 position)
=> Instance?.EmitSignal(SignalName.EnemyDefeated, enemy, position);
public static void EmitEnemyDamaged(Node enemy, float damage, Vector2 position)
=> Instance?.EmitSignal(SignalName.EnemyDamaged, enemy, damage, position);
#endregion
#region Collection Events
[Signal] public delegate void CoinCollectedEventHandler(int amount, Vector2 position);
[Signal] public delegate void ItemCollectedEventHandler(CollectableType itemType, float amount, Vector2 position);
[Signal] public delegate void ChildRescuedEventHandler(Vector2 position);
[Signal] public delegate void SkillCollectedEventHandler(SkillData skill, Vector2 position);
public static void EmitCoinCollected(int amount, Vector2 position)
=> Instance?.EmitSignal(SignalName.CoinCollected, amount, position);
public static void EmitItemCollected(CollectableType itemType, float amount, Vector2 position)
=> Instance?.EmitSignal(SignalName.ItemCollected, (int)itemType, amount, position);
public static void EmitChildRescued(Vector2 position)
=> Instance?.EmitSignal(SignalName.ChildRescued, position);
public static void EmitSkillCollected(SkillData skill, Vector2 position)
=> Instance?.EmitSignal(SignalName.SkillCollected, skill, position);
#endregion
#region Skill Events
[Signal] public delegate void SkillUnlockedEventHandler(string skillName, int level);
[Signal] public delegate void SkillActivatedEventHandler(string skillName);
[Signal] public delegate void SkillDeactivatedEventHandler(string skillName);
public static void EmitSkillUnlocked(string skillName, int level = 1)
=> Instance?.EmitSignal(SignalName.SkillUnlocked, skillName, level);
public static void EmitSkillActivated(string skillName)
=> Instance?.EmitSignal(SignalName.SkillActivated, skillName);
public static void EmitSkillDeactivated(string skillName)
=> Instance?.EmitSignal(SignalName.SkillDeactivated, skillName);
#endregion
#region Game State Events
[Signal] public delegate void GamePausedEventHandler();
[Signal] public delegate void GameResumedEventHandler();
[Signal] public delegate void GameSavedEventHandler();
[Signal] public delegate void GameStartedEventHandler();
[Signal] public delegate void GameContinuedEventHandler();
public static void EmitGamePaused()
=> Instance?.EmitSignal(SignalName.GamePaused);
public static void EmitGameResumed()
=> Instance?.EmitSignal(SignalName.GameResumed);
public static void EmitGameSaved()
=> Instance?.EmitSignal(SignalName.GameSaved);
public static void EmitGameStarted()
=> Instance?.EmitSignal(SignalName.GameStarted);
public static void EmitGameContinued()
=> Instance?.EmitSignal(SignalName.GameContinued);
#endregion
#region Achievement Events
[Signal] public delegate void AchievementUnlockedEventHandler(string achievementId);
public static void EmitAchievementUnlocked(string achievementId)
=> Instance?.EmitSignal(SignalName.AchievementUnlocked, achievementId);
#endregion
#region State Change Events
[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);
public static void EmitLivesChanged(int lives)
=> Instance?.EmitSignal(SignalName.LivesChanged, lives);
#endregion
} }

View File

@@ -1,5 +1,7 @@
using System;
using System.Globalization; using System.Globalization;
using Godot; using Godot;
using Mr.BrickAdventures.scripts.Resources;
using Mr.BrickAdventures.scripts.UI; using Mr.BrickAdventures.scripts.UI;
namespace Mr.BrickAdventures.Autoloads; namespace Mr.BrickAdventures.Autoloads;
@@ -14,6 +16,18 @@ public partial class FloatingTextManager : Node
[Export] public Color CoinColor { get; set; } = new Color("#ebd320"); // Gold [Export] public Color CoinColor { get; set; } = new Color("#ebd320"); // Gold
[Export] public Color MessageColor { get; set; } = new Color("#ffffff"); // White [Export] public Color MessageColor { get; set; } = new Color("#ffffff"); // White
public static FloatingTextManager Instance { get; private set; }
public override void _Ready()
{
Instance = this;
}
public override void _ExitTree()
{
if (Instance == this) Instance = null;
}
public void ShowDamage(float amount, Vector2 position) public void ShowDamage(float amount, Vector2 position)
{ {
var text = Mathf.Round(amount * 100f).ToString(CultureInfo.InvariantCulture); var text = Mathf.Round(amount * 100f).ToString(CultureInfo.InvariantCulture);
@@ -22,7 +36,7 @@ public partial class FloatingTextManager : Node
public void ShowHeal(float amount, Vector2 position) public void ShowHeal(float amount, Vector2 position)
{ {
var text = $"+{Mathf.Round(amount)}"; var text = $"+{Mathf.Round(amount * 100f).ToString(CultureInfo.InvariantCulture)}";
CreateFloatingText(text, position, HealColor); CreateFloatingText(text, position, HealColor);
} }

View File

@@ -1,270 +1,191 @@
using System.Collections.Generic;
using Godot; using Godot;
using Godot.Collections; using Mr.BrickAdventures.Progression;
using Mr.BrickAdventures.scripts.components; using Mr.BrickAdventures.scripts.components;
using Mr.BrickAdventures.scripts.Resources;
using Double = System.Double;
namespace Mr.BrickAdventures.Autoloads; namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Game orchestrator: scene transitions and game flow.
/// </summary>
/// <remarks>
/// <para>
/// Which Level the player is on now belongs to <see cref="ProgressionRoot"/>, addressed by a stable
/// <see cref="LevelId"/> and resolved to a scene by the level catalogue. The old
/// <c>Array&lt;PackedScene&gt; LevelScenes</c> is gone: it made a save's meaning depend on the order
/// of an inspector array, so reordering levels silently invalidated every save.
/// </para>
/// <para>
/// Progression is persisted by <see cref="ProgressionRoot"/>'s stores, which save themselves at the
/// points that matter — a First Clear and a death — so there is no separate "save the game" step to
/// forget.
/// </para>
/// </remarks>
public partial class GameManager : Node public partial class GameManager : Node
{ {
[Export] public Array<PackedScene> LevelScenes { get; set; } = []; public PlayerController Player
{
public PlayerController Player {
get => GetPlayer(); get => GetPlayer();
private set => _player = value; private set => _player = value;
} }
private List<Node> _sceneNodes = [];
private PlayerController _player; private PlayerController _player;
private SpeedRunManager _speedRunManager; private SpeedRunManager SpeedRunManager => SpeedRunManager.Instance;
private EventBus _eventBus; private ProgressionRoot Progression => ProgressionRoot.Instance;
[Export] public static GameManager Instance { get; private set; }
public Dictionary PlayerState { get; set; } = new()
{
{ "coins", 0 },
{ "lives", 3 },
{ "current_level", 0 },
{ "completed_levels", new Array<int>() },
{ "unlocked_levels", new Array<int>() {0}},
{ "unlocked_skills", new Array<SkillData>() }
};
[Export] public override void _Ready()
public Dictionary CurrentSessionState { get; private set; } = new()
{ {
{ "coins_collected", 0 }, Instance = this;
{ "skills_unlocked", new Array<SkillData>() } EventBus.Instance.PlayerSpawned += OnPlayerSpawned;
};
public override void _EnterTree()
{
GetTree().NodeAdded += OnNodeAdded;
GetTree().NodeRemoved += OnNodeRemoved;
} }
public override void _ExitTree() public override void _ExitTree()
{ {
GetTree().NodeAdded -= OnNodeAdded; if (Instance == this) Instance = null;
GetTree().NodeRemoved -= OnNodeRemoved; if (EventBus.Instance != null)
_sceneNodes.Clear(); EventBus.Instance.PlayerSpawned -= OnPlayerSpawned;
} }
public override void _Ready() private void OnPlayerSpawned(PlayerController player)
{ {
_speedRunManager = GetNode<SpeedRunManager>("/root/SpeedRunManager"); _player = player;
_eventBus = GetNode<EventBus>("/root/EventBus"); player.TreeExiting += () => { if (_player == player) _player = null; };
} }
private void OnNodeAdded(Node node) #region Game Flow
{
_sceneNodes.Add(node);
}
private void OnNodeRemoved(Node node)
{
_sceneNodes.Remove(node);
if (node == _player)
{
_player = null;
}
}
public void AddCoins(int amount)
{
PlayerState["coins"] = Mathf.Max(0, (int)PlayerState["coins"] + amount);
}
public void SetCoins(int amount) => PlayerState["coins"] = Mathf.Max(0, amount);
public int GetCoins() => (int)PlayerState["coins"] + (int)CurrentSessionState["coins_collected"];
public void RemoveCoins(int amount)
{
var sessionCoins = (int)CurrentSessionState["coins_collected"];
if (amount <= sessionCoins)
{
CurrentSessionState["coins_collected"] = sessionCoins - amount;
}
else
{
var remaining = amount - sessionCoins;
CurrentSessionState["coins_collected"] = 0;
PlayerState["coins"] = Mathf.Max(0, (int)PlayerState["coins"] - remaining);
}
PlayerState["coins"] = Mathf.Max(0, (int)PlayerState["coins"]);
}
public void AddLives(int amount) => PlayerState["lives"] = (int)PlayerState["lives"] + amount;
public void RemoveLives(int amount) => PlayerState["lives"] = (int)PlayerState["lives"] - amount;
public void SetLives(int amount) => PlayerState["lives"] = amount;
public int GetLives() => (int)PlayerState["lives"];
public bool IsSkillUnlocked(SkillData skill)
{
return ((Array)PlayerState["unlocked_skills"]).Contains(skill)
|| ((Array)CurrentSessionState["skills_unlocked"]).Contains(skill);
}
public void UnlockSkill(SkillData skill)
{
if (!IsSkillUnlocked(skill))
((Array)PlayerState["unlocked_skills"]).Add(skill);
}
public void RemoveSkill(string skillName)
{
var arr = (Array)PlayerState["unlocked_skills"];
foreach (SkillData s in arr)
{
if (s.Name != skillName) continue;
arr.Remove(s);
break;
}
}
public void UnlockSkills(Array<SkillData> skills)
{
foreach (var s in skills)
UnlockSkill(s);
}
public void ResetPlayerState()
{
PlayerState = new Dictionary
{
{ "coins", 0 },
{ "lives", 3 },
{ "current_level", 0 },
{ "completed_levels", new Array<int>() },
{ "unlocked_levels", new Array<int>() {0}},
{ "unlocked_skills", new Array<SkillData>() },
{ "statistics", new Godot.Collections.Dictionary<string, Variant>()}
};
}
public void UnlockLevel(int levelIndex)
{
var unlocked = (Array)PlayerState["unlocked_levels"];
if (!unlocked.Contains(levelIndex)) unlocked.Add(levelIndex);
}
public void TryToGoToNextLevel()
{
var next = (int)PlayerState["current_level"] + 1;
var unlocked = (Array)PlayerState["unlocked_levels"];
if (next < LevelScenes.Count && unlocked.Contains(next))
{
PlayerState["current_level"] = next;
GetTree().ChangeSceneToPacked(LevelScenes[next]);
_eventBus.EmitSignal(EventBus.SignalName.LevelStarted, next, GetTree().CurrentScene);
}
}
public void MarkLevelComplete(int levelIndex)
{
UnlockLevel(levelIndex + 1);
var completed = (Array)PlayerState["completed_levels"];
if (!completed.Contains(levelIndex)) completed.Add(levelIndex);
}
public void ResetCurrentSessionState()
{
CurrentSessionState = new Dictionary
{
{ "coins_collected", 0 },
{ "skills_unlocked", new Array<SkillData>() }
};
}
public void RestartGame()
{
ResetPlayerState();
ResetCurrentSessionState();
GetTree().ChangeSceneToPacked(LevelScenes[0]);
GetNode<SaveSystem>("/root/SaveSystem").SaveGame();
}
public void QuitGame() => GetTree().Quit();
public void PauseGame() => Engine.TimeScale = 0;
public void ResumeGame() => Engine.TimeScale = 1;
public void StartNewGame() public void StartNewGame()
{ {
ResetPlayerState(); if (Progression == null) return;
ResetCurrentSessionState();
_speedRunManager?.StartTimer(); Progression.StartPlaythrough(ProgressionRoot.SoleSlot);
SpeedRunManager?.StartTimer();
EventBus.EmitGameStarted();
GetTree().ChangeSceneToPacked(LevelScenes[0]); GoTo(Progression.Campaign.First);
GetNode<SaveSystem>("/root/SaveSystem").SaveGame();
} }
public void ContinueGame() public void ContinueGame()
{ {
var save = GetNode<SaveSystem>("/root/SaveSystem"); if (Progression == null) return;
if (!save.LoadGame())
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(); StartNewGame();
return; return;
} }
var idx = (int)PlayerState["current_level"]; SpeedRunManager?.StartTimer();
if (idx < LevelScenes.Count) EventBus.EmitGameContinued();
GetTree().ChangeSceneToPacked(LevelScenes[idx]);
else GoTo(Progression.CurrentLevel ?? Progression.Campaign.First);
GD.PrintErr("No levels unlocked to continue."); }
public void RestartGame()
{
if (Progression == null) return;
Progression.StartPlaythrough(ProgressionRoot.SoleSlot);
SpeedRunManager?.StartTimer();
GoTo(Progression.Campaign.First);
} }
public void OnLevelComplete() public void OnLevelComplete()
{ {
var levelIndex = (int)PlayerState["current_level"]; if (Progression?.CurrentLevel is not { } finished) return;
MarkLevelComplete(levelIndex);
AddCoins((int)CurrentSessionState["coins_collected"]); var seconds = SpeedRunManager?.GetCurrentLevelTime() ?? 0.0;
foreach (var s in (Array)CurrentSessionState["skills_unlocked"]) LevelTime? time = seconds > 0 ? LevelTime.Of(seconds) : null;
UnlockSkill((SkillData)s);
var completionTime = _speedRunManager?.GetCurrentLevelTime() ?? 0.0; EventBus.EmitLevelCompleted(IndexOf(finished), GetTree().CurrentScene, seconds);
_eventBus.EmitSignal(EventBus.SignalName.LevelCompleted, levelIndex, GetTree().CurrentScene, completionTime);
ResetCurrentSessionState(); // Banks the attempt's coins and skills, marks the level cleared, and advances campaign
TryToGoToNextLevel(); // position if this was a First Clear.
GetNode<SaveSystem>("/root/SaveSystem").SaveGame(); Progression.FinishLevel(time);
if (Progression.CurrentLevel is { } next && !next.Equals(finished))
GoTo(next);
} }
public Array<SkillData> GetUnlockedSkills() /// <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)
{ {
var unlocked = (Array<SkillData>)PlayerState["unlocked_skills"]; if (!Progression.CanEnter(level))
var session = (Array<SkillData>)CurrentSessionState["skills_unlocked"]; {
if (session!.Count == 0) return unlocked; GD.PushError($"Level '{level}' is not reachable in this playthrough.");
if (unlocked!.Count == 0) return session; return;
var joined = new Array<SkillData>(); }
joined.AddRange(unlocked);
joined.AddRange(session); var scene = Progression.Catalogue.SceneFor(level);
return joined; if (scene == null)
{
GD.PushError($"Level '{level}' has no loadable scene.");
return;
}
GetTree().ChangeSceneToPacked(scene);
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
Progression.EnterLevel(level);
} }
/// <summary>
/// Position in the catalogue, for the signals that are still index-based because GhostManager
/// names its files <c>ghost_level_{index}.json</c>.
/// </summary>
private int IndexOf(LevelId level)
{
var levels = Progression?.Catalogue?.Levels;
if (levels == null) return -1;
for (var i = 0; i < levels.Count; i++)
if (levels[i]?.Id == level.Value)
return i;
return -1;
}
public void PauseGame()
{
Engine.TimeScale = 0;
EventBus.EmitGamePaused();
}
public void ResumeGame()
{
Engine.TimeScale = 1;
EventBus.EmitGameResumed();
}
public void QuitGame() => GetTree().Quit();
#endregion
#region Player Lookup
public PlayerController GetPlayer() public PlayerController GetPlayer()
{ {
if (_player != null && IsInstanceValid(_player)) return _player; if (_player != null && IsInstanceValid(_player)) return _player;
_player = null; _player = null;
foreach (var node in _sceneNodes)
{
if (node is not PlayerController player) continue;
_player = player;
return _player;
}
GD.PrintErr("PlayerController not found in the scene tree.");
return null; return null;
} }
#endregion
} }

243
Autoloads/GameStateStore.cs Normal file
View File

@@ -0,0 +1,243 @@
using Godot;
using System.Collections.Generic;
using Mr.BrickAdventures.scripts.Resources;
using Mr.BrickAdventures.scripts.State;
namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Central store for game state - single source of truth.
/// Use the static Instance property for easy access.
/// </summary>
public partial class GameStateStore : Node
{
/// <summary>
/// Singleton instance.
/// </summary>
public static GameStateStore Instance { get; private set; }
/// <summary>
/// Persistent player state (saved to disk).
/// </summary>
public PlayerState Player { get; set; } = new();
/// <summary>
/// Current session state (transient, reset on death/level complete).
/// </summary>
public SessionState Session { get; set; } = new();
public override void _Ready()
{
Instance = this;
}
public override void _ExitTree()
{
if (Instance == this)
Instance = null;
}
#region Coin Operations
/// <summary>
/// Gets total coins (saved + session).
/// </summary>
public int GetTotalCoins() => Player.Coins + Session.CoinsCollected;
/// <summary>
/// Adds coins to the session (not saved until level complete).
/// </summary>
public void AddSessionCoins(int amount)
{
Session.CoinsCollected += amount;
EventBus.EmitCoinsChanged(GetTotalCoins());
}
/// <summary>
/// Commits session coins to player state.
/// </summary>
public void CommitSessionCoins()
{
Player.Coins += Session.CoinsCollected;
Session.CoinsCollected = 0;
}
/// <summary>
/// Removes coins, first from session then from saved.
/// </summary>
public void RemoveCoins(int amount)
{
if (amount <= Session.CoinsCollected)
{
Session.CoinsCollected -= amount;
}
else
{
var remaining = amount - Session.CoinsCollected;
Session.CoinsCollected = 0;
Player.Coins = Mathf.Max(0, Player.Coins - remaining);
}
EventBus.EmitCoinsChanged(GetTotalCoins());
}
#endregion
#region Lives Operations
/// <summary>
/// Decrements lives by 1.
/// </summary>
public void RemoveLife()
{
Player.Lives = Mathf.Max(0, Player.Lives - 1);
EventBus.EmitLivesChanged(Player.Lives);
}
/// <summary>
/// Adds lives.
/// </summary>
public void AddLives(int amount)
{
Player.Lives += amount;
EventBus.EmitLivesChanged(Player.Lives);
}
#endregion
#region Level Operations
/// <summary>
/// Unlocks a level for access.
/// </summary>
public void UnlockLevel(int levelIndex)
{
if (!Player.UnlockedLevels.Contains(levelIndex))
Player.UnlockedLevels.Add(levelIndex);
}
/// <summary>
/// Marks a level as completed and unlocks the next.
/// </summary>
public void MarkLevelComplete(int levelIndex)
{
if (!Player.CompletedLevels.Contains(levelIndex))
Player.CompletedLevels.Add(levelIndex);
UnlockLevel(levelIndex + 1);
}
/// <summary>
/// Checks if a level is unlocked.
/// </summary>
public bool IsLevelUnlocked(int levelIndex) => Player.UnlockedLevels.Contains(levelIndex);
#endregion
#region Skill Operations
/// <summary>
/// Checks if a skill is unlocked (saved or session).
/// </summary>
public bool IsSkillUnlocked(SkillData skill)
{
return Player.UnlockedSkills.Contains(skill) || Session.SkillsUnlocked.Contains(skill);
}
/// <summary>
/// Unlocks a skill in the session (lost on death, committed on level complete).
/// </summary>
public void UnlockSkillInSession(SkillData skill)
{
if (!IsSkillUnlocked(skill))
Session.SkillsUnlocked.Add(skill);
}
/// <summary>
/// Permanently unlocks a skill directly in player state (bypasses session, e.g. console commands).
/// </summary>
public void UnlockSkillPermanently(SkillData skill)
{
if (!Player.UnlockedSkills.Contains(skill))
Player.UnlockedSkills.Add(skill);
}
/// <summary>
/// Removes a permanently unlocked skill from player state by name.
/// </summary>
public void RemoveUnlockedSkill(string skillName)
{
for (int i = 0; i < Player.UnlockedSkills.Count; i++)
{
if (Player.UnlockedSkills[i].Name == skillName)
{
Player.UnlockedSkills.RemoveAt(i);
return;
}
}
}
/// <summary>
/// Commits session skills to player state.
/// </summary>
public void CommitSessionSkills()
{
foreach (var skill in Session.SkillsUnlocked)
{
if (!Player.UnlockedSkills.Contains(skill))
Player.UnlockedSkills.Add(skill);
}
Session.SkillsUnlocked.Clear();
}
/// <summary>
/// Gets all unlocked skills from player persistence and current session.
/// </summary>
public List<SkillData> GetAllUnlockedSkills()
{
var result = new List<SkillData>(Player.UnlockedSkills);
foreach (var skill in Session.SkillsUnlocked)
{
if (!result.Contains(skill)) result.Add(skill);
}
return result;
}
#endregion
#region Statistics Operations
public void IncrementStat(string name, int amount = 1)
{
if (Player.Statistics.TryGetValue(name, out var current))
Player.Statistics[name] = current + amount;
else
Player.Statistics[name] = amount;
}
public void SetStat(string name, int value) => Player.Statistics[name] = value;
public int GetStat(string name) =>
Player.Statistics.TryGetValue(name, out var value) ? value : 0;
public System.Collections.Generic.Dictionary<string, int> GetAllStats() =>
new(Player.Statistics);
#endregion
#region Reset Operations
/// <summary>
/// Resets only the session state.
/// </summary>
public void ResetSession() => Session.Reset();
/// <summary>
/// Resets everything to defaults.
/// </summary>
public void ResetAll()
{
Player.Reset();
Session.ResetAll();
}
#endregion
}

View File

@@ -0,0 +1 @@
uid://bwrhkipwecytk

View File

@@ -1,14 +1,20 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Text.Json;
using Godot; using Godot;
using Godot.Collections;
using Mr.BrickAdventures.scripts; using Mr.BrickAdventures.scripts;
using Mr.BrickAdventures.scripts.State;
namespace Mr.BrickAdventures.Autoloads; namespace Mr.BrickAdventures.Autoloads;
public partial class GhostManager : Node public partial class GhostManager : Node
{ {
public static GhostManager Instance { get; private set; }
[Export] private PackedScene GhostPlayerScene { get; set; } [Export] private PackedScene GhostPlayerScene { get; set; }
public override void _Ready() => Instance = this;
public override void _ExitTree() { if (Instance == this) Instance = null; }
public bool IsRecording { get; private set; } = false; public bool IsRecording { get; private set; } = false;
public bool IsPlaybackEnabled { get; private set; } = true; public bool IsPlaybackEnabled { get; private set; } = true;
@@ -48,12 +54,11 @@ public partial class GhostManager : Node
{ {
if (!IsRecording) return; if (!IsRecording) return;
var frame = new GhostFrame _currentRecording.Add(new GhostFrame
{ {
Timestamp = (Time.GetTicksMsec() / 1000.0) - _startTime, Timestamp = (Time.GetTicksMsec() / 1000.0) - _startTime,
Position = position Position = position
}; });
_currentRecording.Add(frame);
} }
public void SpawnGhostPlayer(int levelIndex, Node parent) public void SpawnGhostPlayer(int levelIndex, Node parent)
@@ -72,41 +77,60 @@ public partial class GhostManager : Node
private void SaveGhostData(int levelIndex, double time) private void SaveGhostData(int levelIndex, double time)
{ {
var path = $"user://ghost_level_{levelIndex}.dat"; var path = $"user://ghost_level_{levelIndex}.json";
using var file = FileAccess.Open(path, FileAccess.ModeFlags.Write); var saveData = new GhostSaveData { BestTime = time };
foreach (var frame in _currentRecording)
saveData.Frames.Add(new GhostFrameDto { Timestamp = frame.Timestamp, X = frame.Position.X, Y = frame.Position.Y });
var dataToSave = new Godot.Collections.Dictionary try
{ {
{ "time", time }, var json = JsonSerializer.Serialize(saveData, SaveSystem.JsonOptions);
{ "frames", _currentRecording.ToArray() } using var file = FileAccess.Open(path, FileAccess.ModeFlags.Write);
}; file.StoreString(json);
file.StoreVar(dataToSave); }
catch (System.Exception e)
{
GD.PrintErr($"GhostManager: Failed to save ghost data: {e.Message}");
}
} }
private List<GhostFrame> LoadGhostData(int levelIndex) private List<GhostFrame> LoadGhostData(int levelIndex)
{ {
var path = $"user://ghost_level_{levelIndex}.dat"; var path = $"user://ghost_level_{levelIndex}.json";
if (!FileAccess.FileExists(path)) return []; if (!FileAccess.FileExists(path)) return [];
using var file = FileAccess.Open(path, FileAccess.ModeFlags.Read); try
var savedData = (Dictionary)file.GetVar();
var framesArray = (Array)savedData["frames"];
var frames = new List<GhostFrame>();
foreach (var obj in framesArray)
{ {
frames.Add((GhostFrame)obj); using var file = FileAccess.Open(path, FileAccess.ModeFlags.Read);
var saveData = JsonSerializer.Deserialize<GhostSaveData>(file.GetAsText(), SaveSystem.JsonOptions);
if (saveData == null) return [];
var frames = new List<GhostFrame>();
foreach (var dto in saveData.Frames)
frames.Add(new GhostFrame { Timestamp = dto.Timestamp, Position = new Vector2(dto.X, dto.Y) });
return frames;
}
catch (System.Exception e)
{
GD.PrintErr($"GhostManager: Failed to load ghost data: {e.Message}");
return [];
} }
return frames;
} }
private double LoadBestTime(int levelIndex) private double LoadBestTime(int levelIndex)
{ {
var path = $"user://ghost_level_{levelIndex}.dat"; var path = $"user://ghost_level_{levelIndex}.json";
if (!FileAccess.FileExists(path)) return double.MaxValue; if (!FileAccess.FileExists(path)) return double.MaxValue;
using var file = FileAccess.Open(path, FileAccess.ModeFlags.Read); try
var data = (Dictionary)file.GetVar(); {
return (double)data["time"]; using var file = FileAccess.Open(path, FileAccess.ModeFlags.Read);
var saveData = JsonSerializer.Deserialize<GhostSaveData>(file.GetAsText(), SaveSystem.JsonOptions);
return saveData?.BestTime ?? double.MaxValue;
}
catch
{
return double.MaxValue;
}
} }
} }

View File

@@ -0,0 +1,54 @@
using Godot;
namespace Mr.BrickAdventures.Autoloads;
public partial class InputDeviceManager : Node
{
public enum InputDevice { Mouse, Keyboard, Gamepad }
public static InputDeviceManager Instance { get; private set; }
public InputDevice CurrentDevice { get; private set; } = InputDevice.Mouse;
public bool IsPointerless => CurrentDevice is InputDevice.Keyboard or InputDevice.Gamepad;
[Signal] public delegate void DeviceChangedEventHandler(int device);
public override void _Ready()
{
Instance = this;
Input.MouseMode = Input.MouseModeEnum.Visible;
}
public override void _ExitTree()
{
if (Instance == this) Instance = null;
}
public override void _Input(InputEvent @event)
{
InputDevice detected;
if (@event is InputEventMouseMotion or InputEventMouseButton)
detected = InputDevice.Mouse;
else if (@event is InputEventKey)
detected = InputDevice.Keyboard;
else if (@event is InputEventJoypadButton)
detected = InputDevice.Gamepad;
else if (@event is InputEventJoypadMotion joyEvent)
{
if (Mathf.Abs(joyEvent.AxisValue) <= 0.15f) return;
detected = InputDevice.Gamepad;
}
else
return;
if (detected == CurrentDevice) return;
CurrentDevice = detected;
Input.MouseMode = CurrentDevice == InputDevice.Mouse
? Input.MouseModeEnum.Visible
: Input.MouseModeEnum.Hidden;
EmitSignal(SignalName.DeviceChanged, (int)CurrentDevice);
}
}

View File

@@ -0,0 +1 @@
uid://b81610g8fd6p1

View 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;
}
}

View File

@@ -0,0 +1 @@
uid://cjm0g50la7uie

View 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
}

View File

@@ -0,0 +1 @@
uid://b6amqp58yjfc4

View File

@@ -1,61 +1,176 @@
using System.Collections.Generic;
using System.Text.Json;
using Godot; using Godot;
using Godot.Collections;
using Mr.BrickAdventures.scripts.Resources; using Mr.BrickAdventures.scripts.Resources;
using Mr.BrickAdventures.scripts.State;
namespace Mr.BrickAdventures.Autoloads; namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Save system that serializes state to JSON using DTOs.
/// </summary>
public partial class SaveSystem : Node public partial class SaveSystem : Node
{ {
[Export] public string SavePath { get; set; } = "user://savegame.save"; [Export] public string SavePath { get; set; } = "user://savegame.json";
[Export] public int Version { get; set; } = 1; [Export] public int Version { get; set; } = 2;
private GameManager _gameManager; public static SaveSystem Instance { get; private set; }
internal static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public override void _Ready() public override void _Ready()
{ {
_gameManager = GetNode<GameManager>("/root/GameManager"); Instance = this;
}
public override void _ExitTree()
{
if (Instance == this) Instance = null;
} }
public void SaveGame() public void SaveGame()
{ {
var saveData = new Dictionary var store = GameStateStore.Instance;
if (store == null)
{ {
{ "player_state", _gameManager.PlayerState}, GD.PrintErr("SaveSystem: GameStateStore not available.");
{ "version", Version} return;
}
// Convert to DTO (only serializable data)
var saveData = new SaveDataDto
{
Version = Version,
Coins = store.Player.Coins,
Lives = store.Player.Lives,
CurrentLevel = store.Player.CurrentLevel,
CompletedLevels = [.. store.Player.CompletedLevels],
UnlockedLevels = new List<int>(store.Player.UnlockedLevels),
UnlockedSkillNames = GetSkillNames(store.Player.UnlockedSkills),
UnlockedAchievements = new List<string>(store.Player.UnlockedAchievements),
Statistics = new Dictionary<string, int>(store.Player.Statistics)
}; };
using var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Write); try
file.StoreVar(saveData); {
GD.Print("Game state saved to: ", SavePath); var json = JsonSerializer.Serialize(saveData, JsonOptions);
using var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Write);
file.StoreString(json);
GD.Print("Game saved to: ", SavePath);
EventBus.EmitGameSaved();
}
catch (System.Exception e)
{
GD.PrintErr($"SaveSystem: Failed to save game: {e.Message}");
}
} }
public bool LoadGame() public bool LoadGame()
{ {
if (!FileAccess.FileExists(SavePath)) if (!FileAccess.FileExists(SavePath))
return false;
using var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Read);
var saveDataObj = (Dictionary)file.GetVar();
if (saveDataObj.ContainsKey("version") && (int)saveDataObj["version"] != Version)
{ {
GD.Print($"Save file version mismatch. Expected: {Version}, Found: {saveDataObj["version"]}"); GD.Print("SaveSystem: No save file found.");
return false; return false;
} }
GD.Print("Game state loaded from: ", SavePath); try
GD.Print("Player state: ", saveDataObj["player_state"]);
_gameManager.PlayerState = (Dictionary)saveDataObj["player_state"];
var skills = new Array<SkillData>();
foreach (var skill in (Array<SkillData>)_gameManager.PlayerState["unlocked_skills"])
{ {
skills.Add(skill); using var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Read);
var json = file.GetAsText();
var saveData = JsonSerializer.Deserialize<SaveDataDto>(json, JsonOptions);
if (saveData == null)
{
GD.PrintErr("SaveSystem: Failed to deserialize save data.");
return false;
}
if (saveData.Version != Version)
{
GD.PrintErr($"SaveSystem: Version mismatch. Expected {Version}, found {saveData.Version}");
return false;
}
var store = GameStateStore.Instance;
if (store == null)
{
GD.PrintErr("SaveSystem: GameStateStore not available.");
return false;
}
// Apply loaded state
store.Player.Coins = saveData.Coins;
store.Player.Lives = saveData.Lives;
store.Player.CurrentLevel = saveData.CurrentLevel;
store.Player.CompletedLevels = saveData.CompletedLevels ?? new List<int>();
store.Player.UnlockedLevels = saveData.UnlockedLevels ?? new List<int> { 0 };
store.Player.UnlockedAchievements = saveData.UnlockedAchievements ?? new List<string>();
store.Player.Statistics = saveData.Statistics ?? new Dictionary<string, int>();
// Reload skills by name from SkillManager
store.Player.UnlockedSkills = LoadSkillsByName(saveData.UnlockedSkillNames);
GD.Print("Game loaded from: ", SavePath);
return true;
}
catch (System.Exception e)
{
GD.PrintErr($"SaveSystem: Failed to load game: {e.Message}");
return false;
}
}
private static List<string> GetSkillNames(List<SkillData> skills)
{
var names = new List<string>();
foreach (var skill in skills)
{
if (skill != null)
names.Add(skill.Name);
}
return names;
}
private List<SkillData> LoadSkillsByName(List<string> skillNames)
{
var skills = new List<SkillData>();
if (skillNames == null) return skills;
var skillManager = SkillManager.Instance;
if (skillManager == null)
{
GD.PrintErr("SaveSystem: SkillManager not available to resolve skill names.");
return skills;
} }
_gameManager.UnlockSkills(skills); foreach (var name in skillNames)
return true; {
var skill = skillManager.GetSkillByName(name);
if (skill != null)
{
skills.Add(skill);
}
else
{
GD.PrintErr($"SaveSystem: Skill '{name}' not found in SkillManager.");
}
}
return skills;
} }
public bool CheckSaveExists() => FileAccess.FileExists(SavePath); public bool CheckSaveExists() => FileAccess.FileExists(SavePath);
public void DeleteSave()
{
if (FileAccess.FileExists(SavePath))
{
DirAccess.RemoveAbsolute(ProjectSettings.GlobalizePath(SavePath));
GD.Print("Save file deleted.");
}
}
} }

View File

@@ -0,0 +1,203 @@
using Godot;
using System.Collections.Generic;
namespace Mr.BrickAdventures.Autoloads;
public partial class SettingsManager : Node
{
public static SettingsManager Instance { get; private set; }
public float GamepadDeadzone { get; set; } = 0.2f;
public float GamepadSensitivity { get; set; } = 1.0f;
public string WindowMode { get; set; } = "fullscreen";
public Vector2I Resolution { get; set; } = new Vector2I(1920, 1080);
private static readonly List<Vector2I> CuratedResolutions = new()
{
// 4:3
new Vector2I(640, 480),
new Vector2I(800, 600),
new Vector2I(1024, 768),
new Vector2I(1280, 960),
new Vector2I(1600, 1200),
// 16:9
new Vector2I(1280, 720),
new Vector2I(1366, 768),
new Vector2I(1600, 900),
new Vector2I(1920, 1080),
new Vector2I(2560, 1440),
new Vector2I(3840, 2160),
// 16:10
new Vector2I(1280, 800),
new Vector2I(1440, 900),
new Vector2I(1920, 1200),
new Vector2I(2560, 1600),
};
private readonly List<Vector2I> _customResolutions = new();
private static readonly string[] DeadzoneActions = { "left", "right", "up", "down" };
public override void _Ready()
{
Instance = this;
if (ConfigFileHandler.Instance == null)
{
GD.PushError("SettingsManager: ConfigFileHandler.Instance is null");
return;
}
var cfg = ConfigFileHandler.Instance.SettingsConfig;
// --- display_settings ---
WindowMode = cfg.GetValue("display_settings", "window_mode", Variant.From("fullscreen")).AsString();
var resStr = cfg.GetValue("display_settings", "resolution", Variant.From("1920x1080")).AsString();
Resolution = ParseResolution(resStr, new Vector2I(1920, 1080));
var customResStr = cfg.GetValue("display_settings", "custom_resolutions", Variant.From("")).AsString();
if (!string.IsNullOrWhiteSpace(customResStr))
{
foreach (var part in customResStr.Split(','))
{
var r = ParseResolution(part.Trim(), Vector2I.Zero);
if (r != Vector2I.Zero)
_customResolutions.Add(r);
}
}
ApplyDisplaySettings();
// --- gameplay_settings ---
GamepadDeadzone = (float)cfg.GetValue("gameplay_settings", "gamepad_deadzone", Variant.From(0.2)).AsDouble();
GamepadSensitivity = (float)cfg.GetValue("gameplay_settings", "gamepad_sensitivity", Variant.From(1.0)).AsDouble();
ApplyGamepadDeadzone();
// --- input_settings ---
ApplyInputBindings(cfg);
}
public override void _ExitTree()
{
if (Instance == this) Instance = null;
}
// ── public API ───────────────────────────────────────────────────────────
public List<Vector2I> GetAllResolutions()
{
var all = new List<Vector2I>(CuratedResolutions);
all.AddRange(_customResolutions);
return all;
}
public void SaveDisplaySettings()
{
var cfg = ConfigFileHandler.Instance.SettingsConfig;
cfg.SetValue("display_settings", "window_mode", WindowMode);
cfg.SetValue("display_settings", "resolution", $"{Resolution.X}x{Resolution.Y}");
cfg.SetValue("display_settings", "custom_resolutions", BuildCustomResolutionsString());
cfg.Save(ConfigFileHandler.SettingsPath);
}
public void SaveGameplaySettings()
{
var cfg = ConfigFileHandler.Instance.SettingsConfig;
cfg.SetValue("gameplay_settings", "gamepad_deadzone", (double)GamepadDeadzone);
cfg.SetValue("gameplay_settings", "gamepad_sensitivity", (double)GamepadSensitivity);
cfg.Save(ConfigFileHandler.SettingsPath);
}
public void SaveInputSettings()
{
var cfg = ConfigFileHandler.Instance.SettingsConfig;
foreach (var action in InputMap.GetActions())
{
if (action.ToString().StartsWith("ui_")) continue;
foreach (var ev in InputMap.ActionGetEvents(action))
{
if (ev is InputEventKey key)
{
cfg.SetValue("input_settings", action, (long)key.PhysicalKeycode);
break;
}
}
}
cfg.Save(ConfigFileHandler.SettingsPath);
}
// ── apply helpers ────────────────────────────────────────────────────────
public void ApplyDisplaySettings()
{
switch (WindowMode)
{
case "fullscreen":
DisplayServer.WindowSetMode(DisplayServer.WindowMode.Fullscreen);
break;
case "borderless":
DisplayServer.WindowSetMode(DisplayServer.WindowMode.Windowed);
DisplayServer.WindowSetFlag(DisplayServer.WindowFlags.Borderless, true);
DisplayServer.WindowSetSize(Resolution);
break;
default: // "windowed"
DisplayServer.WindowSetMode(DisplayServer.WindowMode.Windowed);
DisplayServer.WindowSetFlag(DisplayServer.WindowFlags.Borderless, false);
DisplayServer.WindowSetSize(Resolution);
break;
}
}
public void ApplyGamepadDeadzone()
{
foreach (var action in DeadzoneActions)
{
if (InputMap.HasAction(action))
InputMap.ActionSetDeadzone(action, GamepadDeadzone);
}
}
private static void ApplyInputBindings(ConfigFile cfg)
{
if (!cfg.HasSection("input_settings")) return;
foreach (var actionName in cfg.GetSectionKeys("input_settings"))
{
if (actionName.StartsWith("ui_")) continue;
if (!InputMap.HasAction(actionName)) continue;
var scancode = (Key)(long)cfg.GetValue("input_settings", actionName);
// Remove existing keyboard events for this action
var events = InputMap.ActionGetEvents(actionName);
foreach (var ev in events)
{
if (ev is InputEventKey)
InputMap.ActionEraseEvent(actionName, ev);
}
// Add the saved key
var newKey = new InputEventKey { PhysicalKeycode = scancode };
InputMap.ActionAddEvent(actionName, newKey);
}
}
// ── util ─────────────────────────────────────────────────────────────────
private static Vector2I ParseResolution(string s, Vector2I fallback)
{
var parts = s.Split('x');
if (parts.Length == 2 && int.TryParse(parts[0], out var w) && int.TryParse(parts[1], out var h))
return new Vector2I(w, h);
return fallback;
}
private string BuildCustomResolutionsString()
{
var parts = new List<string>();
foreach (var r in _customResolutions)
parts.Add($"{r.X}x{r.Y}");
return string.Join(",", parts);
}
}

View File

@@ -0,0 +1 @@
uid://d4jyxtm4jgtka

View File

@@ -1,22 +1,42 @@
using System.Collections.Generic;
using System.Linq; using System.Linq;
using Godot; using Godot;
using Godot.Collections; using Godot.Collections;
using Mr.BrickAdventures.Progression;
using Mr.BrickAdventures.scripts.components; using Mr.BrickAdventures.scripts.components;
using Mr.BrickAdventures.scripts.interfaces; using Mr.BrickAdventures.scripts.interfaces;
using Mr.BrickAdventures.scripts.Resources; using Mr.BrickAdventures.scripts.Resources;
namespace Mr.BrickAdventures.Autoloads; 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 public partial class SkillManager : Node
{ {
private GameManager _gameManager;
private PlayerController _player; private PlayerController _player;
[Export] public Array<SkillData> AvailableSkills { get; set; } = []; [Export] public Array<SkillData> AvailableSkills { get; set; } = [];
public Dictionary ActiveComponents { get; private set; } = new(); 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] [Signal]
public delegate void ActiveThrowSkillChangedEventHandler(BrickThrowComponent throwComponent); public delegate void ActiveThrowSkillChangedEventHandler(BrickThrowComponent throwComponent);
[Signal] [Signal]
@@ -24,7 +44,16 @@ public partial class SkillManager : Node
public override void _Ready() public override void _Ready()
{ {
_gameManager = GetNode<GameManager>("/root/GameManager"); Instance = this;
EventBus.Instance.SkillSetChanged += Reconcile;
}
public override void _ExitTree()
{
if (EventBus.Instance != null)
EventBus.Instance.SkillSetChanged -= Reconcile;
if (Instance == this) Instance = null;
} }
/// <summary> /// <summary>
@@ -34,24 +63,17 @@ public partial class SkillManager : Node
{ {
if (_player == player) return; if (_player == player) return;
// If a player is already registered (e.g., from a previous scene), unregister it first.
if (_player != null && IsInstanceValid(_player)) if (_player != null && IsInstanceValid(_player))
{
UnregisterPlayer(); UnregisterPlayer();
}
_player = player; _player = player;
if (_player != null) if (_player != null)
{ {
// Automatically unregister when the player node is removed from the scene.
_player.TreeExiting += UnregisterPlayer; _player.TreeExiting += UnregisterPlayer;
ApplyUnlockedSkills(); Reconcile();
} }
} }
/// <summary>
/// Cleans up skills and references related to the current player.
/// </summary>
private void UnregisterPlayer() private void UnregisterPlayer()
{ {
if (_player != null && IsInstanceValid(_player)) if (_player != null && IsInstanceValid(_player))
@@ -62,9 +84,37 @@ public partial class SkillManager : Node
_player = null; _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)) if (_player == null || !IsInstanceValid(_player))
{ {
GD.Print("SkillManager: Player not available to add skill."); GD.Print("SkillManager: Player not available to add skill.");
@@ -76,29 +126,17 @@ public partial class SkillManager : Node
if (skillData.Type == SkillType.Throw) if (skillData.Type == SkillType.Throw)
{ {
var unlocked = _gameManager.GetUnlockedSkills(); foreach (var sd in AvailableSkills)
foreach (var sd in unlocked)
{ {
SkillData data = null; if (sd != skillData && sd?.Type == SkillType.Throw)
foreach (var s in AvailableSkills) RemoveSkill(sd.Name);
{
if (s == sd)
{
data = s;
break;
}
}
// Remove other throw skills if a new one is added
if (data is { Type: SkillType.Throw })
RemoveSkill(data.Name);
} }
} }
var instance = skillData.Node.Instantiate(); var instance = skillData.Node.Instantiate();
if (instance is ISkill skill) if (instance is ISkill skill)
{ {
// Initialize the skill with the registered player instance. skill.Initialize(_player, skillData, level);
skill.Initialize(_player, skillData);
skill.Activate(); skill.Activate();
} }
else else
@@ -108,44 +146,31 @@ public partial class SkillManager : Node
return; return;
} }
// Add the skill node as a child of the player.
_player.AddChild(instance); _player.AddChild(instance);
ActiveComponents[skillData.Name] = instance; ActiveComponents[skillData.Name] = instance;
_activeLevels[skillData.Name] = level;
if (instance is BrickThrowComponent btc) if (instance is BrickThrowComponent btc)
{ EmitSignalActiveThrowSkillChanged(btc);
EmitSignalActiveThrowSkillChanged(btc);
}
} }
public void RemoveSkill(string skillName) public void RemoveSkill(string skillName)
{ {
_activeLevels.Remove(skillName);
if (!ActiveComponents.TryGetValue(skillName, out var component)) if (!ActiveComponents.TryGetValue(skillName, out var component))
return; return;
if (component.AsGodotObject() is BrickThrowComponent) if (component.AsGodotObject() is BrickThrowComponent)
{
EmitSignalActiveThrowSkillChanged(null); EmitSignalActiveThrowSkillChanged(null);
}
var inst = (Node)component; var inst = (Node)component;
if (inst is ISkill skill) if (inst is ISkill skill)
{
skill.Deactivate(); skill.Deactivate();
}
if (IsInstanceValid(inst)) if (IsInstanceValid(inst))
inst.QueueFree(); inst.QueueFree();
var skills = _gameManager.GetUnlockedSkills();
foreach (var s in skills)
{
if (s.Name == skillName)
{
s.IsActive = false;
break;
}
}
ActiveComponents.Remove(skillName); ActiveComponents.Remove(skillName);
var sd = GetSkillByName(skillName); var sd = GetSkillByName(skillName);
if (sd != null) EmitSignalSkillRemoved(sd); if (sd != null) EmitSignalSkillRemoved(sd);
@@ -153,56 +178,30 @@ public partial class SkillManager : Node
private void RemoveAllActiveSkills() private void RemoveAllActiveSkills()
{ {
// Create a copy of keys to avoid modification during iteration foreach (var skillName in ActiveComponents.Keys.Select(k => k.ToString()).ToList())
var keys = ActiveComponents.Keys.ToArray();
var skillNames = keys.Select(key => key.ToString()).ToList();
foreach (var skillName in skillNames)
{
RemoveSkill(skillName); RemoveSkill(skillName);
}
}
public void ApplyUnlockedSkills()
{
if (_player == null || !IsInstanceValid(_player)) return;
foreach (var sd in AvailableSkills)
{
if (_gameManager.IsSkillUnlocked(sd))
{
CallDeferred(MethodName.AddSkill, sd);
}
else
{
RemoveSkill(sd.Name);
}
}
} }
public SkillData GetSkillByName(string skillName) public SkillData GetSkillByName(string skillName)
{ {
foreach (var sd in AvailableSkills) foreach (var sd in AvailableSkills)
if (sd.Name == skillName) return sd; if (sd?.Name == skillName) return sd;
return null; return null;
} }
public bool IsSkillActive(SkillData skill) =>
skill != null && ActiveComponents.ContainsKey(skill.Name);
public void ActivateSkill(SkillData skill) public void ActivateSkill(SkillData skill)
{ {
if (!ActiveComponents.ContainsKey(skill.Name)) if (!ActiveComponents.ContainsKey(skill.Name))
{
AddSkill(skill); AddSkill(skill);
skill.IsActive = true;
}
} }
public void DeactivateSkill(SkillData skill) public void DeactivateSkill(SkillData skill)
{ {
if (ActiveComponents.ContainsKey(skill.Name)) if (ActiveComponents.ContainsKey(skill.Name))
{
RemoveSkill(skill.Name); RemoveSkill(skill.Name);
skill.IsActive = false;
}
} }
public void ToggleSkillActivation(SkillData skill) public void ToggleSkillActivation(SkillData skill)

View File

@@ -5,8 +5,12 @@ namespace Mr.BrickAdventures.Autoloads;
public partial class SpeedRunManager : Node public partial class SpeedRunManager : Node
{ {
public static SpeedRunManager Instance { get; private set; }
public override void _EnterTree() => Instance = this;
public override void _ExitTree() { if (Instance == this) Instance = null; }
public bool IsRunning { get; private set; } = false; public bool IsRunning { get; private set; } = false;
public bool IsVisible { get; private set; } = false;
private double _startTime; private double _startTime;
private double _levelStartTime; private double _levelStartTime;
@@ -16,7 +20,7 @@ public partial class SpeedRunManager : Node
public override void _Process(double delta) public override void _Process(double delta)
{ {
if (!IsRunning || !IsVisible) return; if (!IsRunning) return;
EmitSignalTimeUpdated(GetCurrentTotalTime(), GetCurrentLevelTime()); EmitSignalTimeUpdated(GetCurrentTotalTime(), GetCurrentLevelTime());
} }

View File

@@ -1,78 +0,0 @@
using Godot;
using Godot.Collections;
namespace Mr.BrickAdventures.Autoloads;
public partial class StatisticsManager : Node
{
private GameManager _gameManager;
private AchievementManager _achievementManager;
private Dictionary<string, Variant> _stats = new();
public override void _Ready()
{
_gameManager = GetNode<GameManager>("/root/GameManager");
_achievementManager = GetNode<AchievementManager>("/root/AchievementManager");
LoadStatistics();
}
private void LoadStatistics()
{
if (_gameManager.PlayerState.TryGetValue("statistics", out var statsObj))
{
_stats = (Dictionary<string, Variant>)statsObj;
}
else
{
_stats = new Dictionary<string, Variant>();
_gameManager.PlayerState["statistics"] = _stats;
}
}
/// <summary>
/// Increases a numerical statistic by a given amount.
/// </summary>
public void IncrementStat(string statName, int amount = 1)
{
if (_stats.TryGetValue(statName, out var currentValue))
{
_stats[statName] = (int)currentValue + amount;
}
else
{
_stats[statName] = amount;
}
GD.Print($"Stat '{statName}' updated to: {_stats[statName]}");
CheckAchievementsForStat(statName);
}
/// <summary>
/// Gets the value of a statistic.
/// </summary>
public Variant GetStat(string statName, Variant defaultValue = default)
{
return _stats.TryGetValue(statName, out var value) ? value : defaultValue;
}
/// <summary>
/// Checks if the updated stat meets the criteria for any achievements.
/// </summary>
private void CheckAchievementsForStat(string statName)
{
switch (statName)
{
case "enemies_defeated":
if ((int)GetStat(statName, 0) >= 100)
{
_achievementManager.UnlockAchievement("slayer_100_enemies");
}
break;
case "jumps_made":
if ((int)GetStat(statName, 0) >= 1000)
{
_achievementManager.UnlockAchievement("super_jumper");
}
break;
}
}
}

View File

@@ -1 +0,0 @@
uid://c5p3l2mhkw0p4

View File

@@ -1,11 +1,22 @@
using Godot; using Godot;
using Godot.Collections;
namespace Mr.BrickAdventures.Autoloads; namespace Mr.BrickAdventures.Autoloads;
public partial class UIManager : Node public partial class UIManager : Node
{ {
[Export] public Array<Control> UiStack { get; set; } = new(); public static UIManager Instance { get; private set; }
private readonly System.Collections.Generic.List<Control> UiStack = new();
public override void _Ready()
{
Instance = this;
}
public override void _ExitTree()
{
if (Instance == this) Instance = null;
}
[Signal] public delegate void ScreenPushedEventHandler(Control screen); [Signal] public delegate void ScreenPushedEventHandler(Control screen);
[Signal] public delegate void ScreenPoppedEventHandler(Control screen); [Signal] public delegate void ScreenPoppedEventHandler(Control screen);
@@ -34,19 +45,19 @@ public partial class UIManager : Node
return; return;
} }
var top = (Control)UiStack[^1]; var top = UiStack[^1];
UiStack.RemoveAt(UiStack.Count - 1); UiStack.RemoveAt(UiStack.Count - 1);
top.Hide(); top.Hide();
top.SetProcessInput(false); top.SetProcessInput(false);
EmitSignalScreenPopped(top); EmitSignalScreenPopped(top);
top.AcceptEvent(); top.AcceptEvent();
if (UiStack.Count > 0) ((Control)UiStack[^1]).GrabFocus(); if (UiStack.Count > 0) UiStack[^1].GrabFocus();
} }
public Control TopScreen() => UiStack.Count > 0 ? (Control)UiStack[^1] : null; public Control TopScreen() => UiStack.Count > 0 ? UiStack[^1] : null;
public bool IsScreenOnTop(Control screen) => UiStack.Count > 0 && (Control)UiStack[^1] == screen; public bool IsScreenOnTop(Control screen) => UiStack.Count > 0 && UiStack[^1] == screen;
public bool IsVisibleOnStack(Control screen) => UiStack.Contains(screen) && screen.Visible; public bool IsVisibleOnStack(Control screen) => UiStack.Contains(screen) && screen.Visible;

103
CONTEXT.md Normal file
View 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.

View File

@@ -1,4 +1,4 @@
<Project Sdk="Godot.NET.Sdk/4.4.1"> <Project Sdk="Godot.NET.Sdk/4.7.1">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<EnableDynamicLoading>true</EnableDynamicLoading> <EnableDynamicLoading>true</EnableDynamicLoading>
@@ -8,6 +8,18 @@
<PackageReference Include="Facepunch.Steamworks" Version="2.3.3" /> <PackageReference Include="Facepunch.Steamworks" Version="2.3.3" />
<PackageReference Include="Facepunch.Steamworks.Dlls" Version="2.3.2" /> <PackageReference Include="Facepunch.Steamworks.Dlls" Version="2.3.2" />
<PackageReference Include="Facepunch.Steamworks.Library" Version="2.3.3" /> <PackageReference Include="Facepunch.Steamworks.Library" Version="2.3.3" />
<PackageReference Include="LimboConsole.Sharp" Version="0.0.1-beta-008" /> </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> </ItemGroup>
</Project> </Project>

View File

@@ -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 # Visual Studio 2012
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr. Brick Adventures", "Mr. Brick Adventures.csproj", "{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr. Brick Adventures", "Mr. Brick Adventures.csproj", "{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}"
EndProject 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
ExportDebug|Any CPU = ExportDebug|Any CPU Debug|x64 = Debug|x64
ExportRelease|Any CPU = ExportRelease|Any CPU 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 EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {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|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.ActiveCfg = ExportDebug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|Any CPU.Build.0 = 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.ActiveCfg = ExportRelease|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|Any CPU.Build.0 = 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 EndGlobalSection
EndGlobal EndGlobal

View File

@@ -1,4 +1,5 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> <wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AArea2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F116d8c5f8dae51522ba398e1d89e3d4722f4af7b6e7f071196b928be44af7_003FArea2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACamera2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fa2e12a1a67ad701a97608de6be85250e3e353951ecf8058a02c703490c753_003FCamera2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACamera2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fa2e12a1a67ad701a97608de6be85250e3e353951ecf8058a02c703490c753_003FCamera2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACanvasItem_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fef7b819b226fab796d1dfe66d415dd7510bcac87675020ddb8f03a828e763_003FCanvasItem_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACanvasItem_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fef7b819b226fab796d1dfe66d415dd7510bcac87675020ddb8f03a828e763_003FCanvasItem_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACecovym_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003Ftmp_003FJetBrainsPerUserTemp_002D1000_002D1_003FSandboxFiles_003FSadijuw_003FCecovym_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACecovym_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003Ftmp_003FJetBrainsPerUserTemp_002D1000_002D1_003FSandboxFiles_003FSadijuw_003FCecovym_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
@@ -10,5 +11,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AList_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fe747192abb38e2df82cbdb37e721567726f559914a7b81f8b26ba537de632f4_003FList_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AList_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fe747192abb38e2df82cbdb37e721567726f559914a7b81f8b26ba537de632f4_003FList_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMr_002EBrickAdventures_002Escripts_002Ecomponents_002ECollectableComponent_005FScriptSignals_002Egenerated_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F80d9408eb7280c15eb4a12b61cdf8f7f1b0c5a2_003FMr_002EBrickAdventures_002Escripts_002Ecomponents_002ECollectableComponent_005FScriptSignals_002Egenerated_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMr_002EBrickAdventures_002Escripts_002Ecomponents_002ECollectableComponent_005FScriptSignals_002Egenerated_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F80d9408eb7280c15eb4a12b61cdf8f7f1b0c5a2_003FMr_002EBrickAdventures_002Escripts_002Ecomponents_002ECollectableComponent_005FScriptSignals_002Egenerated_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANode2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F86db9cd834346aad02d74c1b66dd9c64d6ef3147435dd9c9c9477b48f7_003FNode2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANode2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F86db9cd834346aad02d74c1b66dd9c64d6ef3147435dd9c9c9477b48f7_003FNode2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APhysicsBody2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F4fcd28ddaffa68e6fe53288161b788dea7d402b4a41b9d9f0f0f2c52f9af075_003FPhysicsBody2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARectangleShape2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fa1cc98873548652da0c14ecefa4737431426fcbb24a7f0641e3d9c266c3_003FRectangleShape2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARectangleShape2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fa1cc98873548652da0c14ecefa4737431426fcbb24a7f0641e3d9c266c3_003FRectangleShape2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARigidBody2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F7bc3ccc1ac5bbc68933d64c7b7eb5ab4aecde2b73c686dd6495b68bdf08ba5b2_003FRigidBody2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AShape2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F3671dbbd9b17cdf2bf9075b468b6bd7e3ab13fc3be7a116484085d3b6cc9fe_003FShape2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AShape2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F3671dbbd9b17cdf2bf9075b468b6bd7e3ab13fc3be7a116484085d3b6cc9fe_003FShape2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>

View 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; }
}
}

View File

@@ -0,0 +1 @@
uid://exr10b7nmkgn

View 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;
}

View File

@@ -0,0 +1 @@
uid://isnscgr1thvc

View 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

View File

@@ -0,0 +1 @@
uid://6y4snwf31boh

View 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

View File

@@ -0,0 +1 @@
uid://dnuvfv3rgyywd

View 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

View File

@@ -0,0 +1 @@
uid://cojnhxpcgepi2

View 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

View File

@@ -0,0 +1 @@
uid://y55l4iw35tih

View 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")

View 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"

View File

@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/banner.png-7e9e6a304eef850602c8d5afb80df9c3.c
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/icon.svg-17eb5d3e2a3cfbe59852220758c5b7bd.cte
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -26,6 +28,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/responses_menu.svg-87cf63ca685d53616205049572
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -26,6 +28,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/update.svg-f1628866ed4eb2e13e3b81f75443687e.c
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -21,6 +21,7 @@ msdf_pixel_range=8
msdf_size=48 msdf_size=48
allow_system_fallback=true allow_system_fallback=true
force_autohinter=false force_autohinter=false
modulate_color_glyphs=false
hinting=1 hinting=1
subpixel_positioning=1 subpixel_positioning=1
keep_rounding_remainders=true keep_rounding_remainders=true

View File

@@ -21,6 +21,7 @@ msdf_pixel_range=8
msdf_size=48 msdf_size=48
allow_system_fallback=true allow_system_fallback=true
force_autohinter=false force_autohinter=false
modulate_color_glyphs=false
hinting=1 hinting=1
subpixel_positioning=1 subpixel_positioning=1
keep_rounding_remainders=true keep_rounding_remainders=true

View File

@@ -21,6 +21,7 @@ msdf_pixel_range=8
msdf_size=48 msdf_size=48
allow_system_fallback=true allow_system_fallback=true
force_autohinter=false force_autohinter=false
modulate_color_glyphs=false
hinting=1 hinting=1
subpixel_positioning=1 subpixel_positioning=1
keep_rounding_remainders=true keep_rounding_remainders=true

View File

@@ -21,6 +21,7 @@ msdf_pixel_range=8
msdf_size=48 msdf_size=48
allow_system_fallback=true allow_system_fallback=true
force_autohinter=false force_autohinter=false
modulate_color_glyphs=false
hinting=1 hinting=1
subpixel_positioning=1 subpixel_positioning=1
keep_rounding_remainders=true keep_rounding_remainders=true

View File

@@ -21,6 +21,7 @@ msdf_pixel_range=8
msdf_size=48 msdf_size=48
allow_system_fallback=true allow_system_fallback=true
force_autohinter=false force_autohinter=false
modulate_color_glyphs=false
hinting=1 hinting=1
subpixel_positioning=1 subpixel_positioning=1
keep_rounding_remainders=true keep_rounding_remainders=true

View File

@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/PhantomCameraBtnPrimaryDefault.png-fcf3696b58
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/PhantomCameraBtnPrimaryHover.png-3d2e4d225f6a
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/inventory_container.png-12241277f279bfc4bf7d5
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/level_spritesheet.png-26a44dd21a040a5480d5ccb
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_2d_sprite.png-deab230b83ae03ae
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/player_sprite.svg-8862ecb19e12152eb892607676f
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/sign_prompt.png-18d451127e1cd1a16367acd23cec4
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/checker_pattern_dark.png-70cedad2d3abf4ad6166
compress/mode=2 compress/mode=2
compress/high_quality=true compress/high_quality=true
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -26,6 +28,10 @@ mipmaps/generate=true
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/target.png-878c5e8d057c8a9a4c2322d4ab88e9ef.s
compress/mode=2 compress/mode=2
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -26,6 +28,10 @@ mipmaps/generate=true
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -21,6 +21,7 @@ msdf_pixel_range=8
msdf_size=48 msdf_size=48
allow_system_fallback=true allow_system_fallback=true
force_autohinter=false force_autohinter=false
modulate_color_glyphs=false
hinting=1 hinting=1
subpixel_positioning=1 subpixel_positioning=1
keep_rounding_remainders=true keep_rounding_remainders=true

View File

@@ -21,6 +21,7 @@ msdf_pixel_range=8
msdf_size=48 msdf_size=48
allow_system_fallback=true allow_system_fallback=true
force_autohinter=false force_autohinter=false
modulate_color_glyphs=false
hinting=1 hinting=1
subpixel_positioning=1 subpixel_positioning=1
keep_rounding_remainders=true keep_rounding_remainders=true

View File

@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/PriorityOverride.svg-e76e07f4bbd98169f119e17f
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_2d.svg-e5483cbc858fc5f95f7210b
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_3d.svg-41ed612e834470377fb56ee
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_camera_3d_resource.svg-f8bf8d1
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/phantom_camera_gizmo.svg-ba1aacb9b1c5f4ef401d
compress/mode=2 compress/mode=2
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -26,6 +28,10 @@ mipmaps/generate=true
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_glow_logo.png-078f944973b55b32
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/phantom_camera_host.svg-3150f8f2d82ca9ecab9a3
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -26,6 +28,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_logo.png-8b8d347b5e4800c86cd80
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_noise_emitter_2d.svg-1b3d37fe3
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/phantom_camera_noise_emitter_3d.svg-9b90fe54a
compress/mode=2 compress/mode=2
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -26,6 +28,10 @@ mipmaps/generate=true
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/phantom_camera_noise_emitter_gizmo.svg-9a5938
compress/mode=2 compress/mode=2
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -26,6 +28,10 @@ mipmaps/generate=true
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_noise_resource.svg-a81ed223714
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/phantom_camera_tween.svg-16faced08ef4a5f34582
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -26,6 +28,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_updater_panel_icon.svg-19823e6
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/Camera2DIcon.svg-300e6f57281180711c5ecf391104
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/Camera3DIcon.svg-4805c46004db1c89cc9443dd7406
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/SceneTypesIcon.svg-66e2255bd3398007bec03a5cbf
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/Select.svg-cdf90b8b400d3b91a023b70c6a823894.c
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/warning.svg-c1b21c265e0842bbdc9ed10735995366.
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false

View File

@@ -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="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"] [ext_resource type="StyleBox" uid="uid://5weqvkjsfso3" path="res://addons/phantom_camera/themes/button_hover.tres" id="2_du6h5"]

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View File

@@ -21,6 +21,7 @@ msdf_pixel_range=8
msdf_size=48 msdf_size=48
allow_system_fallback=true allow_system_fallback=true
force_autohinter=false force_autohinter=false
modulate_color_glyphs=false
hinting=1 hinting=1
subpixel_positioning=1 subpixel_positioning=1
keep_rounding_remainders=true keep_rounding_remainders=true

View File

@@ -21,6 +21,7 @@ msdf_pixel_range=8
msdf_size=48 msdf_size=48
allow_system_fallback=true allow_system_fallback=true
force_autohinter=false force_autohinter=false
modulate_color_glyphs=false
hinting=1 hinting=1
subpixel_positioning=1 subpixel_positioning=1
keep_rounding_remainders=true keep_rounding_remainders=true

View File

@@ -21,6 +21,7 @@ msdf_pixel_range=8
msdf_size=48 msdf_size=48
allow_system_fallback=true allow_system_fallback=true
force_autohinter=false force_autohinter=false
modulate_color_glyphs=false
hinting=1 hinting=1
subpixel_positioning=1 subpixel_positioning=1
keep_rounding_remainders=true keep_rounding_remainders=true

View File

@@ -21,6 +21,7 @@ msdf_pixel_range=8
msdf_size=48 msdf_size=48
allow_system_fallback=true allow_system_fallback=true
force_autohinter=false force_autohinter=false
modulate_color_glyphs=false
hinting=1 hinting=1
subpixel_positioning=1 subpixel_positioning=1
keep_rounding_remainders=true keep_rounding_remainders=true

View File

@@ -1,4 +1,4 @@
[gd_scene load_steps=7 format=3 uid="uid://bargnp4twtmxg"] [gd_scene format=3 uid="uid://bargnp4twtmxg"]
[ext_resource type="Texture2D" uid="uid://djifxc5x0dyrw" path="res://sprites/ppc_tileset.png" id="1_aya2w"] [ext_resource type="Texture2D" uid="uid://djifxc5x0dyrw" path="res://sprites/ppc_tileset.png" id="1_aya2w"]
[ext_resource type="Script" uid="uid://r4jybneigfcn" path="res://scripts/components/CollectableComponent.cs" id="2_htmrw"] [ext_resource type="Script" uid="uid://r4jybneigfcn" path="res://scripts/components/CollectableComponent.cs" id="2_htmrw"]
@@ -9,33 +9,33 @@
[sub_resource type="CircleShape2D" id="CircleShape2D_3ask2"] [sub_resource type="CircleShape2D" id="CircleShape2D_3ask2"]
radius = 9.0 radius = 9.0
[node name="Big Coin" type="Area2D" groups=["coins"]] [node name="Big Coin" type="Area2D" unique_id=354254828 groups=["coins"]]
scale = Vector2(2, 2) scale = Vector2(2, 2)
collision_layer = 2 collision_layer = 2
collision_mask = 4 collision_mask = 4
[node name="CollisionShape2D" type="CollisionShape2D" parent="."] [node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=2066526597]
shape = SubResource("CircleShape2D_3ask2") shape = SubResource("CircleShape2D_3ask2")
[node name="Sprite2D" type="Sprite2D" parent="."] [node name="Sprite2D" type="Sprite2D" parent="." unique_id=1626463721]
texture = ExtResource("1_aya2w") texture = ExtResource("1_aya2w")
hframes = 12 hframes = 12
vframes = 12 vframes = 12
frame = 51 frame = 51
[node name="CollectableComponent" type="Node" parent="." node_paths=PackedStringArray("Area2D", "CollisionShape", "Sfx")] [node name="CollectableComponent" type="Node" parent="." unique_id=1124310030 node_paths=PackedStringArray("Area2D", "CollisionShape", "Sfx")]
script = ExtResource("2_htmrw") script = ExtResource("2_htmrw")
Area2D = NodePath("..") Area2D = NodePath("..")
CollisionShape = NodePath("../CollisionShape2D") CollisionShape = NodePath("../CollisionShape2D")
Data = ExtResource("3_lk3av") Data = ExtResource("3_lk3av")
Sfx = NodePath("../sfx") Sfx = NodePath("../sfx")
[node name="FadeAwayComponent" type="Node" parent="." node_paths=PackedStringArray("Sprite", "Area")] [node name="FadeAwayComponent" type="Node" parent="." unique_id=222617739 node_paths=PackedStringArray("Sprite", "Area")]
script = ExtResource("4_62p7g") script = ExtResource("4_62p7g")
Sprite = NodePath("../Sprite2D") Sprite = NodePath("../Sprite2D")
Area = NodePath("..") Area = NodePath("..")
[node name="sfx" type="AudioStreamPlayer2D" parent="."] [node name="sfx" type="AudioStreamPlayer2D" parent="." unique_id=1978747299]
stream = ExtResource("5_dbffd") stream = ExtResource("5_dbffd")
volume_db = -5.0 volume_db = -5.0
bus = &"sfx" bus = &"sfx"

View File

@@ -1,4 +1,4 @@
[gd_scene load_steps=7 format=3 uid="uid://d08dfqmirnd66"] [gd_scene format=3 uid="uid://d08dfqmirnd66"]
[ext_resource type="Texture2D" uid="uid://djifxc5x0dyrw" path="res://sprites/ppc_tileset.png" id="1_1co1x"] [ext_resource type="Texture2D" uid="uid://djifxc5x0dyrw" path="res://sprites/ppc_tileset.png" id="1_1co1x"]
[ext_resource type="Script" uid="uid://r4jybneigfcn" path="res://scripts/components/CollectableComponent.cs" id="2_lthbn"] [ext_resource type="Script" uid="uid://r4jybneigfcn" path="res://scripts/components/CollectableComponent.cs" id="2_lthbn"]
@@ -9,32 +9,32 @@
[sub_resource type="CircleShape2D" id="CircleShape2D_3ask2"] [sub_resource type="CircleShape2D" id="CircleShape2D_3ask2"]
radius = 9.0 radius = 9.0
[node name="Big Treasure" type="Area2D" groups=["coins"]] [node name="Big Treasure" type="Area2D" unique_id=1021217632 groups=["coins"]]
collision_layer = 2 collision_layer = 2
collision_mask = 4 collision_mask = 4
[node name="CollisionShape2D" type="CollisionShape2D" parent="."] [node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=458290011]
shape = SubResource("CircleShape2D_3ask2") shape = SubResource("CircleShape2D_3ask2")
[node name="Sprite2D" type="Sprite2D" parent="."] [node name="Sprite2D" type="Sprite2D" parent="." unique_id=564060301]
texture = ExtResource("1_1co1x") texture = ExtResource("1_1co1x")
hframes = 12 hframes = 12
vframes = 12 vframes = 12
frame = 64 frame = 64
[node name="CollectableComponent" type="Node" parent="." node_paths=PackedStringArray("Area2D", "CollisionShape", "Sfx")] [node name="CollectableComponent" type="Node" parent="." unique_id=2009195182 node_paths=PackedStringArray("Area2D", "CollisionShape", "Sfx")]
script = ExtResource("2_lthbn") script = ExtResource("2_lthbn")
Area2D = NodePath("..") Area2D = NodePath("..")
CollisionShape = NodePath("../CollisionShape2D") CollisionShape = NodePath("../CollisionShape2D")
Data = ExtResource("3_k64cr") Data = ExtResource("3_k64cr")
Sfx = NodePath("../sfx") Sfx = NodePath("../sfx")
[node name="FadeAwayComponent" type="Node" parent="." node_paths=PackedStringArray("Sprite", "Area")] [node name="FadeAwayComponent" type="Node" parent="." unique_id=1809580000 node_paths=PackedStringArray("Sprite", "Area")]
script = ExtResource("4_qwwsj") script = ExtResource("4_qwwsj")
Sprite = NodePath("../Sprite2D") Sprite = NodePath("../Sprite2D")
FadeDuration = 0.4 FadeDuration = 0.4
Area = NodePath("..") Area = NodePath("..")
[node name="sfx" type="AudioStreamPlayer2D" parent="."] [node name="sfx" type="AudioStreamPlayer2D" parent="." unique_id=466669009]
stream = ExtResource("5_fxf8v") stream = ExtResource("5_fxf8v")
bus = &"sfx" bus = &"sfx"

View File

@@ -1,25 +1,14 @@
[gd_scene load_steps=58 format=3 uid="uid://bqi5s710xb1ju"] [gd_scene format=3 uid="uid://bqi5s710xb1ju"]
[ext_resource type="Script" uid="uid://csel4s0e4g5uf" path="res://scripts/components/PlayerController.cs" id="1_yysbb"] [ext_resource type="Script" uid="uid://csel4s0e4g5uf" path="res://scripts/components/PlayerController.cs" id="1_yysbb"]
[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="Shader" uid="uid://bs4xvm4qkurpr" path="res://shaders/hit_flash.tres" id="2_lgb3u"]
[ext_resource type="PackedScene" uid="uid://bcv8kx6bc7u5e" path="res://objects/movement_abilities/ground_ability.tscn" id="2_oefns"]
[ext_resource type="Texture2D" uid="uid://jl1gwqchhpdc" path="res://sprites/left_eye.png" id="3_2srrh"] [ext_resource type="Texture2D" uid="uid://jl1gwqchhpdc" path="res://sprites/left_eye.png" id="3_2srrh"]
[ext_resource type="PackedScene" uid="uid://d0r5edxnx5jqx" path="res://objects/movement_abilities/variable_jump_ability.tscn" id="3_bnap0"]
[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://iiawtnwmeny3" path="res://sprites/right_eye.png" id="4_ccn81"]
[ext_resource type="PackedScene" uid="uid://cala7bpo1v4no" path="res://objects/movement_abilities/gravity_ability.tscn" id="4_qec3q"]
[ext_resource type="PackedScene" uid="uid://bty3jq8u0pxkf" path="res://objects/movement_abilities/one_way_platform_ability.tscn" id="5_dhjci"]
[ext_resource type="Texture2D" uid="uid://0l454rfplmqg" path="res://sprites/MrBrick_base-sheet.png" id="5_yysbb"] [ext_resource type="Texture2D" uid="uid://0l454rfplmqg" path="res://sprites/MrBrick_base-sheet.png" id="5_yysbb"]
[ext_resource type="PackedScene" uid="uid://bu3vuxlrvoo1t" path="res://objects/movement_abilities/spaceship_ability.tscn" id="6_721q0"]
[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="PackedScene" uid="uid://chjbi5mgtwhsh" path="res://objects/movement_abilities/wall_jump_ability.tscn" id="7_bnap0"]
[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="Texture2D" uid="uid://dhkwyv6ayb5qb" path="res://sprites/flying_ship.png" id="8_6lsog"]
[ext_resource type="PackedScene" uid="uid://dre1vit1m4d2n" path="res://objects/movement_abilities/grid_movement_ability.tscn" id="8_xuhvf"]
[ext_resource type="Script" uid="uid://dy78ak8eykw6e" path="res://scripts/components/FlipComponent.cs" id="9_yysbb"] [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"] [ext_resource type="Script" uid="uid://mnjg3p0aw1ow" path="res://scripts/components/CanPickUpComponent.cs" id="10_yysbb"]
[ext_resource type="Script" uid="uid://ccqb8kd5m0eh7" path="res://scripts/components/ScoreComponent.cs" id="11_o1ihh"]
[ext_resource type="Script" uid="uid://dgb8bqcri7nsj" path="res://scripts/components/HealthComponent.cs" id="12_ur2y5"] [ext_resource type="Script" uid="uid://dgb8bqcri7nsj" path="res://scripts/components/HealthComponent.cs" id="12_ur2y5"]
[ext_resource type="Script" uid="uid://byw1legrv1ep2" path="res://scripts/components/PlayerDeathComponent.cs" id="13_7til7"] [ext_resource type="Script" uid="uid://byw1legrv1ep2" path="res://scripts/components/PlayerDeathComponent.cs" id="13_7til7"]
[ext_resource type="Script" uid="uid://cecelixl41t3j" path="res://scripts/components/InvulnerabilityComponent.cs" id="15_xuhvf"] [ext_resource type="Script" uid="uid://cecelixl41t3j" path="res://scripts/components/InvulnerabilityComponent.cs" id="15_xuhvf"]
@@ -76,7 +65,7 @@ point_count = 2
[sub_resource type="CurveTexture" id="CurveTexture_xoue7"] [sub_resource type="CurveTexture" id="CurveTexture_xoue7"]
curve = SubResource("Curve_82d6e") curve = SubResource("Curve_82d6e")
[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_lgb3u"] [sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_oxudy"]
resource_local_to_scene = true resource_local_to_scene = true
lifetime_randomness = 1.0 lifetime_randomness = 1.0
particle_flag_disable_z = true particle_flag_disable_z = true
@@ -93,230 +82,202 @@ scale_curve = SubResource("CurveTexture_xoue7")
color = Color(0.764706, 0.443137, 0, 1) color = Color(0.764706, 0.443137, 0, 1)
color_ramp = SubResource("GradientTexture1D_lgb3u") color_ramp = SubResource("GradientTexture1D_lgb3u")
[node name="Brick Player" type="CharacterBody2D" node_paths=PackedStringArray("MovementAbilitiesContainer") groups=["player"]] [node name="Brick Player" type="CharacterBody2D" unique_id=634211282 node_paths=PackedStringArray("MovementAbilitiesContainer") groups=["player"]]
collision_layer = 4 collision_layer = 4
collision_mask = 107 collision_mask = 107
script = ExtResource("1_yysbb") script = ExtResource("1_yysbb")
MovementAbilitiesContainer = NodePath("Movements") MovementAbilitiesContainer = NodePath("Movements")
GroundMovementScene = ExtResource("2_oefns") DefaultPreset = ExtResource("2_7til7")
JumpMovementScene = ExtResource("3_bnap0")
GravityScene = ExtResource("4_qec3q")
OneWayPlatformScene = ExtResource("5_dhjci")
SpaceshipMovementScene = ExtResource("6_721q0")
WallJumpScene = ExtResource("7_bnap0")
GridMovementScene = ExtResource("8_xuhvf")
metadata/_custom_type_script = "uid://csel4s0e4g5uf" metadata/_custom_type_script = "uid://csel4s0e4g5uf"
[node name="Movements" type="Node" parent="."] [node name="Movements" type="Node" parent="." unique_id=1545001]
[node name="GroundMovementAbility" type="Node" parent="Movements"] [node name="Graphics" type="Node2D" parent="." unique_id=271317654]
script = ExtResource("4_7til7")
MaxSpeed = 376.0
Friction = 2500.0
metadata/_custom_type_script = "uid://bf4yclropol43"
[node name="GravityAbility" type="Node" parent="Movements"] [node name="Root" type="Node2D" parent="Graphics" unique_id=2012260442]
script = ExtResource("6_xuhvf")
metadata/_custom_type_script = "uid://chgw53qwt7rt8"
[node name="VariableJumpAbility" type="Node" parent="Movements"] [node name="Base" type="Sprite2D" parent="Graphics/Root" unique_id=1178176210]
script = ExtResource("7_bl1gx")
JumpCutMultiplier = 0.507
metadata/_custom_type_script = "uid://ccksp2e76s7sr"
[node name="OneWayPlatformAbility" type="Node" parent="Movements"]
script = ExtResource("7_uno3u")
metadata/_custom_type_script = "uid://ck6kmnbwhsttt"
[node name="Graphics" type="Node2D" parent="."]
[node name="Root" type="Node2D" parent="Graphics"]
[node name="Base" type="Sprite2D" parent="Graphics/Root"]
material = SubResource("ShaderMaterial_xoue7") material = SubResource("ShaderMaterial_xoue7")
texture = ExtResource("5_yysbb") texture = ExtResource("5_yysbb")
hframes = 5 hframes = 5
[node name="Left Eye" type="Sprite2D" parent="Graphics/Root"] [node name="Left Eye" type="Sprite2D" parent="Graphics/Root" unique_id=653633051]
position = Vector2(-7, -6) position = Vector2(-7, -6)
texture = ExtResource("3_2srrh") texture = ExtResource("3_2srrh")
hframes = 2 hframes = 2
[node name="Right Eye" type="Sprite2D" parent="Graphics/Root"] [node name="Right Eye" type="Sprite2D" parent="Graphics/Root" unique_id=721230787]
position = Vector2(6, -5) position = Vector2(6, -5)
texture = ExtResource("4_ccn81") texture = ExtResource("4_ccn81")
hframes = 2 hframes = 2
[node name="Ship" type="Sprite2D" parent="Graphics"] [node name="Ship" type="Sprite2D" parent="Graphics" unique_id=1074443059]
visible = false visible = false
position = Vector2(1, 7) position = Vector2(1, 7)
texture = ExtResource("8_6lsog") texture = ExtResource("8_6lsog")
[node name="CollisionShape2D" type="CollisionShape2D" parent="."] [node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=22428429]
visible = false visible = false
position = Vector2(0, 0.5) position = Vector2(0, 0.5)
shape = SubResource("RectangleShape2D_hdsg1") shape = SubResource("RectangleShape2D_hdsg1")
[node name="FlipPlayerComponent" type="Node2D" parent="." node_paths=PackedStringArray("LeftEye", "RightEye", "PlayerController")] [node name="FlipPlayerComponent" type="Node2D" parent="." unique_id=290535881 node_paths=PackedStringArray("LeftEye", "RightEye", "PlayerController")]
script = ExtResource("9_yysbb") script = ExtResource("9_yysbb")
LeftEye = NodePath("../Graphics/Root/Left Eye") LeftEye = NodePath("../Graphics/Root/Left Eye")
RightEye = NodePath("../Graphics/Root/Right Eye") RightEye = NodePath("../Graphics/Root/Right Eye")
PlayerController = NodePath("..") PlayerController = NodePath("..")
[node name="StompDamageArea" type="Area2D" parent="."] [node name="StompDamageArea" type="Area2D" parent="." unique_id=2071262827]
collision_layer = 0 collision_layer = 0
collision_mask = 8 collision_mask = 8
[node name="CollisionShape2D" type="CollisionShape2D" parent="StompDamageArea"] [node name="CollisionShape2D" type="CollisionShape2D" parent="StompDamageArea" unique_id=1230518642]
visible = false visible = false
position = Vector2(0, 1) position = Vector2(0, 1)
shape = SubResource("RectangleShape2D_vad0t") shape = SubResource("RectangleShape2D_vad0t")
[node name="CanPickUpComponent" type="Node" parent="."] [node name="CanPickUpComponent" type="Node" parent="." unique_id=1912343790]
script = ExtResource("10_yysbb") script = ExtResource("10_yysbb")
[node name="ScoreComponent" type="Node" parent="."] [node name="HealthComponent" type="Node2D" parent="." unique_id=278902432 node_paths=PackedStringArray("HurtSfx", "HealSfx")]
script = ExtResource("11_o1ihh")
[node name="HealthComponent" type="Node2D" parent="." node_paths=PackedStringArray("HurtSfx", "HealSfx")]
script = ExtResource("12_ur2y5") script = ExtResource("12_ur2y5")
HurtSfx = NodePath("../sfx_hurt") HurtSfx = NodePath("../sfx_hurt")
HealSfx = NodePath("../sfx_heal") HealSfx = NodePath("../sfx_heal")
[node name="PlayerDeathComponent" type="Node2D" parent="." node_paths=PackedStringArray("DeathSfx", "HealthComponent")] [node name="PlayerDeathComponent" type="Node2D" parent="." unique_id=1989490803 node_paths=PackedStringArray("DeathSfx", "HealthComponent")]
process_mode = 3 process_mode = 3
script = ExtResource("13_7til7") script = ExtResource("13_7til7")
DeathSfx = NodePath("../sfx_hurt") DeathSfx = NodePath("../sfx_hurt")
HealthComponent = NodePath("../HealthComponent") HealthComponent = NodePath("../HealthComponent")
[node name="InvulnerabilityComponent" type="Node" parent="." node_paths=PackedStringArray("FlashingComponent")] [node name="InvulnerabilityComponent" type="Node" parent="." unique_id=963173254 node_paths=PackedStringArray("FlashingComponent")]
script = ExtResource("15_xuhvf") script = ExtResource("15_xuhvf")
FlashingComponent = NodePath("../FlashingComponent Base") FlashingComponent = NodePath("../FlashingComponent Base")
[node name="FlashingComponent Base" type="Node" parent="." node_paths=PackedStringArray("Sprite", "HealthComponent")] [node name="FlashingComponent Base" type="Node" parent="." unique_id=533766453 node_paths=PackedStringArray("Sprite", "HealthComponent")]
script = ExtResource("16_uno3u") script = ExtResource("16_uno3u")
Sprite = NodePath("../Graphics/Root/Base") Sprite = NodePath("../Graphics/Root/Base")
FlashDuration = 1.0 FlashDuration = 1.0
HealthComponent = NodePath("../HealthComponent") HealthComponent = NodePath("../HealthComponent")
[node name="FlashingComponent LEye" type="Node" parent="." node_paths=PackedStringArray("Sprite", "HealthComponent")] [node name="FlashingComponent LEye" type="Node" parent="." unique_id=835038717 node_paths=PackedStringArray("Sprite", "HealthComponent")]
script = ExtResource("16_uno3u") script = ExtResource("16_uno3u")
Sprite = NodePath("../Graphics/Root/Left Eye") Sprite = NodePath("../Graphics/Root/Left Eye")
FlashDuration = 1.0 FlashDuration = 1.0
HealthComponent = NodePath("../HealthComponent") HealthComponent = NodePath("../HealthComponent")
[node name="FlashingComponent REye" type="Node" parent="." node_paths=PackedStringArray("Sprite", "HealthComponent")] [node name="FlashingComponent REye" type="Node" parent="." unique_id=601265429 node_paths=PackedStringArray("Sprite", "HealthComponent")]
script = ExtResource("16_uno3u") script = ExtResource("16_uno3u")
Sprite = NodePath("../Graphics/Root/Right Eye") Sprite = NodePath("../Graphics/Root/Right Eye")
FlashDuration = 1.0 FlashDuration = 1.0
HealthComponent = NodePath("../HealthComponent") HealthComponent = NodePath("../HealthComponent")
[node name="StompDamageComponent" type="Node" parent="." node_paths=PackedStringArray("Area", "Root")] [node name="StompDamageComponent" type="Node" parent="." unique_id=1027107459 node_paths=PackedStringArray("Area", "Root")]
script = ExtResource("17_bl1gx") script = ExtResource("17_bl1gx")
Damage = 4.0 Damage = 4.0
Area = NodePath("../StompDamageArea") Area = NodePath("../StompDamageArea")
Root = NodePath("..") Root = NodePath("..")
[node name="SkillUnlockerComponent" type="Node" parent="."] [node name="SkillUnlockerComponent" type="Node" parent="." unique_id=496647555]
script = ExtResource("25_yysbb") script = ExtResource("25_yysbb")
[node name="HitComponent" type="Node" parent="." node_paths=PackedStringArray("Sprite", "Health", "HitFx")] [node name="HitComponent" type="Node" parent="." unique_id=131916578 node_paths=PackedStringArray("Sprite", "Health", "HitFx")]
script = ExtResource("26_6n1ss") script = ExtResource("26_6n1ss")
Sprite = NodePath("../Graphics/Root/Base") Sprite = NodePath("../Graphics/Root/Base")
Health = NodePath("../HealthComponent") Health = NodePath("../HealthComponent")
HitFx = NodePath("../HitParticles") HitFx = NodePath("../HitParticles")
[node name="MagneticArea" type="Area2D" parent="."] [node name="MagneticArea" type="Area2D" parent="." unique_id=1477305842]
collision_layer = 0 collision_layer = 0
collision_mask = 2 collision_mask = 2
[node name="CollisionShape2D" type="CollisionShape2D" parent="MagneticArea"] [node name="CollisionShape2D" type="CollisionShape2D" parent="MagneticArea" unique_id=1552683633]
visible = false visible = false
shape = SubResource("CircleShape2D_ps31c") shape = SubResource("CircleShape2D_ps31c")
[node name="CanBeLaunchedComponent" type="Node" parent="."] [node name="CanBeLaunchedComponent" type="Node" parent="." unique_id=1998429478]
script = ExtResource("27_oefns") script = ExtResource("27_oefns")
[node name="TriggerLeverComponent" type="Node" parent="."] [node name="TriggerLeverComponent" type="Node" parent="." unique_id=366225548]
script = ExtResource("28_bnap0") script = ExtResource("28_bnap0")
[node name="sfx_jump" type="AudioStreamPlayer2D" parent="."] [node name="sfx_jump" type="AudioStreamPlayer2D" parent="." unique_id=218072388]
stream = ExtResource("18_pysae") stream = ExtResource("18_pysae")
bus = &"sfx" bus = &"sfx"
[node name="sfx_hurt" type="AudioStreamPlayer2D" parent="."] [node name="sfx_hurt" type="AudioStreamPlayer2D" parent="." unique_id=1815263121]
stream = ExtResource("19_7anly") stream = ExtResource("19_7anly")
bus = &"sfx" bus = &"sfx"
[node name="sfx_heal" type="AudioStreamPlayer2D" parent="."] [node name="sfx_heal" type="AudioStreamPlayer2D" parent="." unique_id=1792916109]
stream = ExtResource("20_bptj5") stream = ExtResource("20_bptj5")
bus = &"sfx" bus = &"sfx"
[node name="sfx_shoot" type="AudioStreamPlayer2D" parent="."] [node name="sfx_shoot" type="AudioStreamPlayer2D" parent="." unique_id=1414786458]
stream = ExtResource("32_x2b7c") stream = ExtResource("32_x2b7c")
bus = &"sfx" bus = &"sfx"
[node name="ChargingBarLayer" parent="." instance=ExtResource("28_3f5nm")] [node name="ChargingBarLayer" parent="." unique_id=664675361 instance=ExtResource("28_3f5nm")]
offset_left = -17.0 offset_left = -17.0
offset_top = -30.0 offset_top = -30.0
offset_right = 23.0 offset_right = 23.0
offset_bottom = -20.0 offset_bottom = -20.0
[node name="HitParticles" parent="." instance=ExtResource("28_jh5m0")] [node name="HitParticles" parent="." unique_id=1322585720 instance=ExtResource("28_jh5m0")]
process_material = SubResource("ParticleProcessMaterial_lgb3u") process_material = SubResource("ParticleProcessMaterial_oxudy")
[node name="ShipShooter" type="Node" parent="." node_paths=PackedStringArray("BulletSpawn", "ShootSfx")] [node name="ShipShooter" type="Node" parent="." unique_id=1147013800 node_paths=PackedStringArray("BulletSpawn", "ShootSfx")]
script = ExtResource("30_usc1p") script = ExtResource("30_usc1p")
BulletScene = ExtResource("36_oxudy") BulletScene = ExtResource("36_oxudy")
BulletSpawn = NodePath("../Ship shoot spawn") BulletSpawn = NodePath("../Ship shoot spawn")
ShootSfx = NodePath("../sfx_shoot") ShootSfx = NodePath("../sfx_shoot")
[node name="Ship shoot spawn" type="Marker2D" parent="."] [node name="Ship shoot spawn" type="Marker2D" parent="." unique_id=1653596970]
position = Vector2(17, 5) position = Vector2(17, 5)
gizmo_extents = 1.0 gizmo_extents = 1.0
[node name="VisibleOnScreenNotifier2D" type="VisibleOnScreenNotifier2D" parent="."] [node name="VisibleOnScreenNotifier2D" type="VisibleOnScreenNotifier2D" parent="." unique_id=65174812]
position = Vector2(0, 3) position = Vector2(0, 3)
scale = Vector2(0.8, 1.9) scale = Vector2(0.8, 1.9)
[node name="PlayerInputHandler" type="Node" parent="."] [node name="PlayerInputHandler" type="Node" parent="." unique_id=1581385683]
script = ExtResource("42_e5pae") script = ExtResource("42_e5pae")
metadata/_custom_type_script = "uid://dssa2taiwktis" metadata/_custom_type_script = "uid://dssa2taiwktis"
[node name="SpriteTilterComponent" type="Node" parent="." node_paths=PackedStringArray("RotationTarget")] [node name="SpriteTilterComponent" type="Node" parent="." unique_id=1433925210 node_paths=PackedStringArray("RotationTarget")]
script = ExtResource("43_xuhvf") script = ExtResource("43_xuhvf")
RotationTarget = NodePath("../Graphics/Root/Base") RotationTarget = NodePath("../Graphics/Root/Base")
metadata/_custom_type_script = "uid://ceoxet1nqws8w" metadata/_custom_type_script = "uid://ceoxet1nqws8w"
[node name="PlayerSfxComponent" type="Node" parent="." node_paths=PackedStringArray("JumpSfx")] [node name="PlayerSfxComponent" type="Node" parent="." unique_id=1375936690 node_paths=PackedStringArray("JumpSfx")]
script = ExtResource("49_qec3q") script = ExtResource("49_qec3q")
JumpSfx = NodePath("../sfx_jump") JumpSfx = NodePath("../sfx_jump")
metadata/_custom_type_script = "uid://b1h8r5irryxcx" metadata/_custom_type_script = "uid://b1h8r5irryxcx"
[node name="PlayerGraphicsComponent" type="Node" parent="." node_paths=PackedStringArray("DefaultSprite", "SpaceshipSprite")] [node name="PlayerGraphicsComponent" type="Node" parent="." unique_id=443649347 node_paths=PackedStringArray("DefaultSprite", "SpaceshipSprite")]
script = ExtResource("50_dhjci") script = ExtResource("50_dhjci")
DefaultSprite = NodePath("../Graphics/Root") DefaultSprite = NodePath("../Graphics/Root")
SpaceshipSprite = NodePath("../Graphics/Ship") SpaceshipSprite = NodePath("../Graphics/Ship")
metadata/_custom_type_script = "uid://b2aanqykvdnev" metadata/_custom_type_script = "uid://b2aanqykvdnev"
[node name="ProgressiveDamageComponent" type="Node" parent="." node_paths=PackedStringArray("HealthComponent", "Sprite")] [node name="ProgressiveDamageComponent" type="Node" parent="." unique_id=1763641730 node_paths=PackedStringArray("HealthComponent", "Sprite")]
script = ExtResource("38_dhjci") script = ExtResource("38_dhjci")
HealthComponent = NodePath("../HealthComponent") HealthComponent = NodePath("../HealthComponent")
Sprite = NodePath("../Graphics/Root/Base") Sprite = NodePath("../Graphics/Root/Base")
metadata/_custom_type_script = "uid://dupnaark1f7gm" metadata/_custom_type_script = "uid://dupnaark1f7gm"
[node name="PacXonGridInteractor" type="Node" parent="."] [node name="PacXonGridInteractor" type="Node" parent="." unique_id=1036082136]
script = ExtResource("42_xuhvf") script = ExtResource("42_xuhvf")
metadata/_custom_type_script = "uid://c00siqtssccr6" metadata/_custom_type_script = "uid://c00siqtssccr6"
[node name="PacXonTrailComponent" type="Line2D" parent="."] [node name="PacXonTrailComponent" type="Line2D" parent="." unique_id=100969687]
script = ExtResource("44_uno3u") script = ExtResource("44_uno3u")
metadata/_custom_type_script = "uid://cmk4m7mplqnrm" metadata/_custom_type_script = "uid://cmk4m7mplqnrm"
[node name="Feet" type="Marker2D" parent="."] [node name="Feet" type="Marker2D" parent="." unique_id=1308840991]
position = Vector2(0, 16) position = Vector2(0, 16)
[node name="FootstepGfx" type="Node2D" parent="." node_paths=PackedStringArray("_controller", "_marker")] [node name="FootstepGfx" type="Node2D" parent="." unique_id=362059837 node_paths=PackedStringArray("_controller", "_marker")]
script = ExtResource("46_6n1ss") script = ExtResource("46_6n1ss")
_particles = ExtResource("45_bl1gx") _particles = ExtResource("45_bl1gx")
_controller = NodePath("..") _controller = NodePath("..")
@@ -327,7 +288,7 @@ _minMoveSpeed = 4.0
_randomOffsetRange = 0.3 _randomOffsetRange = 0.3
metadata/_custom_type_script = "uid://d3ksrjt1ek4gi" metadata/_custom_type_script = "uid://d3ksrjt1ek4gi"
[node name="JumpGfxComponent" type="Node2D" parent="." node_paths=PackedStringArray("Controller")] [node name="JumpGfxComponent" type="Node2D" parent="." unique_id=1468920790 node_paths=PackedStringArray("Controller")]
script = ExtResource("47_oefns") script = ExtResource("47_oefns")
ParticleScene = ExtResource("48_bnap0") ParticleScene = ExtResource("48_bnap0")
Controller = NodePath("..") Controller = NodePath("..")

View File

@@ -15,7 +15,7 @@ radius = 4.0
[node name="Bullet" type="Area2D"] [node name="Bullet" type="Area2D"]
collision_layer = 64 collision_layer = 64
collision_mask = 21 collision_mask = 85
[node name="CollisionShape2D" type="CollisionShape2D" parent="."] [node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("CircleShape2D_txsw8") shape = SubResource("CircleShape2D_txsw8")
@@ -47,7 +47,7 @@ VisibilityNotifier = NodePath("../VisibleOnScreenNotifier2D")
[node name="LifetimeComponent" type="Node" parent="."] [node name="LifetimeComponent" type="Node" parent="."]
script = ExtResource("4_aniyw") script = ExtResource("4_aniyw")
LifeTime = 10.0 LifeTime = 3.0
[node name="LaunchComponent" type="Node2D" parent="."] [node name="LaunchComponent" type="Node2D" parent="."]
script = ExtResource("5_3ks47") script = ExtResource("5_3ks47")

View File

@@ -0,0 +1,48 @@
[gd_scene load_steps=7 format=3 uid="uid://qrbiu1qo4tt5"]
[ext_resource type="Texture2D" uid="uid://djifxc5x0dyrw" path="res://sprites/ppc_tileset.png" id="1_0efvn"]
[ext_resource type="Script" uid="uid://bnaxy8cw3wrko" path="res://scripts/components/PeriodicShootingComponent.cs" id="2_4ycs1"]
[ext_resource type="PackedScene" uid="uid://chetx6gmnwbxi" path="res://objects/entities/cannon_bullet.tscn" id="3_ab2uh"]
[ext_resource type="Script" uid="uid://b3j23e7b7x8ro" path="res://scripts/components/RecoilComponent.cs" id="4_tynaf"]
[ext_resource type="Script" uid="uid://c707c53k7c5ae" path="res://scripts/components/SquashAndStretchComponent.cs" id="5_0xigu"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_j5sus"]
size = Vector2(16, 16)
[node name="Cannon" type="StaticBody2D"]
collision_layer = 0
collision_mask = 0
[node name="Sprite2D" type="Sprite2D" parent="."]
rotation = 3.14159
texture = ExtResource("1_0efvn")
hframes = 12
vframes = 12
frame = 42
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("RectangleShape2D_j5sus")
[node name="PeriodicShootingComponent" type="Node" parent="." node_paths=PackedStringArray("BulletSpawnPointRight")]
script = ExtResource("2_4ycs1")
BulletScene = ExtResource("3_ab2uh")
ShootInterval = 3.0
ShootDirection = Vector2(0, 1)
BulletSpawnPointRight = NodePath("../Bullet spawn")
ShootingIntervalVariation = 0.61
metadata/_custom_type_script = "uid://bnaxy8cw3wrko"
[node name="Bullet spawn" type="Marker2D" parent="."]
position = Vector2(0, 12)
[node name="RecoilComponent" type="Node" parent="." node_paths=PackedStringArray("RecoilTarget")]
script = ExtResource("4_tynaf")
RecoilTarget = NodePath("../Sprite2D")
RecoilDistance = 4.0
RecoilDuration = 0.12
metadata/_custom_type_script = "uid://b3j23e7b7x8ro"
[node name="SquashAndStretchComponent" type="Node" parent="." node_paths=PackedStringArray("TargetNode")]
script = ExtResource("5_0xigu")
TargetNode = NodePath("../Sprite2D")
metadata/_custom_type_script = "uid://c707c53k7c5ae"

View File

@@ -0,0 +1,48 @@
[gd_scene load_steps=7 format=3 uid="uid://cm0rd70wafif1"]
[ext_resource type="Texture2D" uid="uid://djifxc5x0dyrw" path="res://sprites/ppc_tileset.png" id="1_hnnrt"]
[ext_resource type="Script" uid="uid://bnaxy8cw3wrko" path="res://scripts/components/PeriodicShootingComponent.cs" id="2_cho7n"]
[ext_resource type="PackedScene" uid="uid://chetx6gmnwbxi" path="res://objects/entities/cannon_bullet.tscn" id="3_kgmtj"]
[ext_resource type="Script" uid="uid://b3j23e7b7x8ro" path="res://scripts/components/RecoilComponent.cs" id="4_nfk87"]
[ext_resource type="Script" uid="uid://c707c53k7c5ae" path="res://scripts/components/SquashAndStretchComponent.cs" id="5_43fqe"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_j5sus"]
size = Vector2(16, 16)
[node name="Cannon" type="StaticBody2D"]
collision_layer = 0
collision_mask = 0
[node name="Sprite2D" type="Sprite2D" parent="."]
rotation = -1.5708
texture = ExtResource("1_hnnrt")
hframes = 12
vframes = 12
frame = 42
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("RectangleShape2D_j5sus")
[node name="PeriodicShootingComponent" type="Node" parent="." node_paths=PackedStringArray("BulletSpawnPointRight")]
script = ExtResource("2_cho7n")
BulletScene = ExtResource("3_kgmtj")
ShootInterval = 3.0
ShootDirection = Vector2(-1, 0)
BulletSpawnPointRight = NodePath("../Bullet spawn")
ShootingIntervalVariation = 0.61
metadata/_custom_type_script = "uid://bnaxy8cw3wrko"
[node name="Bullet spawn" type="Marker2D" parent="."]
position = Vector2(-12, 0)
[node name="RecoilComponent" type="Node" parent="." node_paths=PackedStringArray("RecoilTarget")]
script = ExtResource("4_nfk87")
RecoilTarget = NodePath("../Sprite2D")
RecoilDistance = 4.0
RecoilDuration = 0.12
metadata/_custom_type_script = "uid://b3j23e7b7x8ro"
[node name="SquashAndStretchComponent" type="Node" parent="." node_paths=PackedStringArray("TargetNode")]
script = ExtResource("5_43fqe")
TargetNode = NodePath("../Sprite2D")
metadata/_custom_type_script = "uid://c707c53k7c5ae"

View File

@@ -0,0 +1,47 @@
[gd_scene load_steps=7 format=3 uid="uid://dr6srln4mckwn"]
[ext_resource type="Texture2D" uid="uid://djifxc5x0dyrw" path="res://sprites/ppc_tileset.png" id="1_r5bjs"]
[ext_resource type="Script" uid="uid://bnaxy8cw3wrko" path="res://scripts/components/PeriodicShootingComponent.cs" id="2_gd3be"]
[ext_resource type="PackedScene" uid="uid://chetx6gmnwbxi" path="res://objects/entities/cannon_bullet.tscn" id="3_1gufj"]
[ext_resource type="Script" uid="uid://b3j23e7b7x8ro" path="res://scripts/components/RecoilComponent.cs" id="4_hwc6x"]
[ext_resource type="Script" uid="uid://c707c53k7c5ae" path="res://scripts/components/SquashAndStretchComponent.cs" id="5_o674s"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_j5sus"]
size = Vector2(16, 16)
[node name="Cannon" type="StaticBody2D"]
collision_layer = 0
collision_mask = 0
[node name="Sprite2D" type="Sprite2D" parent="."]
rotation = 1.5708
texture = ExtResource("1_r5bjs")
hframes = 12
vframes = 12
frame = 42
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("RectangleShape2D_j5sus")
[node name="PeriodicShootingComponent" type="Node" parent="." node_paths=PackedStringArray("BulletSpawnPointRight")]
script = ExtResource("2_gd3be")
BulletScene = ExtResource("3_1gufj")
ShootInterval = 3.0
BulletSpawnPointRight = NodePath("../Bullet spawn")
ShootingIntervalVariation = 0.61
metadata/_custom_type_script = "uid://bnaxy8cw3wrko"
[node name="Bullet spawn" type="Marker2D" parent="."]
position = Vector2(12, 0)
[node name="RecoilComponent" type="Node" parent="." node_paths=PackedStringArray("RecoilTarget")]
script = ExtResource("4_hwc6x")
RecoilTarget = NodePath("../Sprite2D")
RecoilDistance = 4.0
RecoilDuration = 0.12
metadata/_custom_type_script = "uid://b3j23e7b7x8ro"
[node name="SquashAndStretchComponent" type="Node" parent="." node_paths=PackedStringArray("TargetNode")]
script = ExtResource("5_o674s")
TargetNode = NodePath("../Sprite2D")
metadata/_custom_type_script = "uid://c707c53k7c5ae"

Some files were not shown because too many files have changed in this diff Show More