Compare commits

5 Commits

345 changed files with 4071 additions and 9467 deletions

11
.gitignore vendored
View File

@@ -1,13 +1,4 @@
# Godot 4+ specific ignores
.godot/
/android/
builds/
.worktrees/
# IDE / local tooling
.idea/
.superpowers/
# .NET build output
bin/
obj/
builds/

View File

@@ -9,8 +9,6 @@ namespace Mr.BrickAdventures.Autoloads;
/// </summary>
public partial class AchievementManager : Node
{
public static AchievementManager Instance { get; private set; }
[Export] private string AchievementsFolderPath = "res://achievements/";
[Export] private PackedScene AchievementPopupScene { get; set; }
@@ -18,15 +16,9 @@ public partial class AchievementManager : Node
public override void _Ready()
{
Instance = this;
LoadAchievementsFromFolder();
}
public override void _ExitTree()
{
if (Instance == this) Instance = null;
}
private void LoadAchievementsFromFolder()
{
using var dir = DirAccess.Open(AchievementsFolderPath);
@@ -77,7 +69,6 @@ public partial class AchievementManager : Node
// 1. Mark as unlocked
unlockedIds.Add(achievementId);
GD.Print($"Achievement Unlocked: {achievement.DisplayName}");
EventBus.EmitAchievementUnlocked(achievementId);
// 2. Show the UI popup
if (AchievementPopupScene != null)

View File

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

View File

@@ -1,120 +1,133 @@
using Godot;
using Mr.BrickAdventures;
using Mr.BrickAdventures.scripts.components;
namespace Mr.BrickAdventures.Autoloads;
public partial class ConsoleManager : Node
{
private GameStateStore Store => GameStateStore.Instance;
private GameManager GameManager => GameManager.Instance;
private AchievementManager AchievementManager => AchievementManager.Instance;
private GameManager _gameManager;
private SkillManager _skillManager;
private SkillUnlockerComponent _skillUnlockerComponent;
private AchievementManager _achievementManager;
public override void _Ready()
{
_skillManager = SkillManager.Instance;
_gameManager = GetNode<GameManager>(Constants.GameManagerPath);
_achievementManager = GetNode<AchievementManager>(Constants.AchievementManagerPath);
_skillManager = GetNode<SkillManager>(Constants.SkillManagerPath);
}
private void AddCoinsCommand(int amount)
{
if (Store == null) return;
Store.Player.Coins += amount;
EventBus.EmitCoinsChanged(Store.GetTotalCoins());
_gameManager.AddCoins(amount);
}
private void SetCoinsCommand(int amount)
{
if (Store == null) return;
Store.Player.Coins = Mathf.Max(0, amount);
EventBus.EmitCoinsChanged(Store.GetTotalCoins());
_gameManager.SetCoins(amount);
}
private void SetLivesCommand(int amount)
{
if (Store == null) return;
Store.Player.Lives = amount;
EventBus.EmitLivesChanged(amount);
_gameManager.SetLives(amount);
}
private void AddLivesCommand(int amount)
{
Store?.AddLives(amount);
_gameManager.AddLives(amount);
}
private void SetHealthCommand(float amount)
{
var playerHealthComponent = GameManager?.Player?.GetNode<HealthComponent>("HealthComponent");
var playerHealthComponent = _gameManager.Player.GetNode<HealthComponent>("HealthComponent");
if (playerHealthComponent != null)
{
playerHealthComponent.Health = amount;
}
}
private void ResetSessionCommand()
{
Store?.ResetSession();
_gameManager.ResetCurrentSessionState();
}
private void UnlockSkillCommand(string skillName)
{
if (!EnsureSkillManagement()) return;
if (!GetSkillManagement()) return;
var skill = _skillManager.GetSkillByName(skillName);
if (skill == null) return;
if (skill == null)
{
return;
}
Store?.UnlockSkillPermanently(skill);
_gameManager.UnlockSkill(skill);
_skillManager.ActivateSkill(skill);
_skillUnlockerComponent.EmitSignal(SkillUnlockerComponent.SignalName.SkillUnlocked, skill);
}
private bool GetSkillManagement()
{
var player = _gameManager.Player;
if (player == null || !IsInstanceValid(player))
{
return false;
}
_skillUnlockerComponent ??= player.GetNode<SkillUnlockerComponent>("SkillUnlockerComponent");
if (_skillManager != null && _skillUnlockerComponent != null) return true;
return false;
}
private void UnlockAllSkillsCommand()
{
if (!EnsureSkillManagement()) return;
if (!GetSkillManagement()) return;
_skillUnlockerComponent.UnlockAllSkills();
}
private void RemoveSkillCommand(string skillName)
{
if (!EnsureSkillManagement()) return;
if (!GetSkillManagement()) return;
var skill = _skillManager.GetSkillByName(skillName);
if (skill == null) return;
if (skill == null)
{
return;
}
Store?.RemoveUnlockedSkill(skill.Name);
_gameManager.RemoveSkill(skill.Name);
_skillManager.DeactivateSkill(skill);
}
private void RemoveAllSkillsCommand()
{
if (!EnsureSkillManagement()) return;
if (!GetSkillManagement()) return;
foreach (var skill in _skillManager.AvailableSkills)
{
Store?.RemoveUnlockedSkill(skill.Name);
_gameManager.RemoveSkill(skill.Name);
_skillManager.DeactivateSkill(skill);
}
}
private void GoToNextLevelCommand()
{
GameManager?.OnLevelComplete();
_gameManager.OnLevelComplete();
}
private void UnlockAchievementCommand(string achievementId)
{
AchievementManager?.UnlockAchievement(achievementId);
_achievementManager.UnlockAchievement(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

@@ -82,7 +82,6 @@ public partial class EventBus : Node
[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);
@@ -93,9 +92,6 @@ public partial class EventBus : Node
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
@@ -140,50 +136,11 @@ public partial class EventBus : Node
#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);

View File

@@ -1,7 +1,5 @@
using System;
using System.Globalization;
using Godot;
using Mr.BrickAdventures.scripts.Resources;
using Mr.BrickAdventures.scripts.UI;
namespace Mr.BrickAdventures.Autoloads;
@@ -9,34 +7,22 @@ namespace Mr.BrickAdventures.Autoloads;
public partial class FloatingTextManager : Node
{
[Export] public PackedScene FloatingTextScene { get; set; }
[ExportGroup("Colors")]
[Export] public Color DamageColor { get; set; } = new Color("#b21030"); // Red
[Export] public Color HealColor { get; set; } = new Color("#71f341"); // Green
[Export] public Color CoinColor { get; set; } = new Color("#ebd320"); // Gold
[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)
{
var text = Mathf.Round(amount * 100f).ToString(CultureInfo.InvariantCulture);
CreateFloatingText(text, position, DamageColor);
}
public void ShowHeal(float amount, Vector2 position)
{
var text = $"+{Mathf.Round(amount * 100f).ToString(CultureInfo.InvariantCulture)}";
var text = $"+{Mathf.Round(amount)}";
CreateFloatingText(text, position, HealColor);
}
@@ -45,12 +31,12 @@ public partial class FloatingTextManager : Node
var text = $"+{amount}";
CreateFloatingText(text, position, CoinColor);
}
public void ShowMessage(string message, Vector2 position)
{
CreateFloatingText(message, position, MessageColor);
}
private void CreateFloatingText(string text, Vector2 position, Color color)
{
if (FloatingTextScene == null)

View File

@@ -1,166 +1,194 @@
using System.Collections.Generic;
using Godot;
using Mr.BrickAdventures.Progression;
using Godot.Collections;
using Mr.BrickAdventures.scripts.components;
using Mr.BrickAdventures.scripts.Resources;
using Mr.BrickAdventures.scripts.State;
namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Game orchestrator: scene transitions and game flow.
/// Game orchestrator - handles scene management and game flow.
/// State is delegated to GameStateStore for better separation of concerns.
/// </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
{
[Export] public Array<PackedScene> LevelScenes { get; set; } = [];
public PlayerController Player
{
get => GetPlayer();
private set => _player = value;
}
private List<Node> _sceneNodes = [];
private PlayerController _player;
private SpeedRunManager SpeedRunManager => SpeedRunManager.Instance;
private ProgressionRoot Progression => ProgressionRoot.Instance;
private SpeedRunManager _speedRunManager;
public static GameManager Instance { get; private set; }
/// <summary>
/// Lazy accessor for GameStateStore - avoids initialization order issues.
/// </summary>
private GameStateStore Store => GameStateStore.Instance;
public override void _Ready()
public override void _EnterTree()
{
Instance = this;
EventBus.Instance.PlayerSpawned += OnPlayerSpawned;
GetTree().NodeAdded += OnNodeAdded;
GetTree().NodeRemoved += OnNodeRemoved;
}
public override void _ExitTree()
{
if (Instance == this) Instance = null;
if (EventBus.Instance != null)
EventBus.Instance.PlayerSpawned -= OnPlayerSpawned;
GetTree().NodeAdded -= OnNodeAdded;
GetTree().NodeRemoved -= OnNodeRemoved;
_sceneNodes.Clear();
}
private void OnPlayerSpawned(PlayerController player)
public override void _Ready()
{
_player = player;
player.TreeExiting += () => { if (_player == player) _player = null; };
_speedRunManager = GetNode<SpeedRunManager>(Constants.SpeedRunManagerPath);
}
private void OnNodeAdded(Node node)
{
_sceneNodes.Add(node);
}
private void OnNodeRemoved(Node node)
{
_sceneNodes.Remove(node);
if (node == _player)
{
_player = null;
}
}
#region Coin Operations
public void AddCoins(int amount)
{
if (Store != null)
{
Store.Player.Coins += amount;
EventBus.EmitCoinsChanged(Store.GetTotalCoins());
}
}
public void SetCoins(int amount)
{
if (Store != null)
{
Store.Player.Coins = Mathf.Max(0, amount);
EventBus.EmitCoinsChanged(Store.GetTotalCoins());
}
}
public int GetCoins() => Store?.GetTotalCoins() ?? 0;
public void RemoveCoins(int amount) => Store?.RemoveCoins(amount);
#endregion
#region Lives Operations
public void AddLives(int amount) => Store?.AddLives(amount);
public void RemoveLives(int amount) => Store?.RemoveLife();
public void SetLives(int amount)
{
if (Store != null)
{
Store.Player.Lives = amount;
EventBus.EmitLivesChanged(amount);
}
}
public int GetLives() => Store?.Player.Lives ?? 0;
#endregion
#region Skill Operations
public bool IsSkillUnlocked(SkillData skill) => Store?.IsSkillUnlocked(skill) ?? false;
public void UnlockSkill(SkillData skill)
{
if (Store != null && !Store.IsSkillUnlocked(skill))
{
Store.Player.UnlockedSkills.Add(skill);
}
}
public void RemoveSkill(string skillName)
{
if (Store == null) return;
var skills = Store.Player.UnlockedSkills;
for (int i = 0; i < skills.Count; i++)
{
if (skills[i].Name == skillName)
{
skills.RemoveAt(i);
break;
}
}
}
public void UnlockSkills(Array<SkillData> skills)
{
foreach (var s in skills)
UnlockSkill(s);
}
public Array<SkillData> GetUnlockedSkills()
{
if (Store == null) return new Array<SkillData>();
var result = new Array<SkillData>();
foreach (var s in Store.Player.UnlockedSkills)
result.Add(s);
foreach (var s in Store.Session.SkillsUnlocked)
if (!result.Contains(s)) result.Add(s);
return result;
}
#endregion
#region Level Operations
public void UnlockLevel(int levelIndex) => Store?.UnlockLevel(levelIndex);
public void MarkLevelComplete(int levelIndex) => Store?.MarkLevelComplete(levelIndex);
public void TryToGoToNextLevel()
{
if (Store == null) return;
var next = Store.Session.CurrentLevel + 1;
if (next < LevelScenes.Count && Store.IsLevelUnlocked(next))
{
Store.Session.CurrentLevel = next;
GetTree().ChangeSceneToPacked(LevelScenes[next]);
EventBus.EmitLevelStarted(next, GetTree().CurrentScene);
}
}
#endregion
#region State Reset
public void ResetPlayerState() => Store?.ResetAll();
public void ResetCurrentSessionState() => Store?.ResetSession();
#endregion
#region Game Flow
public void StartNewGame()
{
if (Progression == null) return;
Progression.StartPlaythrough(ProgressionRoot.SoleSlot);
SpeedRunManager?.StartTimer();
EventBus.EmitGameStarted();
GoTo(Progression.Campaign.First);
}
public void ContinueGame()
{
if (Progression == null) return;
if (!Progression.LoadPlaythrough(ProgressionRoot.SoleSlot))
{
var why = Progression.PeekPlaythrough(ProgressionRoot.SoleSlot);
if (why == LoadOutcome.Corrupt)
{
// Deliberately not overwritten: the file is left where it is so it can be recovered.
GD.PushError("Saved progress could not be read. It has been left untouched.");
return;
}
StartNewGame();
return;
}
SpeedRunManager?.StartTimer();
EventBus.EmitGameContinued();
GoTo(Progression.CurrentLevel ?? Progression.Campaign.First);
}
public void RestartGame()
{
if (Progression == null) return;
Progression.StartPlaythrough(ProgressionRoot.SoleSlot);
SpeedRunManager?.StartTimer();
GoTo(Progression.Campaign.First);
Store?.ResetAll();
GetTree().ChangeSceneToPacked(LevelScenes[0]);
GetNode<SaveSystem>(Constants.SaveSystemPath).SaveGame();
}
public void OnLevelComplete()
{
if (Progression?.CurrentLevel is not { } finished) return;
var seconds = SpeedRunManager?.GetCurrentLevelTime() ?? 0.0;
LevelTime? time = seconds > 0 ? LevelTime.Of(seconds) : null;
EventBus.EmitLevelCompleted(IndexOf(finished), GetTree().CurrentScene, seconds);
// Banks the attempt's coins and skills, marks the level cleared, and advances campaign
// position if this was a First Clear.
Progression.FinishLevel(time);
if (Progression.CurrentLevel is { } next && !next.Equals(finished))
GoTo(next);
}
/// <summary>
/// Loads a Level's scene, then announces the entry once the new scene is actually current.
/// </summary>
/// <remarks>
/// The announcement is deliberately after a frame. <c>ChangeSceneToPacked</c> is deferred, so at
/// the moment it returns <c>CurrentScene</c> is still the outgoing scene — which is why the old
/// code handed <c>GhostEventHandler</c> the scene being torn down and ghosts were spawned into it.
/// </remarks>
private async void GoTo(LevelId level)
{
if (!Progression.CanEnter(level))
{
GD.PushError($"Level '{level}' is not reachable in this playthrough.");
return;
}
var scene = Progression.Catalogue.SceneFor(level);
if (scene == null)
{
GD.PushError($"Level '{level}' has no loadable scene.");
return;
}
GetTree().ChangeSceneToPacked(scene);
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
Progression.EnterLevel(level);
}
/// <summary>
/// Position in the catalogue, for the signals that are still index-based because GhostManager
/// names its files <c>ghost_level_{index}.json</c>.
/// </summary>
private int IndexOf(LevelId level)
{
var levels = Progression?.Catalogue?.Levels;
if (levels == null) return -1;
for (var i = 0; i < levels.Count; i++)
if (levels[i]?.Id == level.Value)
return i;
return -1;
}
public void QuitGame() => GetTree().Quit();
public void PauseGame()
{
@@ -174,7 +202,53 @@ public partial class GameManager : Node
EventBus.EmitGameResumed();
}
public void QuitGame() => GetTree().Quit();
public void StartNewGame()
{
Store?.ResetAll();
_speedRunManager?.StartTimer();
GetTree().ChangeSceneToPacked(LevelScenes[0]);
GetNode<SaveSystem>(Constants.SaveSystemPath).SaveGame();
EventBus.EmitGameStarted();
}
public void ContinueGame()
{
var save = GetNode<SaveSystem>(Constants.SaveSystemPath);
if (!save.LoadGame())
{
GD.PrintErr("Failed to load game. Starting a new game instead.");
StartNewGame();
return;
}
var idx = Store?.Session.CurrentLevel ?? 0;
if (idx < LevelScenes.Count)
{
GetTree().ChangeSceneToPacked(LevelScenes[idx]);
EventBus.EmitGameContinued();
}
else
{
GD.PrintErr("No levels unlocked to continue.");
}
}
public void OnLevelComplete()
{
if (Store == null) return;
var levelIndex = Store.Session.CurrentLevel;
Store.MarkLevelComplete(levelIndex);
Store.CommitSessionCoins();
Store.CommitSessionSkills();
var completionTime = _speedRunManager?.GetCurrentLevelTime() ?? 0.0;
EventBus.EmitLevelCompleted(levelIndex, GetTree().CurrentScene, completionTime);
Store.ResetSession();
TryToGoToNextLevel();
GetNode<SaveSystem>(Constants.SaveSystemPath).SaveGame();
}
#endregion
@@ -183,9 +257,20 @@ public partial class GameManager : Node
public PlayerController GetPlayer()
{
if (_player != null && IsInstanceValid(_player)) return _player;
_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;
}
#endregion
}
}

View File

@@ -1,5 +1,4 @@
using Godot;
using System.Collections.Generic;
using Mr.BrickAdventures.scripts.Resources;
using Mr.BrickAdventures.scripts.State;
@@ -143,7 +142,7 @@ public partial class GameStateStore : Node
}
/// <summary>
/// Unlocks a skill in the session (lost on death, committed on level complete).
/// Unlocks a skill in the session.
/// </summary>
public void UnlockSkillInSession(SkillData skill)
{
@@ -151,30 +150,6 @@ public partial class GameStateStore : Node
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>
@@ -188,39 +163,6 @@ public partial class GameStateStore : Node
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

View File

@@ -1,20 +1,14 @@
using System.Collections.Generic;
using System.Text.Json;
using Godot;
using Godot.Collections;
using Mr.BrickAdventures.scripts;
using Mr.BrickAdventures.scripts.State;
namespace Mr.BrickAdventures.Autoloads;
public partial class GhostManager : Node
{
public static GhostManager Instance { get; private 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 IsPlaybackEnabled { get; private set; } = true;
@@ -25,14 +19,14 @@ public partial class GhostManager : Node
public void StartRecording(int levelIndex)
{
if (!IsPlaybackEnabled) return;
_currentLevelIndex = levelIndex;
_currentRecording.Clear();
_startTime = Time.GetTicksMsec() / 1000.0;
IsRecording = true;
GD.Print("Ghost recording started.");
}
public void StopRecording(bool levelCompleted, double finalTime)
{
if (!IsRecording) return;
@@ -49,22 +43,23 @@ public partial class GhostManager : Node
}
_currentRecording.Clear();
}
public void RecordFrame(Vector2 position)
{
if (!IsRecording) return;
_currentRecording.Add(new GhostFrame
var frame = new GhostFrame
{
Timestamp = (Time.GetTicksMsec() / 1000.0) - _startTime,
Position = position
});
};
_currentRecording.Add(frame);
}
public void SpawnGhostPlayer(int levelIndex, Node parent)
{
if (!IsPlaybackEnabled || GhostPlayerScene == null) return;
var ghostData = LoadGhostData(levelIndex);
if (ghostData.Count > 0)
{
@@ -74,63 +69,44 @@ public partial class GhostManager : Node
GD.Print($"Ghost player spawned for level {levelIndex}.");
}
}
private void SaveGhostData(int levelIndex, double time)
{
var path = $"user://ghost_level_{levelIndex}.json";
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 });
try
var path = $"user://ghost_level_{levelIndex}.dat";
using var file = FileAccess.Open(path, FileAccess.ModeFlags.Write);
var dataToSave = new Godot.Collections.Dictionary
{
var json = JsonSerializer.Serialize(saveData, SaveSystem.JsonOptions);
using var file = FileAccess.Open(path, FileAccess.ModeFlags.Write);
file.StoreString(json);
}
catch (System.Exception e)
{
GD.PrintErr($"GhostManager: Failed to save ghost data: {e.Message}");
}
{ "time", time },
{ "frames", _currentRecording.ToArray() }
};
file.StoreVar(dataToSave);
}
private List<GhostFrame> LoadGhostData(int levelIndex)
{
var path = $"user://ghost_level_{levelIndex}.json";
var path = $"user://ghost_level_{levelIndex}.dat";
if (!FileAccess.FileExists(path)) return [];
try
using var file = FileAccess.Open(path, FileAccess.ModeFlags.Read);
var savedData = (Dictionary)file.GetVar();
var framesArray = (Array)savedData["frames"];
var frames = new List<GhostFrame>();
foreach (var obj in framesArray)
{
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 [];
frames.Add((GhostFrame)obj);
}
return frames;
}
private double LoadBestTime(int levelIndex)
{
var path = $"user://ghost_level_{levelIndex}.json";
var path = $"user://ghost_level_{levelIndex}.dat";
if (!FileAccess.FileExists(path)) return double.MaxValue;
try
{
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;
}
using var file = FileAccess.Open(path, FileAccess.ModeFlags.Read);
var data = (Dictionary)file.GetVar();
return (double)data["time"];
}
}
}

