From b54d886145483f2940a2bef971fe6a96d1f50db6 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 10 Aug 2025 01:35:35 +0200 Subject: [PATCH] Add core game components including ConfigFileHandler, GameManager, SaveSystem, and UIManager --- Autoloads/ConfigFileHandler.cs | 25 +++ Autoloads/GameManager.cs | 195 ++++++++++++++++++ Autoloads/SaveSystem.cs | 46 +++++ Autoloads/UIManager.cs | 65 ++++++ Mr. Brick Adventures.csproj | 136 ++++++++++++ autoloads/ui_manager.gd | 2 +- project.godot | 2 +- scripts/Resources/CollectableResource.cs | 9 + scripts/Resources/CollectableType.cs | 8 + scripts/Resources/SkillData.cs | 19 ++ scripts/Resources/SkillType.cs | 8 + scripts/Resources/StatusEffectDataResource.cs | 8 + scripts/Resources/StatusEffectType.cs | 8 + scripts/Screenshot.cs | 15 ++ scripts/SkillManager.cs | 144 +++++++++++++ scripts/components/.idea/.gitignore | 13 ++ scripts/components/.idea/encodings.xml | 4 + scripts/components/.idea/indexLayout.xml | 8 + .../inspectionProfiles/Project_Default.xml | 6 + scripts/components/.idea/vcs.xml | 6 + scripts/components/CollectableComponent.cs | 50 +++++ scripts/components/DamageComponent.cs | 105 ++++++++++ scripts/components/FlashingComponent.cs | 83 ++++++++ scripts/components/HealthComponent.cs | 65 ++++++ .../components/InvulnerabilityComponent.cs | 31 +++ scripts/components/PlatformMovement.cs | 169 +++++++++++++++ scripts/components/PlatformMovement.cs.uid | 1 + scripts/components/PlayerController.cs | 103 +++++++++ scripts/components/PlayerController.cs.uid | 1 + scripts/interfaces/IMovement.cs | 13 ++ scripts/interfaces/IMovement.cs.uid | 1 + 31 files changed, 1347 insertions(+), 2 deletions(-) create mode 100644 Autoloads/ConfigFileHandler.cs create mode 100644 Autoloads/GameManager.cs create mode 100644 Autoloads/SaveSystem.cs create mode 100644 Autoloads/UIManager.cs create mode 100644 scripts/Resources/CollectableResource.cs create mode 100644 scripts/Resources/CollectableType.cs create mode 100644 scripts/Resources/SkillData.cs create mode 100644 scripts/Resources/SkillType.cs create mode 100644 scripts/Resources/StatusEffectDataResource.cs create mode 100644 scripts/Resources/StatusEffectType.cs create mode 100644 scripts/Screenshot.cs create mode 100644 scripts/SkillManager.cs create mode 100644 scripts/components/.idea/.gitignore create mode 100644 scripts/components/.idea/encodings.xml create mode 100644 scripts/components/.idea/indexLayout.xml create mode 100644 scripts/components/.idea/inspectionProfiles/Project_Default.xml create mode 100644 scripts/components/.idea/vcs.xml create mode 100644 scripts/components/CollectableComponent.cs create mode 100644 scripts/components/DamageComponent.cs create mode 100644 scripts/components/FlashingComponent.cs create mode 100644 scripts/components/HealthComponent.cs create mode 100644 scripts/components/InvulnerabilityComponent.cs create mode 100644 scripts/components/PlatformMovement.cs create mode 100644 scripts/components/PlatformMovement.cs.uid create mode 100644 scripts/components/PlayerController.cs create mode 100644 scripts/components/PlayerController.cs.uid create mode 100644 scripts/interfaces/IMovement.cs create mode 100644 scripts/interfaces/IMovement.cs.uid diff --git a/Autoloads/ConfigFileHandler.cs b/Autoloads/ConfigFileHandler.cs new file mode 100644 index 0000000..5799faf --- /dev/null +++ b/Autoloads/ConfigFileHandler.cs @@ -0,0 +1,25 @@ +using Godot; + +namespace Mr.BrickAdventures.Autoloads; + +public partial class ConfigFileHandler : Node +{ + private ConfigFile _settingsConfig = new(); + private const string SettingsPath = "user://settings.ini"; + + public override void _Ready() + { + if (!FileAccess.FileExists(SettingsPath)) + { + var err = _settingsConfig.Save(SettingsPath); + if (err != Error.Ok) + GD.PushError($"Failed to create settings file at {SettingsPath}: {err}"); + } + else + { + var err = _settingsConfig.Load(SettingsPath); + if (err != Error.Ok) + GD.PushError($"Failed to load settings file at {SettingsPath}: {err}"); + } + } +} \ No newline at end of file diff --git a/Autoloads/GameManager.cs b/Autoloads/GameManager.cs new file mode 100644 index 0000000..588fc65 --- /dev/null +++ b/Autoloads/GameManager.cs @@ -0,0 +1,195 @@ +using Godot; +using Godot.Collections; +using Mr.BrickAdventures.scripts.Resources; + +namespace Mr.BrickAdventures.Autoloads; + +public partial class GameManager : Node +{ + [Export] public Array LevelScenes { get; set; } = new(); + + public Dictionary PlayerState { get; set; } = new() + { + { "coins", 0 }, + { "lives", 3 }, + { "current_level", 0 }, + { "completed_levels", new Array() }, + { "unlocked_levels", new Array() {0}}, + { "unlocked_skills", new Array() } + }; + + private Dictionary _currentSessionState = new() + { + { "coins_collected", 0 }, + { "skills_unlocked", new Array() } + }; + + public void AddCoins(int amount) + { + PlayerState["coins"] = Mathf.Max(0, (int)PlayerState["coins"] + amount); + } + + public void SetCoins(int amount) => PlayerState["coins"] = Mathf.Max(0, amount); + + public int GetCoins() => (int)PlayerState["coins"] + (int)_currentSessionState["coins_collected"]; + + public void RemoveCoins(int amount) + { + var sessionCoins = (int)_currentSessionState["coins_collected"]; + if (amount <= sessionCoins) + { + _currentSessionState["coins_collected"] = sessionCoins - amount; + } + else + { + var remaining = amount - sessionCoins; + _currentSessionState["coins_collected"] = 0; + PlayerState["coins"] = Mathf.Max(0, (int)PlayerState["coins"] - remaining); + } + PlayerState["coins"] = Mathf.Max(0, (int)PlayerState["coins"]); + } + + public void AddLives(int amount) => PlayerState["lives"] = (int)PlayerState["lives"] + amount; + public void RemoveLives(int amount) => PlayerState["lives"] = (int)PlayerState["lives"] - amount; + public void SetLives(int amount) => PlayerState["lives"] = amount; + public int GetLives() => (int)PlayerState["lives"]; + + public bool IsSkillUnlocked(SkillData skill) + { + return ((Array)PlayerState["unlocked_skills"]).Contains(skill) + || ((Array)_currentSessionState["skills_unlocked"]).Contains(skill); + } + + public void UnlockSkill(SkillData skill) + { + if (!IsSkillUnlocked(skill)) + ((Array)PlayerState["unlocked_skills"]).Add(skill); + } + + public void RemoveSkill(string skillName) + { + var arr = (Array)PlayerState["unlocked_skills"]; + foreach (SkillData s in arr) + { + if (s.Name != skillName) continue; + + arr.Remove(s); + break; + } + } + + public void UnlockSkills(Array skills) + { + foreach (var s in skills) + UnlockSkill(s); + } + + public void ResetPlayerState() + { + PlayerState = new Dictionary + { + { "coins", 0 }, + { "lives", 3 }, + { "current_level", 0 }, + { "completed_levels", new Array() }, + { "unlocked_levels", new Array() {0}}, + { "unlocked_skills", new Array() } + }; + } + + public void UnlockLevel(int levelIndex) + { + var unlocked = (Array)PlayerState["unlocked_levels"]; + if (!unlocked.Contains(levelIndex)) unlocked.Add(levelIndex); + } + + public void TryToGoToNextLevel() + { + var next = (int)PlayerState["current_level"] + 1; + var unlocked = (Array)PlayerState["unlocked_levels"]; + if (next < LevelScenes.Count && unlocked.Contains(next)) + { + PlayerState["current_level"] = next; + GetTree().ChangeSceneToPacked(LevelScenes[next]); + } + } + + public void MarkLevelComplete(int levelIndex) + { + UnlockLevel(levelIndex + 1); + var completed = (Array)PlayerState["completed_levels"]; + if (!completed.Contains(levelIndex)) completed.Add(levelIndex); + } + + public void ResetCurrentSessionState() + { + _currentSessionState = new Dictionary + { + { "coins_collected", 0 }, + { "skills_unlocked", new Array() } + }; + } + + public void RestartGame() + { + ResetPlayerState(); + ResetCurrentSessionState(); + GetTree().ChangeSceneToPacked(LevelScenes[0]); + GetNode("/root/SaveSystem").SaveGame(); + } + + public void QuitGame() => GetTree().Quit(); + + public void PauseGame() => Engine.TimeScale = 0; + public void ResumeGame() => Engine.TimeScale = 1; + + public void StartNewGame() + { + ResetPlayerState(); + ResetCurrentSessionState(); + GetTree().ChangeSceneToPacked(LevelScenes[0]); + GetNode("/root/SaveSystem").SaveGame(); + } + + public void ContinueGame() + { + var save = GetNode("/root/SaveSystem"); + if (!save.LoadGame()) + { + GD.PrintErr("Failed to load game. Starting a new game instead."); + StartNewGame(); + return; + } + + var idx = (int)PlayerState["current_level"]; + if (idx < LevelScenes.Count) + GetTree().ChangeSceneToPacked(LevelScenes[idx]); + else + GD.PrintErr("No levels unlocked to continue."); + } + + public void OnLevelComplete() + { + var levelIndex = (int)PlayerState["current_level"]; + MarkLevelComplete(levelIndex); + AddCoins((int)_currentSessionState["coins_collected"]); + foreach (var s in (Array)_currentSessionState["skills_unlocked"]) + UnlockSkill((SkillData)s); + + ResetCurrentSessionState(); + TryToGoToNextLevel(); + GetNode("/root/SaveSystem").SaveGame(); + } + + public Array GetUnlockedSkills() + { + var unlocked = (Array)PlayerState["unlocked_skills"]; + var session = (Array)_currentSessionState["skills_unlocked"]; + if ((((Array)session)!).Count == 0) return (Array)unlocked; + if ((((Array)unlocked)!).Count == 0) return (Array)session; + var joined = new Array(); + joined.AddRange((Array)unlocked ?? new Array()); + joined.AddRange((Array)session ?? new Array()); + return joined; + } +} \ No newline at end of file diff --git a/Autoloads/SaveSystem.cs b/Autoloads/SaveSystem.cs new file mode 100644 index 0000000..0e47221 --- /dev/null +++ b/Autoloads/SaveSystem.cs @@ -0,0 +1,46 @@ +using Godot; +using Godot.Collections; + +namespace Mr.BrickAdventures.Autoloads; + +public partial class SaveSystem : Node +{ + [Export] public string SavePath { get; set; } = "user://savegame.save"; + [Export] public int Version { get; set; } = 1; + + //private GM _gm; + + public override void _Ready() + { + //_gm = GetNode("/root/GameManager"); + } + + public void SaveGame() + { + //TODO: Implement saving logic + } + + public bool LoadGame() + { + //TODO: Implement loading logic + + if (!FileAccess.FileExists(SavePath)) + return false; + + using var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Read); + var saveDataObj = (Dictionary)file.GetVar(); + + if (saveDataObj.ContainsKey("version") && (int)saveDataObj["version"] != Version) + { + GD.Print($"Save file version mismatch. Expected: {Version}, Found: {saveDataObj["version"]}"); + return false; + } + + GD.Print("Game state loaded from: ", SavePath); + GD.Print("Player state: ", saveDataObj["player_state"]); + + return true; + } + + public bool CheckSaveExists() => FileAccess.FileExists(SavePath); +} \ No newline at end of file diff --git a/Autoloads/UIManager.cs b/Autoloads/UIManager.cs new file mode 100644 index 0000000..cd576be --- /dev/null +++ b/Autoloads/UIManager.cs @@ -0,0 +1,65 @@ +using Godot; +using Godot.Collections; + +namespace Mr.BrickAdventures.Autoloads; + +public partial class UIManager : Node +{ + [Export] public Array UiStack { get; set; } = new(); + + [Signal] public delegate void ScreenPushedEventHandler(Control screen); + [Signal] public delegate void ScreenPoppedEventHandler(Control screen); + + public void PushScreen(Control screen) + { + if (screen == null) + { + GD.PushError($"Cannot push a null screen."); + return; + } + + UiStack.Add(screen); + screen.Show(); + screen.SetProcessInput(true); + screen.SetFocusMode(Control.FocusModeEnum.All); + screen.GrabFocus(); + EmitSignalScreenPushed(screen); + } + + public void PopScreen() + { + if (UiStack.Count == 0) + { + GD.PushError($"Cannot pop screen from an empty stack."); + return; + } + + var top = (Control)UiStack[^1]; + UiStack.RemoveAt(UiStack.Count - 1); + top.Hide(); + top.SetProcessInput(false); + EmitSignalScreenPopped(top); + top.AcceptEvent(); + + if (UiStack.Count > 0) ((Control)UiStack[^1]).GrabFocus(); + } + + 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; + + public void CloseAll() + { + while (UiStack.Count > 0) + PopScreen(); + } + + public static void HideAndDisable(Control screen) + { + screen.Hide(); + screen.SetProcessInput(false); + } + +} \ No newline at end of file diff --git a/Mr. Brick Adventures.csproj b/Mr. Brick Adventures.csproj index 8d43770..b7064e9 100644 --- a/Mr. Brick Adventures.csproj +++ b/Mr. Brick Adventures.csproj @@ -4,4 +4,140 @@ true Mr.BrickAdventures + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/autoloads/ui_manager.gd b/autoloads/ui_manager.gd index 91e1171..a5c8914 100644 --- a/autoloads/ui_manager.gd +++ b/autoloads/ui_manager.gd @@ -51,4 +51,4 @@ func close_all() -> void: func hide_and_disable(screen: Control) -> void: screen.hide() - screen.set_process_input(false) \ No newline at end of file + screen.set_process_input(false) diff --git a/project.godot b/project.godot index 570baac..42f3f74 100644 --- a/project.godot +++ b/project.godot @@ -19,7 +19,7 @@ config/version="in-dev" run/main_scene="uid://cl00e2ocomk3m" config/use_custom_user_dir=true config/custom_user_dir_name="MrBrickAdventures" -config/features=PackedStringArray("4.4", "GL Compatibility") +config/features=PackedStringArray("4.4", "C#", "GL Compatibility") run/max_fps=180 boot_splash/bg_color=Color(0, 0, 0, 1) boot_splash/show_image=false diff --git a/scripts/Resources/CollectableResource.cs b/scripts/Resources/CollectableResource.cs new file mode 100644 index 0000000..88b338b --- /dev/null +++ b/scripts/Resources/CollectableResource.cs @@ -0,0 +1,9 @@ +using Godot; + +namespace Mr.BrickAdventures.scripts.Resources; + +public partial class CollectableResource : Resource +{ + [Export] public Variant Amount { get; set; } = 0.0; + [Export] public CollectableType Type { get; set; } +} \ No newline at end of file diff --git a/scripts/Resources/CollectableType.cs b/scripts/Resources/CollectableType.cs new file mode 100644 index 0000000..840b8b9 --- /dev/null +++ b/scripts/Resources/CollectableType.cs @@ -0,0 +1,8 @@ +namespace Mr.BrickAdventures.scripts.Resources; + +public enum CollectableType +{ + Coin, + Kid, + Health, +} \ No newline at end of file diff --git a/scripts/Resources/SkillData.cs b/scripts/Resources/SkillData.cs new file mode 100644 index 0000000..2204c6f --- /dev/null +++ b/scripts/Resources/SkillData.cs @@ -0,0 +1,19 @@ +using System; +using Godot; +using Godot.Collections; + +namespace Mr.BrickAdventures.scripts.Resources; + +public partial class SkillData : Resource +{ + [Export] public String Name { get; set; } = "New Skill"; + [Export] public String Description { get; set; } = "New Skill"; + [Export] public Dictionary Config { get; set; } = new(); + [Export] public int Cost { get; set; } = 0; + [Export] public Texture2D Icon { get; set; } + [Export] public bool IsActive { get; set; } = false; + [Export] public int Level { get; set; } = 1; + [Export] public int MaxLevel { get; set; } = 1; + [Export] public SkillType Type { get; set; } = SkillType.Throw; + [Export] public PackedScene Node { get; set; } +} \ No newline at end of file diff --git a/scripts/Resources/SkillType.cs b/scripts/Resources/SkillType.cs new file mode 100644 index 0000000..ceb0b4d --- /dev/null +++ b/scripts/Resources/SkillType.cs @@ -0,0 +1,8 @@ +namespace Mr.BrickAdventures.scripts.Resources; + +public enum SkillType +{ + Attack, + Throw, + Misc, +} \ No newline at end of file diff --git a/scripts/Resources/StatusEffectDataResource.cs b/scripts/Resources/StatusEffectDataResource.cs new file mode 100644 index 0000000..31ff410 --- /dev/null +++ b/scripts/Resources/StatusEffectDataResource.cs @@ -0,0 +1,8 @@ +using Godot; + +namespace Mr.BrickAdventures.scripts.Resources; + +public partial class StatusEffectDataResource : Resource +{ + [Export] public StatusEffectType Type { get; set; } +} \ No newline at end of file diff --git a/scripts/Resources/StatusEffectType.cs b/scripts/Resources/StatusEffectType.cs new file mode 100644 index 0000000..7f61f98 --- /dev/null +++ b/scripts/Resources/StatusEffectType.cs @@ -0,0 +1,8 @@ +namespace Mr.BrickAdventures.scripts.Resources; + +public enum StatusEffectType +{ + None, + Fire, + Ice +} \ No newline at end of file diff --git a/scripts/Screenshot.cs b/scripts/Screenshot.cs new file mode 100644 index 0000000..b389dd5 --- /dev/null +++ b/scripts/Screenshot.cs @@ -0,0 +1,15 @@ +using Godot; + +namespace Mr.BrickAdventures.scripts; + +public partial class Screenshot : Node +{ + public override void _Process(double delta) + { + if (!OS.IsDebugBuild() || !Input.IsActionJustPressed("screenshot")) return; + var img = GetViewport().GetTexture().GetImage(); + var id = OS.GetUniqueId() + "_" + Time.GetDatetimeStringFromSystem(); + var path = "user://screenshots/screenshot_" + id + ".png"; + img.SavePng(path); + } +} \ No newline at end of file diff --git a/scripts/SkillManager.cs b/scripts/SkillManager.cs new file mode 100644 index 0000000..ad53727 --- /dev/null +++ b/scripts/SkillManager.cs @@ -0,0 +1,144 @@ +using Godot; +using Godot.Collections; +using Mr.BrickAdventures.Autoloads; +using Mr.BrickAdventures.scripts.Resources; + +namespace Mr.BrickAdventures.scripts; + +public partial class SkillManager : Node +{ + private GameManager _gameManager; + [Export] public Array AvailableSkills { get; set; } = []; + + public Dictionary ActiveComponents { get; private set; } = new(); + + public override void _Ready() + { + _gameManager = GetNode("/root/GameManager"); + ApplyUnlockedSkills(); + } + + public void AddSkill(SkillData skillData) + { + if (ActiveComponents.ContainsKey(skillData.Name)) + return; + + if (skillData.Type == SkillType.Throw) + { + var unlocked = _gameManager.GetUnlockedSkills(); + foreach (var skill in unlocked) + { + SkillData data = null; + foreach (var s in AvailableSkills) + { + if (s == (SkillData)skill) + { + data = s; + break; + } + } + if (data != null && data.Type == SkillType.Throw) + RemoveSkill(data.Name); + } + } + + var instance = skillData.Node.Instantiate(); + foreach (var key in skillData.Config.Keys) + { + if (instance.HasMethod("get")) // rough presence check + { + var value = skillData.Config[key]; + var parent = GetParent(); + + if (value.VariantType == Variant.Type.NodePath) + { + var np = (NodePath)value; + if (parent.HasNode(np)) + value = parent.GetNode(np); + else if (instance.HasNode(np)) + value = instance.GetNode(np); + else + continue; + } + + // Set via property if exists + instance.Set(key, value); + } + } + + Owner.AddChild(instance); + ActiveComponents[skillData.Name] = instance; + } + + public void RemoveSkill(string skillName) + { + if (!ActiveComponents.TryGetValue(skillName, out var component)) + return; + + var inst = (Node)component; + if (IsInstanceValid(inst)) + inst.QueueFree(); + + var skills = _gameManager.GetUnlockedSkills(); + foreach (SkillData s in skills) + { + if (s.Name == skillName) + { + s.IsActive = false; + break; + } + } + ActiveComponents.Remove(skillName); + } + + public void ApplyUnlockedSkills() + { + foreach (var sd in AvailableSkills) + { + if (_gameManager.IsSkillUnlocked(sd)) + { + GD.Print("Applying skill: ", sd.Name); + CallDeferred(MethodName.AddSkill, sd); + } + else + { + RemoveSkill(sd.Name); + } + } + } + + public SkillData GetSkillByName(string skillName) + { + foreach (var sd in AvailableSkills) + if (sd.Name == skillName) return sd; + return null; + } + + 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) + { + if (skill == null) return; + + if (ActiveComponents.ContainsKey(skill.Name)) + DeactivateSkill(skill); + else + ActivateSkill(skill); + } +} \ No newline at end of file diff --git a/scripts/components/.idea/.gitignore b/scripts/components/.idea/.gitignore new file mode 100644 index 0000000..f36cc23 --- /dev/null +++ b/scripts/components/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/.idea.components.iml +/contentModel.xml +/modules.xml +/projectSettingsUpdater.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/scripts/components/.idea/encodings.xml b/scripts/components/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/scripts/components/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/scripts/components/.idea/indexLayout.xml b/scripts/components/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/scripts/components/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/scripts/components/.idea/inspectionProfiles/Project_Default.xml b/scripts/components/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/scripts/components/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/scripts/components/.idea/vcs.xml b/scripts/components/.idea/vcs.xml new file mode 100644 index 0000000..b2bdec2 --- /dev/null +++ b/scripts/components/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/scripts/components/CollectableComponent.cs b/scripts/components/CollectableComponent.cs new file mode 100644 index 0000000..0233570 --- /dev/null +++ b/scripts/components/CollectableComponent.cs @@ -0,0 +1,50 @@ +using System; +using Godot; +using Mr.BrickAdventures.scripts.Resources; + +namespace Mr.BrickAdventures.scripts.components; + +public partial class CollectableComponent : Node +{ + private bool _hasFadeAway = false; + + [Export] public Area2D Area2D { get; set; } + [Export] public CollisionShape2D CollisionShape { get; set; } + [Export] public CollectableResource Data { get; set; } + [Export] public AudioStreamPlayer2D Sfx {get; set; } + + [Signal] public delegate void CollectedEventHandler(Variant amount, CollectableType type, Node2D body); + + public override void _Ready() + { + if (Area2D != null) + Area2D.BodyEntered += OnArea2DBodyEntered; + else + GD.PushError("Collectable node missing Area2D node."); + + if (Owner.HasNode("FadeAwayComponent")) + _hasFadeAway = true; + } + + private async void OnArea2DBodyEntered(Node2D body) + { + try + { + if (!body.HasNode("CanPickUpComponent")) return; + + EmitSignalCollected(Data.Amount, Data.Type, body); + CollisionShape?.CallDeferred("set_disabled", true); + Sfx?.Play(); + + if (_hasFadeAway) return; + + if (Sfx != null) + await ToSignal(Sfx, AudioStreamPlayer2D.SignalName.Finished); + Owner.QueueFree(); + } + catch (Exception e) + { + GD.PushError($"Error in CollectableComponent.OnArea2DBodyEntered: {e.Message}"); + } + } +} \ No newline at end of file diff --git a/scripts/components/DamageComponent.cs b/scripts/components/DamageComponent.cs new file mode 100644 index 0000000..d343ce7 --- /dev/null +++ b/scripts/components/DamageComponent.cs @@ -0,0 +1,105 @@ +using Godot; +using Mr.BrickAdventures.scripts.Resources; + +namespace Mr.BrickAdventures.scripts.components; + +public partial class DamageComponent : Node +{ + [Export] public float Damage { get; set; } = 0.25f; + [Export] public Area2D Area { get; set; } + [Export] public StatusEffectDataResource StatusEffectData { get; set; } + [Export] public Timer DamageTimer { get; set; } + + private Node _currentTarget = null; + + [Signal] public delegate void EffectInflictedEventHandler(Node2D target, StatusEffectDataResource effect); + + public override void _Ready() + { + if (Area == null) + { + GD.PushError($"DamageComponent: Area2D node is not set."); + return; + } + + Area.BodyEntered += OnAreaBodyEntered; + Area.BodyExited += OnAreaBodyExited; + Area.AreaEntered += OnAreaAreaEntered; + + if (DamageTimer != null) + { + DamageTimer.Timeout += OnDamageTimerTimeout; + } + } + + public override void _Process(double delta) + { + if (_currentTarget == null) return; + if (DamageTimer != null) return; + + ProcessEntityAndApplyDamage(_currentTarget as Node2D); + } + + public void DealDamage(HealthComponent target) => target.DecreaseHealth(Damage); + + private void OnAreaAreaEntered(Area2D area) + { + if (!CheckIfProcessingIsOn()) + return; + if (area == Area) return; + + var parent = area.GetParent(); + if (parent.HasNode("DamageComponent")) + ProcessEntityAndApplyDamage(parent as Node2D); + } + + private void OnAreaBodyExited(Node2D body) + { + if (body != _currentTarget) return; + _currentTarget = null; + DamageTimer?.Stop(); + } + + private void OnAreaBodyEntered(Node2D body) + { + _currentTarget = body; + + if (!CheckIfProcessingIsOn()) + return; + + DamageTimer?.Start(); + + ProcessEntityAndApplyDamage(body); + } + + private void OnDamageTimerTimeout() + { + if (_currentTarget == null) return; + + ProcessEntityAndApplyDamage(_currentTarget as Node2D); + } + + private void ProcessEntityAndApplyDamage(Node2D body) + { + if (body == null) return; + + if (!body.HasNode("HealthComponent")) return; + var health = body.GetNode("HealthComponent"); + var inv = body.GetNodeOrNull("InvulnerabilityComponent"); + + if (inv != null && inv.IsInvulnerable()) + return; + + if (StatusEffectData != null && StatusEffectData.Type != StatusEffectType.None) + EmitSignalEffectInflicted(body, StatusEffectData); + + DealDamage(health); + + inv?.Activate(); + } + + private bool CheckIfProcessingIsOn() + { + return ProcessMode is ProcessModeEnum.Inherit or ProcessModeEnum.Always; + } +} \ No newline at end of file diff --git a/scripts/components/FlashingComponent.cs b/scripts/components/FlashingComponent.cs new file mode 100644 index 0000000..7a7db70 --- /dev/null +++ b/scripts/components/FlashingComponent.cs @@ -0,0 +1,83 @@ +using Godot; + +namespace Mr.BrickAdventures.scripts.components; + +public partial class FlashingComponent : Node +{ + [Export] public Node2D Sprite { get; set; } + [Export] public float FlashDuration { get; set; } = 0.5f; + [Export] public float FlashTime { get; set; } = 0.1f; + [Export] public bool UseModulate { get; set; } = true; + [Export] public HealthComponent HealthComponent { get; set; } + + private Tween _tween; + + public override void _Ready() + { + if (HealthComponent != null) + { + HealthComponent.HealthChanged += OnHealthChanged; + HealthComponent.Death += OnDeath; + } + + if (Sprite == null) + { + GD.PushError("FlashingComponent: Sprite node is not set."); + return; + } + } + + public void StartFlashing() + { + if (Sprite == null) return; + + _tween?.Kill(); + + _tween = CreateTween(); + _tween.SetParallel(true); + + var flashes = (int)(FlashDuration / FlashTime); + for (var i = 0; i < flashes; i++) + { + if (UseModulate) + { + var opacity = i % 2 == 0 ? 1.0f : 0.3f; + _tween.TweenProperty(Sprite, "modulate:a", opacity, FlashTime); + } + else + { + var visible = i % 2 == 0; + _tween.TweenProperty(Sprite, "visible", visible, FlashTime); + } + } + + _tween.TweenCallback(Callable.From(StopFlashing)); + } + + public void StopFlashing() + { + if (UseModulate) + { + var modulateColor = Sprite.GetModulate(); + modulateColor.A = 1.0f; + Sprite.SetModulate(modulateColor); + } + else + { + Sprite.SetVisible(true); + } + } + + private void OnHealthChanged(float delta, float totalHealth) + { + if (delta < 0f) + { + StartFlashing(); + } + } + + private void OnDeath() + { + StopFlashing(); + } +} \ No newline at end of file diff --git a/scripts/components/HealthComponent.cs b/scripts/components/HealthComponent.cs new file mode 100644 index 0000000..2473807 --- /dev/null +++ b/scripts/components/HealthComponent.cs @@ -0,0 +1,65 @@ +using System.Threading.Tasks; +using Godot; + +namespace Mr.BrickAdventures.scripts.components; + +public partial class HealthComponent : Node +{ + [Export] public float Health { get; set; } = 1.0f; + [Export] public float MaxHealth { get; set; } = 1.0f; + [Export] public AudioStreamPlayer2D HurtSfx { get; set; } + [Export] public AudioStreamPlayer2D HealSfx { get; set; } + + [Signal] public delegate void HealthChangedEventHandler(float delta, float totalHealth); + [Signal] public delegate void DeathEventHandler(); + + public void SetHealth(float newValue) + { + _ = ApplyHealthChange(newValue); + } + + public void IncreaseHealth(float delta) + { + _ = ApplyHealthChange(Health + delta); + } + + public void DecreaseHealth(float delta) + { + _ = ApplyHealthChange(Health - delta); + } + + public float GetDelta(float newValue) => newValue - Health; + + private async Task ApplyHealthChange(float newHealth, bool playSfx = true) + { + newHealth = Mathf.Clamp(newHealth, 0.0f, MaxHealth); + var delta = newHealth - Health; + + if (delta == 0.0f) + return; + + if (playSfx) + { + if (delta > 0f && HealSfx != null) + { + HealSfx.Play(); + } + else if (delta < 0f && HurtSfx != null) + { + HurtSfx.Play(); + await HurtSfx.ToSignal(HurtSfx, AudioStreamPlayer2D.SignalName.Finished); + } + } + + Health = newHealth; + + if (Health <= 0f) + { + EmitSignalDeath(); + } + else + { + EmitSignalHealthChanged(delta, Health); + } + } +} \ No newline at end of file diff --git a/scripts/components/InvulnerabilityComponent.cs b/scripts/components/InvulnerabilityComponent.cs new file mode 100644 index 0000000..d69c63d --- /dev/null +++ b/scripts/components/InvulnerabilityComponent.cs @@ -0,0 +1,31 @@ +using Godot; + +namespace Mr.BrickAdventures.scripts.components; + +public partial class InvulnerabilityComponent : Node +{ + [Export] public float Duration { get; set; } = 1f; + [Export] public FlashingComponent FlashingComponent { get; set; } + + private bool _isInvulnerable = false; + + public void Activate() + { + if (_isInvulnerable) + return; + + _isInvulnerable = true; + FlashingComponent?.StartFlashing(); + + var timer = GetTree().CreateTimer(Duration); + timer.Timeout += Deactivate; + } + + private void Deactivate() + { + _isInvulnerable = false; + FlashingComponent?.StopFlashing(); + } + + public bool IsInvulnerable() => _isInvulnerable; +} \ No newline at end of file diff --git a/scripts/components/PlatformMovement.cs b/scripts/components/PlatformMovement.cs new file mode 100644 index 0000000..6775850 --- /dev/null +++ b/scripts/components/PlatformMovement.cs @@ -0,0 +1,169 @@ +using Godot; +using Mr.BrickAdventures.scripts.interfaces; + +namespace Mr.BrickAdventures.scripts.components; + +public partial class PlatformMovement : Node2D, IMovement +{ + [Export] + public float Speed { get; set; } = 300.0f; + + [Export] + public float JumpHeight { get; set; } = 100f; + + [Export] + public float JumpTimeToPeak { get; set; } = 0.5f; + + [Export] + public float JumpTimeToDescent { get; set; } = 0.4f; + + [Export] + public int CoyoteFrames { get; set; } = 6; + + [Export] + public AudioStreamPlayer2D JumpSfx { get; set; } + + [Export] + public Node2D RotationTarget { get; set; } + + [Export] + public CharacterBody2D Body { get; set; } + + private float _gravity; + private bool _wasLastFloor = false; + private bool _coyoteMode = false; + private Timer _coyoteTimer; + private Vector2 _lastDirection = new Vector2(1, 0); + + private float _jumpVelocity; + private float _jumpGravity; + private float _fallGravity; + + public override void _Ready() + { + base._Ready(); + + if (Body == null) + return; + + _gravity = (float)ProjectSettings.GetSetting("physics/2d/default_gravity"); + _jumpVelocity = ((2.0f * JumpHeight) / JumpTimeToPeak) * -1.0f; + _jumpGravity = ((-2.0f * JumpHeight) / (JumpTimeToPeak * JumpTimeToPeak)) * -1.0f; + _fallGravity = ((-2.0f * JumpHeight) / (JumpTimeToDescent * JumpTimeToDescent)) * -1.0f; + + _coyoteTimer = new Timer + { + OneShot = true, + WaitTime = CoyoteFrames / 60.0f + }; + _coyoteTimer.Timeout += OnCoyoteTimerTimeout; + AddChild(_coyoteTimer); + } + + public string MovementType { get; } = "platform"; + public bool Enabled { get; set; } + public Vector2 PreviousVelocity { get; set; } + + public override void _Process(double delta) + { + base._Process(delta); + + if (Body == null || !Enabled) + return; + + if (Body.Velocity.X > 0.0f) + RotationTarget.Rotation = Mathf.DegToRad(-10); + else if (Body.Velocity.X < 0.0f) + RotationTarget.Rotation = Mathf.DegToRad(10); + else + RotationTarget.Rotation = 0; + + CalculateJumpVars(); + } + + public override void _PhysicsProcess(double delta) + { + base._PhysicsProcess(delta); + + if (Body == null || !Enabled) + return; + + if (Body.IsOnFloor()) + { + _wasLastFloor = true; + _coyoteMode = false; // Reset coyote mode when back on the floor + _coyoteTimer.Stop(); // Stop timer when grounded + } + else + { + if (_wasLastFloor) // Start coyote timer only once + { + _coyoteMode = true; + _coyoteTimer.Start(); + } + _wasLastFloor = false; + } + + if (!Body.IsOnFloor()) + Body.Velocity += new Vector2(0, CalculateGravity()) * (float)delta; + + if (Input.IsActionPressed("jump") && (Body.IsOnFloor() || _coyoteMode)) + Jump(); + + if (Input.IsActionJustPressed("down")) + Body.Position += new Vector2(0, 1); + + float direction = Input.GetAxis("left", "right"); + if (direction != 0) + _lastDirection = HandleDirection(direction); + + if (direction != 0) + Body.Velocity = new Vector2(direction * Speed, Body.Velocity.Y); + else + Body.Velocity = new Vector2(Mathf.MoveToward(Body.Velocity.X, 0, Speed), Body.Velocity.Y); + + Body.MoveAndSlide(); + } + + private void Jump() + { + if (Body == null) + return; + + Body.Velocity = new Vector2(Body.Velocity.X, _jumpVelocity); + _coyoteMode = false; + if (JumpSfx != null) + JumpSfx.Play(); + } + + private float CalculateGravity() + { + return Body.Velocity.Y < 0.0f ? _jumpGravity : _fallGravity; + } + + private void OnCoyoteTimerTimeout() + { + _coyoteMode = false; + } + + private Vector2 HandleDirection(float inputDir) + { + if (inputDir > 0) + return new Vector2(1, 0); + else if (inputDir < 0) + return new Vector2(-1, 0); + return _lastDirection; + } + + public void OnShipEntered() + { + RotationTarget.Rotation = 0; + } + + private void CalculateJumpVars() + { + _jumpVelocity = ((2.0f * JumpHeight) / JumpTimeToPeak) * -1.0f; + _jumpGravity = ((-2.0f * JumpHeight) / (JumpTimeToPeak * JumpTimeToPeak)) * -1.0f; + _fallGravity = ((-2.0f * JumpHeight) / (JumpTimeToDescent * JumpTimeToDescent)) * -1.0f; + } +} \ No newline at end of file diff --git a/scripts/components/PlatformMovement.cs.uid b/scripts/components/PlatformMovement.cs.uid new file mode 100644 index 0000000..6a641b5 --- /dev/null +++ b/scripts/components/PlatformMovement.cs.uid @@ -0,0 +1 @@ +uid://btlm1f3l70il diff --git a/scripts/components/PlayerController.cs b/scripts/components/PlayerController.cs new file mode 100644 index 0000000..1f2bf5b --- /dev/null +++ b/scripts/components/PlayerController.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using Godot; +using Mr.BrickAdventures.scripts.interfaces; + +namespace Mr.BrickAdventures.scripts.components; + +public partial class PlayerController : Node2D +{ + [Export] + public string DefaultMovementType { get; set; } = "platform"; + + [Export] + public Godot.Collections.Dictionary MovementTypes { get; set; } + + [Export] + public Sprite2D ShipSprite { get; set; } + + private IMovement _currentMovement = null; + [Signal] + public delegate void MovementSwitchedEventHandler(string movementType); + + public override void _Ready() + { + base._Ready(); + + foreach (var movementType in MovementTypes.Keys) + { + var movementNode = GetNodeOrNull(movementType); + if (movementNode is IMovement playerMovement) + { + playerMovement.Enabled = false; + } + } + + SwitchMovement(DefaultMovementType); + } + + public override void _UnhandledInput(InputEvent @event) + { + base._UnhandledInput(@event); + + if (@event is InputEventKey inputEventKey && inputEventKey.IsActionPressed("switch_movement")) + { + var nextMovementType = GetNextMovementType(); + SwitchMovement(nextMovementType); + } + } + + private void SwitchMovement(string movementType) + { + if (_currentMovement != null) + { + _currentMovement.Enabled = false; + } + + if (MovementTypes.TryGetValue(movementType, out var movement)) + { + _currentMovement = GetNodeOrNull(movement); + if (_currentMovement == null) + { + GD.PushError($"Movement type '{movementType}' not found in MovementTypes."); + return; + } + _currentMovement.Enabled = true; + EmitSignalMovementSwitched(movementType); + } + else + { + GD.PushError($"Movement type '{movementType}' not found in MovementTypes."); + } + + if (_currentMovement == null) + { + GD.PushError("No current movement set after switching."); + } + } + + private string GetNextMovementType() + { + var keys = new List(MovementTypes.Keys); + var currentIndex = keys.IndexOf(_currentMovement?.MovementType); + + if (currentIndex == -1) + { + return DefaultMovementType; + } + + currentIndex = (currentIndex + 1) % keys.Count; + return keys[currentIndex]; + } + + public void OnSpaceshipEntered() + { + SwitchMovement("ship"); + ShipSprite.Visible = true; + } + + public void OnSpaceshipExited() + { + SwitchMovement(DefaultMovementType); + ShipSprite.Visible = false; + } +} \ No newline at end of file diff --git a/scripts/components/PlayerController.cs.uid b/scripts/components/PlayerController.cs.uid new file mode 100644 index 0000000..b30ccfe --- /dev/null +++ b/scripts/components/PlayerController.cs.uid @@ -0,0 +1 @@ +uid://csel4s0e4g5uf diff --git a/scripts/interfaces/IMovement.cs b/scripts/interfaces/IMovement.cs new file mode 100644 index 0000000..ece7b69 --- /dev/null +++ b/scripts/interfaces/IMovement.cs @@ -0,0 +1,13 @@ +using Godot; + +namespace Mr.BrickAdventures.scripts.interfaces; + +public interface IMovement +{ + string MovementType { get; } + bool Enabled { get; set; } + Vector2 PreviousVelocity { get; set; } + + void _Process(double delta); + void _PhysicsProcess(double delta); +} \ No newline at end of file diff --git a/scripts/interfaces/IMovement.cs.uid b/scripts/interfaces/IMovement.cs.uid new file mode 100644 index 0000000..9383ca5 --- /dev/null +++ b/scripts/interfaces/IMovement.cs.uid @@ -0,0 +1 @@ +uid://bt2g2im63o8fh