View File

@@ -1,54 +0,0 @@
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

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

View File

@@ -1,88 +0,0 @@
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

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

View File

@@ -1,219 +0,0 @@
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

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

View File

@@ -14,24 +14,12 @@ public partial class SaveSystem : Node
[Export] public string SavePath { get; set; } = "user://savegame.json";
[Export] public int Version { get; set; } = 2;
public static SaveSystem Instance { get; private set; }
internal static readonly JsonSerializerOptions JsonOptions = new()
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public override void _Ready()
{
Instance = this;
}
public override void _ExitTree()
{
if (Instance == this) Instance = null;
}
public void SaveGame()
{
var store = GameStateStore.Instance;
@@ -47,7 +35,7 @@ public partial class SaveSystem : Node
Version = Version,
Coins = store.Player.Coins,
Lives = store.Player.Lives,
CurrentLevel = store.Player.CurrentLevel,
CurrentLevel = store.Session.CurrentLevel,
CompletedLevels = [.. store.Player.CompletedLevels],
UnlockedLevels = new List<int>(store.Player.UnlockedLevels),
UnlockedSkillNames = GetSkillNames(store.Player.UnlockedSkills),
@@ -105,7 +93,7 @@ public partial class SaveSystem : Node
// Apply loaded state
store.Player.Coins = saveData.Coins;
store.Player.Lives = saveData.Lives;
store.Player.CurrentLevel = saveData.CurrentLevel;
store.Session.CurrentLevel = saveData.CurrentLevel;
store.Player.CompletedLevels = saveData.CompletedLevels ?? new List<int>();
store.Player.UnlockedLevels = saveData.UnlockedLevels ?? new List<int> { 0 };
@@ -141,7 +129,7 @@ public partial class SaveSystem : Node
var skills = new List<SkillData>();
if (skillNames == null) return skills;
var skillManager = SkillManager.Instance;
var skillManager = GetNodeOrNull<SkillManager>(Constants.SkillManagerPath);
if (skillManager == null)
{
GD.PrintErr("SaveSystem: SkillManager not available to resolve skill names.");
@@ -174,3 +162,19 @@ public partial class SaveSystem : Node
}
}
}
/// <summary>
/// Serializable DTO for save data - no Godot types.
/// </summary>
public class SaveDataDto
{
public int Version { get; set; }
public int Coins { get; set; }
public int Lives { get; set; }
public int CurrentLevel { get; set; }
public List<int> CompletedLevels { get; set; }
public List<int> UnlockedLevels { get; set; }
public List<string> UnlockedSkillNames { get; set; }
public List<string> UnlockedAchievements { get; set; }
public Dictionary<string, int> Statistics { get; set; }
}

View File

@@ -1,203 +0,0 @@
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

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

View File

@@ -1,42 +1,23 @@
using System.Collections.Generic;
using System.Linq;
using Godot;
using Godot.Collections;
using Mr.BrickAdventures.Progression;
using Mr.BrickAdventures;
using Mr.BrickAdventures.scripts.components;
using Mr.BrickAdventures.scripts.interfaces;
using Mr.BrickAdventures.scripts.Resources;
namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Owns the live skill components attached to the player, and keeps them matching what the Playthrough
/// says is owned.
/// </summary>
/// <remarks>
/// <para>
/// Reconciles from <see cref="EventBus.SkillSetChanged"/> rather than being told by each caller. The old
/// arrangement had four call sites doing "record it in the store, then tell SkillManager" as two
/// separate calls — anything between them, including <c>AddSkill</c>'s own early return when no player
/// is registered, left a skill owned but inert.
/// </para>
/// <para>
/// Tracks the level each component was built at, so an upgrade rebuilds it. Previously
/// <c>AddSkill</c> returned early for an already-active skill, and the upgrade was applied by the
/// purchase code reaching into the component instead.
/// </para>
/// </remarks>
public partial class SkillManager : Node
{
private GameManager _gameManager;
private PlayerController _player;
[Export] public Array<SkillData> AvailableSkills { get; set; } = [];
public Dictionary ActiveComponents { get; private set; } = new();
private readonly System.Collections.Generic.Dictionary<string, int> _activeLevels = new();
public static SkillManager Instance { get; private set; }
[Signal]
public delegate void ActiveThrowSkillChangedEventHandler(BrickThrowComponent throwComponent);
[Signal]
@@ -44,16 +25,7 @@ public partial class SkillManager : Node
public override void _Ready()
{
Instance = this;
EventBus.Instance.SkillSetChanged += Reconcile;
}
public override void _ExitTree()
{
if (EventBus.Instance != null)
EventBus.Instance.SkillSetChanged -= Reconcile;
if (Instance == this) Instance = null;
_gameManager = GetNode<GameManager>(Constants.GameManagerPath);
}
/// <summary>
@@ -63,17 +35,24 @@ public partial class SkillManager : Node
{
if (_player == player) return;
// If a player is already registered (e.g., from a previous scene), unregister it first.
if (_player != null && IsInstanceValid(_player))
{
UnregisterPlayer();
}
_player = player;
if (_player != null)
{
// Automatically unregister when the player node is removed from the scene.
_player.TreeExiting += UnregisterPlayer;
Reconcile();
ApplyUnlockedSkills();
}
}
/// <summary>
/// Cleans up skills and references related to the current player.
/// </summary>
private void UnregisterPlayer()
{
if (_player != null && IsInstanceValid(_player))
@@ -84,37 +63,9 @@ public partial class SkillManager : Node
_player = null;
}
/// <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)
public void AddSkill(SkillData skillData)
{
// Ensure a valid player is registered before adding a skill.
if (_player == null || !IsInstanceValid(_player))
{
GD.Print("SkillManager: Player not available to add skill.");
@@ -126,17 +77,29 @@ public partial class SkillManager : Node
if (skillData.Type == SkillType.Throw)
{
foreach (var sd in AvailableSkills)
var unlocked = _gameManager.GetUnlockedSkills();
foreach (var sd in unlocked)
{
if (sd != skillData && sd?.Type == SkillType.Throw)
RemoveSkill(sd.Name);
SkillData data = null;
foreach (var s in AvailableSkills)
{
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();
if (instance is ISkill skill)
{
skill.Initialize(_player, skillData, level);
// Initialize the skill with the registered player instance.
skill.Initialize(_player, skillData);
skill.Activate();
}
else
@@ -146,31 +109,44 @@ public partial class SkillManager : Node
return;
}
// Add the skill node as a child of the player.
_player.AddChild(instance);
ActiveComponents[skillData.Name] = instance;
_activeLevels[skillData.Name] = level;
if (instance is BrickThrowComponent btc)
{
EmitSignalActiveThrowSkillChanged(btc);
}
}
public void RemoveSkill(string skillName)
{
_activeLevels.Remove(skillName);
if (!ActiveComponents.TryGetValue(skillName, out var component))
return;
if (component.AsGodotObject() is BrickThrowComponent)
{
EmitSignalActiveThrowSkillChanged(null);
}
var inst = (Node)component;
if (inst is ISkill skill)
{
skill.Deactivate();
}
if (IsInstanceValid(inst))
inst.QueueFree();
var skills = _gameManager.GetUnlockedSkills();
foreach (var s in skills)
{
if (s.Name == skillName)
{
s.IsActive = false;
break;
}
}
ActiveComponents.Remove(skillName);
var sd = GetSkillByName(skillName);
if (sd != null) EmitSignalSkillRemoved(sd);
@@ -178,30 +154,56 @@ public partial class SkillManager : Node
private void RemoveAllActiveSkills()
{
foreach (var skillName in ActiveComponents.Keys.Select(k => k.ToString()).ToList())
// Create a copy of keys to avoid modification during iteration
var keys = ActiveComponents.Keys.ToArray();
var skillNames = keys.Select(key => key.ToString()).ToList();
foreach (var skillName in skillNames)
{
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)
{
foreach (var sd in AvailableSkills)
if (sd?.Name == skillName) return sd;
if (sd.Name == skillName) return sd;
return null;
}
public bool IsSkillActive(SkillData skill) =>
skill != null && ActiveComponents.ContainsKey(skill.Name);
public void ActivateSkill(SkillData skill)
{
if (!ActiveComponents.ContainsKey(skill.Name))
{
AddSkill(skill);
skill.IsActive = true;
}
}
public void DeactivateSkill(SkillData skill)
{
if (ActiveComponents.ContainsKey(skill.Name))
{
RemoveSkill(skill.Name);
skill.IsActive = false;
}
}
public void ToggleSkillActivation(SkillData skill)
@@ -213,4 +215,4 @@ public partial class SkillManager : Node
else
ActivateSkill(skill);
}
}
}

View File

@@ -5,13 +5,9 @@ namespace Mr.BrickAdventures.Autoloads;
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 IsVisible { get; private set; } = false;
private double _startTime;
private double _levelStartTime;
private List<double> _splits = [];
@@ -20,7 +16,7 @@ public partial class SpeedRunManager : Node
public override void _Process(double delta)
{
if (!IsRunning) return;
if (!IsRunning || !IsVisible) return;
EmitSignalTimeUpdated(GetCurrentTotalTime(), GetCurrentLevelTime());
}

View File

@@ -0,0 +1,62 @@
using System.Collections.Generic;
using Godot;
using Mr.BrickAdventures.scripts.State;
namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Manages game statistics using GameStateStore.
/// </summary>
public partial class StatisticsManager : Node
{
/// <summary>
/// Gets the statistics dictionary from the store.
/// </summary>
private Dictionary<string, int> GetStats()
{
return GameStateStore.Instance?.Player.Statistics ?? new Dictionary<string, int>();
}
/// <summary>
/// Increases a numerical statistic by a given amount.
/// </summary>
public void IncrementStat(string statName, int amount = 1)
{
var stats = GetStats();
if (stats.TryGetValue(statName, out var currentValue))
{
stats[statName] = currentValue + amount;
}
else
{
stats[statName] = amount;
}
}
/// <summary>
/// Sets a statistic to a specific value.
/// </summary>
public void SetStat(string statName, int value)
{
var stats = GetStats();
stats[statName] = value;
}
/// <summary>
/// Gets the value of a statistic.
/// </summary>
public int GetStat(string statName)
{
var stats = GetStats();
return stats.TryGetValue(statName, out var value) ? value : 0;
}
/// <summary>
/// Gets a copy of all statistics.
/// </summary>
public Dictionary<string, int> GetAllStats()
{
return new Dictionary<string, int>(GetStats());
}
}

View File

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

View File

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

View File

@@ -1,103 +0,0 @@
# 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.7.1">
<Project Sdk="Godot.NET.Sdk/4.5.1">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<EnableDynamicLoading>true</EnableDynamicLoading>
@@ -9,17 +9,4 @@
<PackageReference Include="Facepunch.Steamworks.Dlls" Version="2.3.2" />
<PackageReference Include="Facepunch.Steamworks.Library" Version="2.3.3" />
</ItemGroup>
<!-- Godot.NET.Sdk globs **/*.cs, which would compile the progression projects into this
assembly as well as their own. They are separate projects; exclude them here. -->
<ItemGroup>
<Compile Remove="progression\**" />
<Compile Remove="progression.tests\**" />
<Compile Remove="progression.adapters\**" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="progression\Mr.BrickAdventures.Progression.csproj" />
<ProjectReference Include="progression.adapters\Mr.BrickAdventures.Progression.Adapters.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,139 +1,19 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 2012
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr. Brick Adventures", "Mr. Brick Adventures.csproj", "{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "progression", "progression", "{73CE8E92-AE0D-34EB-F3CD-A6392CC7A1AA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr.BrickAdventures.Progression", "progression\Mr.BrickAdventures.Progression.csproj", "{4F115C37-24F6-4933-9086-ED5925EE5106}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "progression.tests", "progression.tests", "{B756B2BA-9E41-468E-DBBE-5BA8CC0A1E41}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr.BrickAdventures.Progression.Tests", "progression.tests\Mr.BrickAdventures.Progression.Tests.csproj", "{A4660883-E42A-4D04-99A3-770F36E9D861}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "progression.adapters", "progression.adapters", "{9D5C8041-6C54-0A01-82AE-FC7E0DE079DA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr.BrickAdventures.Progression.Adapters", "progression.adapters\Mr.BrickAdventures.Progression.Adapters.csproj", "{045D9BF4-8E63-4F14-84F4-F8C55073244C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
ExportDebug|Any CPU = ExportDebug|Any CPU
ExportDebug|x64 = ExportDebug|x64
ExportDebug|x86 = ExportDebug|x86
ExportRelease|Any CPU = ExportRelease|Any CPU
ExportRelease|x64 = ExportRelease|x64
ExportRelease|x86 = ExportRelease|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
Debug|Any CPU = Debug|Any CPU
ExportDebug|Any CPU = ExportDebug|Any CPU
ExportRelease|Any CPU = ExportRelease|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|x64.ActiveCfg = Debug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|x64.Build.0 = Debug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|x86.Build.0 = Debug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|Any CPU.ActiveCfg = ExportDebug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|Any CPU.Build.0 = ExportDebug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|x64.ActiveCfg = ExportDebug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|x64.Build.0 = ExportDebug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|x86.ActiveCfg = ExportDebug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|x86.Build.0 = ExportDebug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|Any CPU.ActiveCfg = ExportRelease|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|Any CPU.Build.0 = ExportRelease|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|x64.ActiveCfg = ExportRelease|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|x64.Build.0 = ExportRelease|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|x86.ActiveCfg = ExportRelease|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|x86.Build.0 = ExportRelease|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|Any CPU.Build.0 = Release|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|x64.ActiveCfg = Release|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|x64.Build.0 = Release|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|x86.ActiveCfg = Release|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|x86.Build.0 = Release|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|x64.ActiveCfg = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|x64.Build.0 = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|x86.ActiveCfg = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|x86.Build.0 = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|Any CPU.ActiveCfg = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|Any CPU.Build.0 = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|x64.ActiveCfg = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|x64.Build.0 = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|x86.ActiveCfg = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|x86.Build.0 = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|Any CPU.ActiveCfg = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|Any CPU.Build.0 = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|x64.ActiveCfg = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|x64.Build.0 = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|x86.ActiveCfg = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|x86.Build.0 = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|Any CPU.Build.0 = Release|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|x64.ActiveCfg = Release|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|x64.Build.0 = Release|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|x86.ActiveCfg = Release|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|x86.Build.0 = Release|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|x64.ActiveCfg = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|x64.Build.0 = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|x86.ActiveCfg = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|x86.Build.0 = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|Any CPU.ActiveCfg = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|Any CPU.Build.0 = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|x64.ActiveCfg = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|x64.Build.0 = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|x86.ActiveCfg = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|x86.Build.0 = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|Any CPU.ActiveCfg = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|Any CPU.Build.0 = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|x64.ActiveCfg = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|x64.Build.0 = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|x86.ActiveCfg = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|x86.Build.0 = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|Any CPU.Build.0 = Release|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|x64.ActiveCfg = Release|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|x64.Build.0 = Release|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|x86.ActiveCfg = Release|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|x86.Build.0 = Release|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|x64.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|x64.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|x86.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|x86.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|Any CPU.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|Any CPU.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|x64.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|x64.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|x86.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|x86.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|Any CPU.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|Any CPU.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|x64.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|x64.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|x86.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|x86.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|Any CPU.Build.0 = Release|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|x64.ActiveCfg = Release|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|x64.Build.0 = Release|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|x86.ActiveCfg = Release|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{4F115C37-24F6-4933-9086-ED5925EE5106} = {73CE8E92-AE0D-34EB-F3CD-A6392CC7A1AA}
{A4660883-E42A-4D04-99A3-770F36E9D861} = {B756B2BA-9E41-468E-DBBE-5BA8CC0A1E41}
{045D9BF4-8E63-4F14-84F4-F8C55073244C} = {9D5C8041-6C54-0A01-82AE-FC7E0DE079DA}
EndGlobalSection
EndGlobal

View File

@@ -1,131 +0,0 @@
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

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

View File

@@ -1,23 +0,0 @@
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

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

View File

@@ -1,79 +0,0 @@
#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

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

View File

@@ -1,360 +0,0 @@
#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

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

View File

@@ -1,37 +0,0 @@
#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

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

View File

@@ -1,100 +0,0 @@
#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

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

View File

@@ -1,6 +0,0 @@
[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

@@ -1,6 +0,0 @@
[plugin]
name="CA Level Generator"
description="Cellular automata level blockout generator"
author="Gabriel Kaszewski"
version="1.0"
script="CaLevelGeneratorPlugin.cs"

View File

@@ -18,8 +18,6 @@ dest_files=["res://.godot/imported/banner.png-7e9e6a304eef850602c8d5afb80df9c3.c
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -27,10 +25,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -19,8 +19,6 @@ dest_files=["res://.godot/imported/icon.svg-17eb5d3e2a3cfbe59852220758c5b7bd.cte
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -28,10 +26,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -19,8 +19,6 @@ dest_files=["res://.godot/imported/responses_menu.svg-87cf63ca685d53616205049572
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -28,10 +26,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -18,8 +18,6 @@ dest_files=["res://.godot/imported/update.svg-f1628866ed4eb2e13e3b81f75443687e.c
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -27,10 +25,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,8 +18,6 @@ dest_files=["res://.godot/imported/PhantomCameraBtnPrimaryDefault.png-fcf3696b58
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -27,10 +25,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -18,8 +18,6 @@ dest_files=["res://.godot/imported/PhantomCameraBtnPrimaryHover.png-3d2e4d225f6a
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -27,10 +25,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -18,8 +18,6 @@ dest_files=["res://.godot/imported/inventory_container.png-12241277f279bfc4bf7d5
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -27,10 +25,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -18,8 +18,6 @@ dest_files=["res://.godot/imported/level_spritesheet.png-26a44dd21a040a5480d5ccb
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -27,10 +25,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -18,8 +18,6 @@ dest_files=["res://.godot/imported/phantom_camera_2d_sprite.png-deab230b83ae03ae
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -27,10 +25,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -18,8 +18,6 @@ dest_files=["res://.godot/imported/player_sprite.svg-8862ecb19e12152eb892607676f
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -27,10 +25,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -18,8 +18,6 @@ dest_files=["res://.godot/imported/sign_prompt.png-18d451127e1cd1a16367acd23cec4
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -27,10 +25,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -19,8 +19,6 @@ dest_files=["res://.godot/imported/checker_pattern_dark.png-70cedad2d3abf4ad6166
compress/mode=2
compress/high_quality=true
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -28,10 +26,6 @@ mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -19,8 +19,6 @@ dest_files=["res://.godot/imported/target.png-878c5e8d057c8a9a4c2322d4ab88e9ef.s
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -28,10 +26,6 @@ mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

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

View File

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

View File

@@ -18,8 +18,6 @@ dest_files=["res://.godot/imported/PriorityOverride.svg-e76e07f4bbd98169f119e17f
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -27,10 +25,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -18,8 +18,6 @@ dest_files=["res://.godot/imported/phantom_camera_2d.svg-e5483cbc858fc5f95f7210b
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -27,10 +25,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -18,8 +18,6 @@ dest_files=["res://.godot/imported/phantom_camera_3d.svg-41ed612e834470377fb56ee
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -27,10 +25,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -18,8 +18,6 @@ dest_files=["res://.godot/imported/phantom_camera_camera_3d_resource.svg-f8bf8d1
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -27,10 +25,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -19,8 +19,6 @@ dest_files=["res://.godot/imported/phantom_camera_gizmo.svg-ba1aacb9b1c5f4ef401d
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -28,10 +26,6 @@ mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -18,8 +18,6 @@ dest_files=["res://.godot/imported/phantom_camera_glow_logo.png-078f944973b55b32
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -27,10 +25,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -19,8 +19,6 @@ dest_files=["res://.godot/imported/phantom_camera_host.svg-3150f8f2d82ca9ecab9a3
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -28,10 +26,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -18,8 +18,6 @@ dest_files=["res://.godot/imported/phantom_camera_logo.png-8b8d347b5e4800c86cd80
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -27,10 +25,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -18,8 +18,6 @@ dest_files=["res://.godot/imported/phantom_camera_noise_emitter_2d.svg-1b3d37fe3
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -27,10 +25,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -19,8 +19,6 @@ dest_files=["res://.godot/imported/phantom_camera_noise_emitter_3d.svg-9b90fe54a
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -28,10 +26,6 @@ mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -19,8 +19,6 @@ dest_files=["res://.godot/imported/phantom_camera_noise_emitter_gizmo.svg-9a5938
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -28,10 +26,6 @@ mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -18,8 +18,6 @@ dest_files=["res://.godot/imported/phantom_camera_noise_resource.svg-a81ed223714
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -27,10 +25,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -19,8 +19,6 @@ dest_files=["res://.godot/imported/phantom_camera_tween.svg-16faced08ef4a5f34582
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -28,10 +26,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -18,8 +18,6 @@ dest_files=["res://.godot/imported/phantom_camera_updater_panel_icon.svg-19823e6
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -27,10 +25,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -18,8 +18,6 @@ dest_files=["res://.godot/imported/Camera2DIcon.svg-300e6f57281180711c5ecf391104
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -27,10 +25,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -18,8 +18,6 @@ dest_files=["res://.godot/imported/Camera3DIcon.svg-4805c46004db1c89cc9443dd7406
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -27,10 +25,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -18,8 +18,6 @@ dest_files=["res://.godot/imported/SceneTypesIcon.svg-66e2255bd3398007bec03a5cbf
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -27,10 +25,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -18,8 +18,6 @@ dest_files=["res://.godot/imported/Select.svg-cdf90b8b400d3b91a023b70c6a823894.c
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -27,10 +25,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -18,8 +18,6 @@ dest_files=["res://.godot/imported/warning.svg-c1b21c265e0842bbdc9ed10735995366.
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -27,10 +25,6 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@@ -1,4 +1,4 @@
[gd_resource type="Theme" format=3 uid="uid://bhppejri5dbsf"]
[gd_resource type="Theme" load_steps=12 format=3 uid="uid://bhppejri5dbsf"]
[ext_resource type="FontFile" uid="uid://dve7mgsjik4dg" path="res://addons/phantom_camera/fonts/Nunito-Regular.ttf" id="1_5rtjh"]
[ext_resource type="StyleBox" uid="uid://5weqvkjsfso3" path="res://addons/phantom_camera/themes/button_hover.tres" id="2_du6h5"]

View File

@@ -1,15 +0,0 @@
# 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

@@ -1,16 +0,0 @@
# 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

@@ -1,15 +0,0 @@
# 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

@@ -1,21 +0,0 @@
# 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

@@ -1,28 +0,0 @@
# 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

@@ -1,322 +0,0 @@
# 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,7 +21,6 @@ msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=1
keep_rounding_remainders=true

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
[gd_scene format=3 uid="uid://54w4wisfj8v8"]
[gd_scene load_steps=7 format=3 uid="uid://54w4wisfj8v8"]
[ext_resource type="Texture2D" uid="uid://djifxc5x0dyrw" path="res://sprites/ppc_tileset.png" id="1_epuj5"]
[ext_resource type="Script" uid="uid://r4jybneigfcn" path="res://scripts/components/CollectableComponent.cs" id="2_gxix7"]
@@ -9,33 +9,33 @@
[sub_resource type="CircleShape2D" id="CircleShape2D_3ask2"]
radius = 9.0
[node name="Coin" type="Area2D" unique_id=1771447403 groups=["coins"]]
[node name="Coin" type="Area2D" groups=["coins"]]
collision_layer = 2
collision_mask = 4
[node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=707378099]
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("CircleShape2D_3ask2")
[node name="Sprite2D" type="Sprite2D" parent="." unique_id=898624458]
[node name="Sprite2D" type="Sprite2D" parent="."]
texture = ExtResource("1_epuj5")
hframes = 12
vframes = 12
frame = 51
[node name="CollectableComponent" type="Node" parent="." unique_id=564726971 node_paths=PackedStringArray("Area2D", "CollisionShape", "Sfx")]
[node name="CollectableComponent" type="Node" parent="." node_paths=PackedStringArray("Area2D", "CollisionShape", "Sfx")]
script = ExtResource("2_gxix7")
Area2D = NodePath("..")
CollisionShape = NodePath("../CollisionShape2D")
Data = ExtResource("3_fm2fq")
Sfx = NodePath("../sfx")
[node name="FadeAwayComponent" type="Node" parent="." unique_id=1534239994 node_paths=PackedStringArray("Sprite", "Area")]
[node name="FadeAwayComponent" type="Node" parent="." node_paths=PackedStringArray("Sprite", "Area")]
script = ExtResource("4_gxix7")
Sprite = NodePath("../Sprite2D")
FadeDuration = 0.4
Area = NodePath("..")
[node name="sfx" type="AudioStreamPlayer2D" parent="." unique_id=1641717]
[node name="sfx" type="AudioStreamPlayer2D" parent="."]
stream = ExtResource("5_4jc2c")
volume_db = -10.0
bus = &"sfx"

View File

@@ -1,6 +1,6 @@
[gd_scene format=3 uid="uid://c40r76qqacqie"]
[gd_scene load_steps=5 format=3 uid="uid://c40r76qqacqie"]
[ext_resource type="Texture2D" uid="uid://cysarpu6snb2y" path="res://sprites/ppc-tileset.png" id="1_jmsgb"]
[ext_resource type="Texture2D" uid="uid://cw42lvnqxubq2" path="res://sprites/PS_Tileset_10_nes.png" id="1_jmsgb"]
[ext_resource type="Script" uid="uid://xqhrb1c7f6y4" path="res://scripts/components/CollapsableComponent.cs" id="2_jmsgb"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_l44jt"]
@@ -9,30 +9,30 @@ size = Vector2(16, 16)
[sub_resource type="RectangleShape2D" id="RectangleShape2D_sfbe2"]
size = Vector2(16, 2)
[node name="Collapsing Block" type="StaticBody2D" unique_id=1834310310]
[node name="Collapsing Block" type="StaticBody2D"]
[node name="Sprite2D" type="Sprite2D" parent="." unique_id=2120289038]
[node name="Sprite2D" type="Sprite2D" parent="."]
texture = ExtResource("1_jmsgb")
hframes = 64
vframes = 64
frame = 710
hframes = 16
vframes = 12
frame = 182
[node name="StaticCollisionShape2D" type="CollisionShape2D" parent="." unique_id=196070485]
[node name="StaticCollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("RectangleShape2D_l44jt")
[node name="Collapsable detector" type="Area2D" parent="." unique_id=1910035087]
[node name="Collapsable detector" type="Area2D" parent="."]
collision_mask = 4
[node name="AreaCollisionShape2D" type="CollisionShape2D" parent="Collapsable detector" unique_id=1903927379]
[node name="AreaCollisionShape2D" type="CollisionShape2D" parent="Collapsable detector"]
position = Vector2(0, -8)
shape = SubResource("RectangleShape2D_sfbe2")
[node name="ToCollapseTimer" type="Timer" parent="." unique_id=2109761872]
[node name="ToCollapseTimer" type="Timer" parent="."]
wait_time = 0.5
[node name="ResetTimer" type="Timer" parent="." unique_id=1072118129]
[node name="ResetTimer" type="Timer" parent="."]
[node name="CollapsableComponent" type="Node" parent="." unique_id=1696909259 node_paths=PackedStringArray("ToCollapseTimer", "ResetTimer", "Sprite2D", "CollisionShape")]
[node name="CollapsableComponent" type="Node" parent="." node_paths=PackedStringArray("ToCollapseTimer", "ResetTimer", "Sprite2D", "CollisionShape")]
script = ExtResource("2_jmsgb")
ToCollapseTimer = NodePath("../ToCollapseTimer")
ResetTimer = NodePath("../ResetTimer")

View File

@@ -1,34 +0,0 @@
[gd_scene load_steps=6 format=3 uid="uid://dk2cu8qs7odib"]
[ext_resource type="Texture2D" uid="uid://djifxc5x0dyrw" path="res://sprites/ppc_tileset.png" id="1_214vd"]
[ext_resource type="Script" uid="uid://r4jybneigfcn" path="res://scripts/components/CollectableComponent.cs" id="2_h7fi3"]
[ext_resource type="Script" uid="uid://bjln6jb1sigx2" path="res://scripts/components/FadeAwayComponent.cs" id="3_b687r"]
[ext_resource type="Resource" path="res://resources/collectables/double_jump_pickup.tres" id="3_h7fi3"]
[sub_resource type="CircleShape2D" id="CircleShape2D_pickup"]
radius = 12.0
[node name="SkillPickup" type="Area2D" groups=["Collectables"]]
collision_layer = 2
collision_mask = 4
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("CircleShape2D_pickup")
[node name="Sprite2D" type="Sprite2D" parent="."]
texture = ExtResource("1_214vd")
hframes = 12
vframes = 12
frame = 24
[node name="CollectableComponent" type="Node" parent="." node_paths=PackedStringArray("Area2D", "CollisionShape")]
script = ExtResource("2_h7fi3")
Area2D = NodePath("..")
CollisionShape = NodePath("../CollisionShape2D")
Data = ExtResource("3_h7fi3")
[node name="FadeAwayComponent" type="Node" parent="." node_paths=PackedStringArray("Sprite", "Area")]
script = ExtResource("3_b687r")
Sprite = NodePath("../Sprite2D")
FadeDuration = 0.5
Area = NodePath("..")

View File

@@ -1,4 +1,4 @@
[gd_scene format=3 uid="uid://12jnkdygpxwc"]
[gd_scene load_steps=6 format=3 uid="uid://12jnkdygpxwc"]
[ext_resource type="Texture2D" uid="uid://djifxc5x0dyrw" path="res://sprites/ppc_tileset.png" id="1_agxwm"]
[ext_resource type="Script" uid="uid://dnh0mekg2vqxi" path="res://scripts/components/RequirementComponent.cs" id="2_ed7mh"]
@@ -8,14 +8,14 @@
[sub_resource type="RectangleShape2D" id="RectangleShape2D_yfu6m"]
size = Vector2(28, 32)
[node name="ExitLevel" type="Area2D" unique_id=1951927125 node_paths=PackedStringArray("DoorSprite")]
[node name="ExitLevel" type="Area2D" node_paths=PackedStringArray("DoorSprite")]
collision_layer = 0
collision_mask = 4
script = ExtResource("4_06sog")
DoorSprite = NodePath("Sprite2D")
OpenedDoorFrame = 88
[node name="Sprite2D" type="Sprite2D" parent="." unique_id=1296410089]
[node name="Sprite2D" type="Sprite2D" parent="."]
position = Vector2(0, -8)
scale = Vector2(2, 2)
texture = ExtResource("1_agxwm")
@@ -23,15 +23,15 @@ hframes = 12
vframes = 12
frame = 54
[node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=220927363]
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
position = Vector2(0, -8)
shape = SubResource("RectangleShape2D_yfu6m")
[node name="RequirementComponent" type="Node" parent="." unique_id=1338124097]
[node name="RequirementComponent" type="Node" parent="."]
script = ExtResource("2_ed7mh")
RequirementType = 2
RequirementType = 1
[node name="UnlockOnRequirementComponent" type="Node" parent="." unique_id=1279359200 node_paths=PackedStringArray("RequirementComponent", "UnlockTarget")]
[node name="UnlockOnRequirementComponent" type="Node" parent="." node_paths=PackedStringArray("RequirementComponent", "UnlockTarget")]
script = ExtResource("3_ed7mh")
RequirementComponent = NodePath("../RequirementComponent")
UnlockTarget = NodePath("..")

View File

@@ -1,4 +1,4 @@
[gd_scene format=3 uid="uid://xp4njljog0x2"]
[gd_scene load_steps=30 format=3 uid="uid://xp4njljog0x2"]
[ext_resource type="Texture2D" uid="uid://22k1u37j6k8y" path="res://sprites/flying_enemy.png" id="1_30hhw"]
[ext_resource type="Shader" uid="uid://bs4xvm4qkurpr" path="res://shaders/hit_flash.tres" id="1_uyhuj"]
@@ -60,7 +60,7 @@ point_count = 2
[sub_resource type="CurveTexture" id="CurveTexture_7b7mt"]
curve = SubResource("Curve_f2w8b")
[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_q78ru"]
[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_qxfb0"]
resource_local_to_scene = true
lifetime_randomness = 1.0
particle_flag_disable_z = true
@@ -80,52 +80,50 @@ color_ramp = SubResource("GradientTexture1D_f1fvy")
[sub_resource type="RectangleShape2D" id="RectangleShape2D_cmp1h"]
size = Vector2(16, 26)
[node name="Flying Enemy" type="CharacterBody2D" unique_id=1068972930]
[node name="Flying Enemy" type="CharacterBody2D"]
collision_layer = 8
collision_mask = 21
motion_mode = 1
[node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=572073196]
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
visible = false
shape = SubResource("CapsuleShape2D_hil2i")
[node name="Sprite2D" type="Sprite2D" parent="." unique_id=191340928]
[node name="Sprite2D" type="Sprite2D" parent="."]
material = SubResource("ShaderMaterial_q78ru")
texture = ExtResource("1_30hhw")
[node name="Jetpack Particles" type="GPUParticles2D" parent="." unique_id=1641064286]
[node name="Jetpack Particles" type="GPUParticles2D" parent="."]
z_index = -1
position = Vector2(-4, 16)
explosiveness = 0.5
fixed_fps = 24
process_material = SubResource("ParticleProcessMaterial_fd2du")
[node name="Jetpack Particles2" type="GPUParticles2D" parent="." unique_id=962140317]
[node name="Jetpack Particles2" type="GPUParticles2D" parent="."]
z_index = -1
position = Vector2(4, 16)
explosiveness = 0.5
fixed_fps = 24
process_material = SubResource("ParticleProcessMaterial_fd2du")
[node name="HealthComponent" type="Node2D" parent="." unique_id=1125679087 node_paths=PackedStringArray("HurtSfx")]
[node name="HealthComponent" type="Node2D" parent="." node_paths=PackedStringArray("HurtSfx")]
script = ExtResource("3_uyhuj")
Health = 0.25
MaxHealth = 0.25
HurtSfx = NodePath("../sfx_hurt")
[node name="sfx_hurt" type="AudioStreamPlayer2D" parent="." unique_id=1006537001]
[node name="sfx_hurt" type="AudioStreamPlayer2D" parent="."]
stream = ExtResource("3_fd2du")
bus = &"sfx"
[node name="sfx_shoot" type="AudioStreamPlayer2D" parent="." unique_id=1437744637]
[node name="sfx_shoot" type="AudioStreamPlayer2D" parent="."]
stream = ExtResource("4_rhq76")
bus = &"sfx"
[node name="DamageComponent" type="Node" parent="." unique_id=1923393563 node_paths=PackedStringArray("Area")]
[node name="DamageComponent" type="Node" parent="." node_paths=PackedStringArray("Area")]
script = ExtResource("6_q78ru")
Area = NodePath("../Hitbox")
[node name="PeriodicShootingComponent" type="Node" parent="." unique_id=1743534415 node_paths=PackedStringArray("BulletSpawnPointRight", "BulletSpawnPointLeft")]
[node name="PeriodicShootingComponent" type="Node" parent="." node_paths=PackedStringArray("BulletSpawnPointRight", "BulletSpawnPointLeft")]
script = ExtResource("7_weo6b")
BulletScene = ExtResource("7_4ajjm")
ShootInterval = 2.0
@@ -133,68 +131,68 @@ BulletSpawnPointRight = NodePath("../laser spawn point right")
BulletSpawnPointLeft = NodePath("../laser spawn point left")
ShootingIntervalVariation = 0.5
[node name="EnemyDeathComponent" type="Node" parent="." unique_id=1519565079 node_paths=PackedStringArray("CollisionShape", "Health")]
[node name="EnemyDeathComponent" type="Node" parent="." node_paths=PackedStringArray("CollisionShape", "Health")]
script = ExtResource("9_6p4k7")
TweenDuration = 0.1
CollisionShape = NodePath("../CollisionShape2D")
Health = NodePath("../HealthComponent")
[node name="FlashingComponent" type="Node" parent="." unique_id=1761479670 node_paths=PackedStringArray("Sprite", "HealthComponent")]
[node name="FlashingComponent" type="Node" parent="." node_paths=PackedStringArray("Sprite", "HealthComponent")]
process_mode = 3
script = ExtResource("10_jmybk")
Sprite = NodePath("../Sprite2D")
HealthComponent = NodePath("../HealthComponent")
[node name="HitComponent" type="Node" parent="." unique_id=2106183592 node_paths=PackedStringArray("Sprite", "Health", "HitFx")]
[node name="HitComponent" type="Node" parent="." node_paths=PackedStringArray("Sprite", "Health", "HitFx")]
script = ExtResource("11_2yvae")
Sprite = NodePath("../Sprite2D")
Health = NodePath("../HealthComponent")
HitFx = NodePath("../HitParticles")
[node name="StatusEffectComponent" type="Node" parent="." unique_id=2016067092]
[node name="StatusEffectComponent" type="Node" parent="."]
script = ExtResource("12_xlup2")
[node name="FireEffectComponent" type="Node" parent="." unique_id=599378746 node_paths=PackedStringArray("Health", "StatusEffectComponent", "FireFX")]
[node name="FireEffectComponent" type="Node" parent="." node_paths=PackedStringArray("Health", "StatusEffectComponent", "FireFX")]
script = ExtResource("13_mrjm6")
Health = NodePath("../HealthComponent")
StatusEffectComponent = NodePath("../StatusEffectComponent")
FireFX = NodePath("../FireFX")
[node name="IceEffectComponent" type="Node" parent="." unique_id=1515560540 node_paths=PackedStringArray("ComponentsToDisable", "StatusEffectComponent", "IceFx")]
[node name="IceEffectComponent" type="Node" parent="." node_paths=PackedStringArray("ComponentsToDisable", "StatusEffectComponent", "IceFx")]
script = ExtResource("14_pkino")
ComponentsToDisable = [NodePath("../PeriodicShootingComponent"), NodePath("../DamageComponent")]
StatusEffectComponent = NodePath("../StatusEffectComponent")
IceFx = NodePath("../Ice FX")
[node name="HitParticles" parent="." unique_id=579475644 instance=ExtResource("13_xlup2")]
[node name="HitParticles" parent="." instance=ExtResource("13_xlup2")]
position = Vector2(0, 1)
process_material = SubResource("ParticleProcessMaterial_q78ru")
process_material = SubResource("ParticleProcessMaterial_qxfb0")
[node name="FireFX" parent="." unique_id=1136026281 instance=ExtResource("14_mrjm6")]
[node name="FireFX" parent="." instance=ExtResource("14_mrjm6")]
position = Vector2(0, 9)
emitting = false
amount = 2048
[node name="Ice FX" parent="." unique_id=275134518 instance=ExtResource("15_pkino")]
[node name="Ice FX" parent="." instance=ExtResource("15_pkino")]
visible = false
position = Vector2(1, 0)
scale = Vector2(0.684407, 0.929677)
[node name="laser spawn point right" type="Node2D" parent="." unique_id=915998238]
[node name="laser spawn point right" type="Node2D" parent="."]
position = Vector2(8, -2)
[node name="laser spawn point left" type="Node2D" parent="." unique_id=1180867485]
[node name="laser spawn point left" type="Node2D" parent="."]
position = Vector2(-9, -2)
[node name="Hitbox" type="Area2D" parent="." unique_id=1699649839]
[node name="Hitbox" type="Area2D" parent="."]
collision_layer = 8
collision_mask = 20
[node name="CollisionShape2D" type="CollisionShape2D" parent="Hitbox" unique_id=379520850]
[node name="CollisionShape2D" type="CollisionShape2D" parent="Hitbox"]
position = Vector2(0, 2)
shape = SubResource("RectangleShape2D_cmp1h")
[node name="PathFollowerComponent" type="Node2D" parent="." unique_id=1706994676]
[node name="PathFollowerComponent" type="Node2D" parent="."]
script = ExtResource("18_q78ru")
ShouldRotate = false
metadata/_custom_type_script = "uid://b4hvq2i66fjhi"

View File

@@ -1,4 +1,4 @@
[gd_scene format=3 uid="uid://cm3rixnnev1pg"]
[gd_scene load_steps=4 format=3 uid="uid://cm3rixnnev1pg"]
[ext_resource type="Texture2D" uid="uid://djifxc5x0dyrw" path="res://sprites/ppc_tileset.png" id="1_ctugi"]
[ext_resource type="Script" uid="uid://bgbnof7aeydmq" path="res://scripts/components/JumpPadComponent.cs" id="2_huktk"]
@@ -6,23 +6,23 @@
[sub_resource type="RectangleShape2D" id="RectangleShape2D_ci3ij"]
size = Vector2(16, 6)
[node name="Jump pad" type="Area2D" unique_id=986549607]
[node name="Jump pad" type="Area2D"]
collision_layer = 0
collision_mask = 4
[node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=1755302504]
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
position = Vector2(0, 5)
shape = SubResource("RectangleShape2D_ci3ij")
[node name="Sprite2D" type="Sprite2D" parent="." unique_id=1150967280]
[node name="Sprite2D" type="Sprite2D" parent="."]
texture = ExtResource("1_ctugi")
hframes = 12
vframes = 12
frame = 120
[node name="JumpPadComponent" type="Node" parent="." unique_id=1295810804 node_paths=PackedStringArray("Area", "Sprite")]
[node name="JumpPadComponent" type="Node" parent="." node_paths=PackedStringArray("Area", "Sprite")]
script = ExtResource("2_huktk")
JumpForce = 1210.0
JumpForce = 1110.0
Area = NodePath("..")
Sprite = NodePath("../Sprite2D")
StartAnimationIndex = 120

View File

@@ -1,32 +0,0 @@
[gd_scene load_steps=5 format=3 uid="uid://0idmnkwids1r"]
[ext_resource type="Texture2D" uid="uid://djifxc5x0dyrw" path="res://sprites/ppc_tileset.png" id="1_sprite"]
[ext_resource type="Script" uid="uid://r4jybneigfcn" path="res://scripts/components/CollectableComponent.cs" id="2_collectable"]
[ext_resource type="Script" uid="uid://bjln6jb1sigx2" path="res://scripts/components/FadeAwayComponent.cs" id="3_fadeaway"]
[sub_resource type="CircleShape2D" id="CircleShape2D_pickup"]
radius = 12.0
[node name="SkillPickup" type="Area2D" groups=["Collectables"]]
collision_layer = 2
collision_mask = 4
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("CircleShape2D_pickup")
[node name="Sprite2D" type="Sprite2D" parent="."]
texture = ExtResource("1_sprite")
hframes = 12
vframes = 12
frame = 24
[node name="CollectableComponent" type="Node" parent="." node_paths=PackedStringArray("Area2D", "CollisionShape")]
script = ExtResource("2_collectable")
Area2D = NodePath("..")
CollisionShape = NodePath("../CollisionShape2D")
[node name="FadeAwayComponent" type="Node" parent="." node_paths=PackedStringArray("Sprite", "Area")]
script = ExtResource("3_fadeaway")
Sprite = NodePath("../Sprite2D")
FadeDuration = 0.5
Area = NodePath("..")

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