From e0e191dc6b4fd117f32b620be84c3c979b5a2ba3 Mon Sep 17 00:00:00 2001 From: "Nuno \"Omega Leo\" Diogo" Date: Tue, 9 Dec 2025 22:34:26 +0000 Subject: [PATCH] Added new Game logic library to assist with game projects --- .../Extensions/ListExtensions.cs | 17 ++ .../Handlers/DelayedActionHandler.cs | 22 ++ .../Interfaces/ISaveFileProvider.cs | 8 + .../Models/ConsoleCommand.cs | 22 ++ .../Models/DelayedAction.cs | 22 ++ OmegaLeo.HelperLib.Game/Models/Loot.cs | 15 ++ OmegaLeo.HelperLib.Game/Models/LootTable.cs | 30 +++ OmegaLeo.HelperLib.Game/Models/RNG.cs | 35 +++ OmegaLeo.HelperLib.Game/Models/SaveData.cs | 15 ++ .../OmegaLeo.HelperLib.Game.csproj | 16 ++ .../Providers/GodotDelayedActionRunner.cs | 33 +++ .../Providers/GodotSaveFileProvider.cs | 30 +++ .../Providers/UnityDelayedActionRunner.cs | 41 ++++ .../Providers/UnitySaveFileProvider.cs | 33 +++ .../Services/CommandConsole.cs | 200 ++++++++++++++++++ .../Services/DelayedActionManager.cs | 73 +++++++ .../Services/SaveManager.cs | 88 ++++++++ OmegaLeo.HelperLib.Game/UI/GodotConsoleUI.cs | 153 ++++++++++++++ OmegaLeo.HelperLib.Game/UI/UnityConsoleUI.cs | 148 +++++++++++++ .../Utilities/WeightedPicker.cs | 26 +++ .../OmegaLeo.HelperLib.Web.csproj | 19 ++ OmegaLeo.HelperLib.sln | 12 ++ OmegaLeo.HelperLib.sln.DotSettings.user | 1 + .../Helpers/BenchmarkUtility.cs | 7 +- OmegaLeo.HelperLib/Models/NeoDictionary.cs | 108 ++++++++++ 25 files changed, 1169 insertions(+), 5 deletions(-) create mode 100644 OmegaLeo.HelperLib.Game/Extensions/ListExtensions.cs create mode 100644 OmegaLeo.HelperLib.Game/Handlers/DelayedActionHandler.cs create mode 100644 OmegaLeo.HelperLib.Game/Interfaces/ISaveFileProvider.cs create mode 100644 OmegaLeo.HelperLib.Game/Models/ConsoleCommand.cs create mode 100644 OmegaLeo.HelperLib.Game/Models/DelayedAction.cs create mode 100644 OmegaLeo.HelperLib.Game/Models/Loot.cs create mode 100644 OmegaLeo.HelperLib.Game/Models/LootTable.cs create mode 100644 OmegaLeo.HelperLib.Game/Models/RNG.cs create mode 100644 OmegaLeo.HelperLib.Game/Models/SaveData.cs create mode 100644 OmegaLeo.HelperLib.Game/OmegaLeo.HelperLib.Game.csproj create mode 100644 OmegaLeo.HelperLib.Game/Providers/GodotDelayedActionRunner.cs create mode 100644 OmegaLeo.HelperLib.Game/Providers/GodotSaveFileProvider.cs create mode 100644 OmegaLeo.HelperLib.Game/Providers/UnityDelayedActionRunner.cs create mode 100644 OmegaLeo.HelperLib.Game/Providers/UnitySaveFileProvider.cs create mode 100644 OmegaLeo.HelperLib.Game/Services/CommandConsole.cs create mode 100644 OmegaLeo.HelperLib.Game/Services/DelayedActionManager.cs create mode 100644 OmegaLeo.HelperLib.Game/Services/SaveManager.cs create mode 100644 OmegaLeo.HelperLib.Game/UI/GodotConsoleUI.cs create mode 100644 OmegaLeo.HelperLib.Game/UI/UnityConsoleUI.cs create mode 100644 OmegaLeo.HelperLib.Game/Utilities/WeightedPicker.cs create mode 100644 OmegaLeo.HelperLib.Web/OmegaLeo.HelperLib.Web.csproj diff --git a/OmegaLeo.HelperLib.Game/Extensions/ListExtensions.cs b/OmegaLeo.HelperLib.Game/Extensions/ListExtensions.cs new file mode 100644 index 0000000..6e90296 --- /dev/null +++ b/OmegaLeo.HelperLib.Game/Extensions/ListExtensions.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using OmegaLeo.HelperLib.Game.Models; + +namespace OmegaLeo.HelperLib.Game.Extensions +{ + public static class ListExtensions + { + public static void Shuffle(this IList list, RandomNumberGenerator rng) + { + for (int i = list.Count - 1; i > 0; i--) + { + int j = rng.Range(0, i + 1); + (list[i], list[j]) = (list[j], list[i]); + } + } + } +} \ No newline at end of file diff --git a/OmegaLeo.HelperLib.Game/Handlers/DelayedActionHandler.cs b/OmegaLeo.HelperLib.Game/Handlers/DelayedActionHandler.cs new file mode 100644 index 0000000..eb5b4b3 --- /dev/null +++ b/OmegaLeo.HelperLib.Game/Handlers/DelayedActionHandler.cs @@ -0,0 +1,22 @@ +using OmegaLeo.HelperLib.Game.Models; + +namespace OmegaLeo.HelperLib.Game.Handlers +{ + public class DelayedActionHandler + { + private readonly DelayedAction _action; + + internal DelayedActionHandler(DelayedAction action) + { + _action = action; + } + + public void Cancel() + { + if (_action != null) + _action.IsCancelled = true; + } + + public bool IsCancelled => _action?.IsCancelled ?? true; + } +} \ No newline at end of file diff --git a/OmegaLeo.HelperLib.Game/Interfaces/ISaveFileProvider.cs b/OmegaLeo.HelperLib.Game/Interfaces/ISaveFileProvider.cs new file mode 100644 index 0000000..e43b89d --- /dev/null +++ b/OmegaLeo.HelperLib.Game/Interfaces/ISaveFileProvider.cs @@ -0,0 +1,8 @@ +namespace OmegaLeo.HelperLib.Game.Interfaces +{ + public interface ISaveFileProvider + { + string GetSavePath(string fileName); + string GetSaveDirectory(); + } +} \ No newline at end of file diff --git a/OmegaLeo.HelperLib.Game/Models/ConsoleCommand.cs b/OmegaLeo.HelperLib.Game/Models/ConsoleCommand.cs new file mode 100644 index 0000000..59f77e4 --- /dev/null +++ b/OmegaLeo.HelperLib.Game/Models/ConsoleCommand.cs @@ -0,0 +1,22 @@ +using System; + +namespace OmegaLeo.HelperLib.Game.Models +{ + public class ConsoleCommand + { + public string Name { get; } + public string Description { get; } + public Action Action { get; } + public int MinArgs { get; } + public int MaxArgs { get; } + + public ConsoleCommand(string name, string description, Action action, int minArgs = 0, int maxArgs = -1) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Description = description ?? "No description"; + Action = action ?? throw new ArgumentNullException(nameof(action)); + MinArgs = minArgs; + MaxArgs = maxArgs; + } + } +} \ No newline at end of file diff --git a/OmegaLeo.HelperLib.Game/Models/DelayedAction.cs b/OmegaLeo.HelperLib.Game/Models/DelayedAction.cs new file mode 100644 index 0000000..ed93b40 --- /dev/null +++ b/OmegaLeo.HelperLib.Game/Models/DelayedAction.cs @@ -0,0 +1,22 @@ +using System; + +namespace OmegaLeo.HelperLib.Game.Models +{ + internal class DelayedAction + { + public float TimeRemaining { get; set; } + public float InitialDelay { get; } + public Action Action { get; } + public bool IsRepeating { get; } + public bool IsCancelled { get; set; } + + public DelayedAction(float delay, Action action, bool isRepeating) + { + TimeRemaining = delay; + InitialDelay = delay; + Action = action; + IsRepeating = isRepeating; + IsCancelled = false; + } + } +} \ No newline at end of file diff --git a/OmegaLeo.HelperLib.Game/Models/Loot.cs b/OmegaLeo.HelperLib.Game/Models/Loot.cs new file mode 100644 index 0000000..8d2ed62 --- /dev/null +++ b/OmegaLeo.HelperLib.Game/Models/Loot.cs @@ -0,0 +1,15 @@ +namespace OmegaLeo.HelperLib.Game.Models +{ + [System.Serializable] + public class Loot + { + public T Item { get; set; } + public float Weight { get; set; } + + public Loot(T item, float quantity) + { + Item = item; + Weight = quantity; + } + } +} \ No newline at end of file diff --git a/OmegaLeo.HelperLib.Game/Models/LootTable.cs b/OmegaLeo.HelperLib.Game/Models/LootTable.cs new file mode 100644 index 0000000..3e1a957 --- /dev/null +++ b/OmegaLeo.HelperLib.Game/Models/LootTable.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Linq; + +namespace OmegaLeo.HelperLib.Game.Models +{ + public class LootTable + { + public List> Loot = new List>(); + + public void Add(T item, float weight) + { + Loot.Add(new Loot(item, weight)); + } + + public T Roll(RandomNumberGenerator rng) + { + float total = Loot.Sum(e => e.Weight); + float roll = rng.Range(0f, total); + + foreach (var e in Loot) + { + roll -= e.Weight; + if (roll <= 0) + return e.Item; + } + + return Loot.Last().Item; + } + } +} \ No newline at end of file diff --git a/OmegaLeo.HelperLib.Game/Models/RNG.cs b/OmegaLeo.HelperLib.Game/Models/RNG.cs new file mode 100644 index 0000000..8ae32d0 --- /dev/null +++ b/OmegaLeo.HelperLib.Game/Models/RNG.cs @@ -0,0 +1,35 @@ +using System; + +namespace OmegaLeo.HelperLib.Game.Models +{ + public class RandomNumberGenerator + { + private readonly Random _random; + + public int Seed { get; } + + public RandomNumberGenerator(int? seed = null) + { + Seed = seed ?? Environment.TickCount; + _random = new Random(Seed); + } + + public float NextFloat() => + (float)_random.NextDouble(); + + public float Range(float min, float max) => + min + NextFloat() * (max - min); + + public int Range(int min, int max) => + _random.Next(min, max); + + public bool Chance(float probability) => + NextFloat() <= probability; + + public bool Percent(float percent) => + Chance(percent / 100f); + + public bool OneIn(int x) => + Range(0, x) == 0; + } +} \ No newline at end of file diff --git a/OmegaLeo.HelperLib.Game/Models/SaveData.cs b/OmegaLeo.HelperLib.Game/Models/SaveData.cs new file mode 100644 index 0000000..bbe9eb5 --- /dev/null +++ b/OmegaLeo.HelperLib.Game/Models/SaveData.cs @@ -0,0 +1,15 @@ +using System; + +namespace OmegaLeo.HelperLib.Game.Models +{ + [Serializable] + public class SaveData + { + public Guid Id; + + public SaveData() + { + Id = Guid.NewGuid(); + } + } +} \ No newline at end of file diff --git a/OmegaLeo.HelperLib.Game/OmegaLeo.HelperLib.Game.csproj b/OmegaLeo.HelperLib.Game/OmegaLeo.HelperLib.Game.csproj new file mode 100644 index 0000000..e99046c --- /dev/null +++ b/OmegaLeo.HelperLib.Game/OmegaLeo.HelperLib.Game.csproj @@ -0,0 +1,16 @@ + + + + netstandard2.1 + enable + + + + + + + + + + + diff --git a/OmegaLeo.HelperLib.Game/Providers/GodotDelayedActionRunner.cs b/OmegaLeo.HelperLib.Game/Providers/GodotDelayedActionRunner.cs new file mode 100644 index 0000000..00f7152 --- /dev/null +++ b/OmegaLeo.HelperLib.Game/Providers/GodotDelayedActionRunner.cs @@ -0,0 +1,33 @@ +namespace OmegaLeo.HelperLib.Game.Providers +{ + #if GODOT + public partial class GodotDelayedActionRunner : Node + { + private static GodotDelayedActionRunner _instance; + public static DelayedActionManager Manager { get; private set; } + + public override void _Ready() + { + if (_instance == null) + { + _instance = this; + Manager = new DelayedActionManager(); + } + } + + public override void _Process(double delta) + { + Manager?.Update((float)delta); + } + + public override void _ExitTree() + { + if (_instance == this) + { + Manager?.CancelAll(); + _instance = null; + } + } + } + #endif +} \ No newline at end of file diff --git a/OmegaLeo.HelperLib.Game/Providers/GodotSaveFileProvider.cs b/OmegaLeo.HelperLib.Game/Providers/GodotSaveFileProvider.cs new file mode 100644 index 0000000..6abd179 --- /dev/null +++ b/OmegaLeo.HelperLib.Game/Providers/GodotSaveFileProvider.cs @@ -0,0 +1,30 @@ +using System.IO; +using OmegaLeo.HelperLib.Game.Interfaces; + +namespace OmegaLeo.HelperLib.Game.Providers +{ +#if GODOT + public class GodotSaveFileProvider : ISaveFileProvider + { + private readonly string _saveFolder; + + public GodotSaveFileProvider(string saveFolder = "Saves") + { + _saveFolder = saveFolder; + } + + public string GetSaveDirectory() + { + return Path.Combine("user://", _saveFolder); + } + + public string GetSavePath(string fileName) + { + if (!fileName.EndsWith(".json")) + fileName += ".json"; + + return Path.Combine(GetSaveDirectory(), fileName); + } + } +#endif +} \ No newline at end of file diff --git a/OmegaLeo.HelperLib.Game/Providers/UnityDelayedActionRunner.cs b/OmegaLeo.HelperLib.Game/Providers/UnityDelayedActionRunner.cs new file mode 100644 index 0000000..b9ffb11 --- /dev/null +++ b/OmegaLeo.HelperLib.Game/Providers/UnityDelayedActionRunner.cs @@ -0,0 +1,41 @@ +namespace OmegaLeo.HelperLib.Game.Providers +{ +#if UNITY_EDITOR || UNITY_STANDALONE +using UnityEngine; + +namespace OmegaLeo.HelperLib.DelayedActions +{ + public class UnityDelayedActionRunner : MonoBehaviour + { + private static UnityDelayedActionRunner _instance; + public static DelayedActionManager Manager { get; private set; } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + private static void Initialize() + { + if (_instance == null) + { + var go = new GameObject("[DelayedActionRunner]"); + _instance = go.AddComponent(); + DontDestroyOnLoad(go); + Manager = new DelayedActionManager(); + } + } + + private void Update() + { + Manager?.Update(Time.deltaTime); + } + + private void OnDestroy() + { + if (_instance == this) + { + Manager?.CancelAll(); + _instance = null; + } + } + } +} +#endif +} \ No newline at end of file diff --git a/OmegaLeo.HelperLib.Game/Providers/UnitySaveFileProvider.cs b/OmegaLeo.HelperLib.Game/Providers/UnitySaveFileProvider.cs new file mode 100644 index 0000000..7cbdb59 --- /dev/null +++ b/OmegaLeo.HelperLib.Game/Providers/UnitySaveFileProvider.cs @@ -0,0 +1,33 @@ +namespace OmegaLeo.HelperLib.Game.Providers +{ +#if UNITY_EDITOR || UNITY_STANDALONE + using System.IO; + using UnityEngine; + + namespace OmegaLeo.HelperLib.SaveSystem + { + public class UnitySaveFileProvider : ISaveFileProvider + { + private readonly string _saveFolder; + + public UnitySaveFileProvider(string saveFolder = "Saves") + { + _saveFolder = saveFolder; + } + + public string GetSaveDirectory() + { + return Path.Combine(Application.persistentDataPath, _saveFolder); + } + + public string GetSavePath(string fileName) + { + if (!fileName.EndsWith(".json")) + fileName += ".json"; + + return Path.Combine(GetSaveDirectory(), fileName); + } + } + } +#endif +} \ No newline at end of file diff --git a/OmegaLeo.HelperLib.Game/Services/CommandConsole.cs b/OmegaLeo.HelperLib.Game/Services/CommandConsole.cs new file mode 100644 index 0000000..b3f4ca3 --- /dev/null +++ b/OmegaLeo.HelperLib.Game/Services/CommandConsole.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NetFlow.DocumentationHelper.Library.Attributes; +using OmegaLeo.HelperLib.Game.Models; + +namespace OmegaLeo.HelperLib.Game.Services +{ + public class CommandConsole + { + private readonly Dictionary _commands = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly List _history = new List(); + private int _maxHistorySize = 100; + + public event Action OnOutput; + public event Action OnError; + + public IReadOnlyList History => _history.AsReadOnly(); + public IReadOnlyDictionary Commands => _commands; + + [Documentation(nameof(RegisterCommand), "Registers a new command with the console.")] + public void RegisterCommand(string name, string description, Action action, int minArgs = 0, int maxArgs = -1) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Command name cannot be empty", nameof(name)); + + var command = new ConsoleCommand(name, description, action, minArgs, maxArgs); + _commands[name] = command; + } + + [Documentation(nameof(RegisterCommand), "Registers a new command with the console using a delegate.")] + public void RegisterCommand(string name, string description, Func func, int minArgs = 0, int maxArgs = -1) + { + RegisterCommand(name, description, args => + { + string result = func(args); + if (!string.IsNullOrEmpty(result)) + Output(result); + }, minArgs, maxArgs); + } + + [Documentation(nameof(UnregisterCommand), "Removes a command from the console.")] + public bool UnregisterCommand(string name) + { + return _commands.Remove(name); + } + + [Documentation(nameof(ExecuteCommand), "Executes a command string.")] + public bool ExecuteCommand(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return false; + + _history.Add(input); + if (_history.Count > _maxHistorySize) + _history.RemoveAt(0); + + string[] parts = ParseCommandLine(input); + if (parts.Length == 0) + return false; + + string commandName = parts[0]; + string[] args = parts.Skip(1).ToArray(); + + if (!_commands.TryGetValue(commandName, out var command)) + { + Error($"Unknown command: {commandName}"); + return false; + } + + if (command.MinArgs > 0 && args.Length < command.MinArgs) + { + Error($"Command '{commandName}' requires at least {command.MinArgs} argument(s)"); + return false; + } + + if (command.MaxArgs >= 0 && args.Length > command.MaxArgs) + { + Error($"Command '{commandName}' accepts at most {command.MaxArgs} argument(s)"); + return false; + } + + try + { + command.Action(args); + return true; + } + catch (Exception ex) + { + Error($"Error executing command '{commandName}': {ex.Message}"); + return false; + } + } + + [Documentation(nameof(Output), "Outputs a message to the console.")] + public void Output(string message) + { + OnOutput?.Invoke(message); + } + + [Documentation(nameof(Error), "Outputs an error message to the console.")] + public void Error(string message) + { + OnError?.Invoke(message); + } + + [Documentation(nameof(ClearHistory), "Clears command history.")] + public void ClearHistory() + { + _history.Clear(); + } + + [Documentation(nameof(GetAutoComplete), "Gets autocomplete suggestions for partial input.")] + public string[] GetAutoComplete(string partial) + { + if (string.IsNullOrWhiteSpace(partial)) + return _commands.Keys.ToArray(); + + return _commands.Keys + .Where(cmd => cmd.StartsWith(partial, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + } + + private string[] ParseCommandLine(string input) + { + var result = new List(); + bool inQuotes = false; + string current = ""; + + for (int i = 0; i < input.Length; i++) + { + char c = input[i]; + + if (c == '"') + { + inQuotes = !inQuotes; + } + else if (char.IsWhiteSpace(c) && !inQuotes) + { + if (current.Length > 0) + { + result.Add(current); + current = ""; + } + } + else + { + current += c; + } + } + + if (current.Length > 0) + result.Add(current); + + return result.ToArray(); + } + + [Documentation(nameof(RegisterDefaultCommands), "Registers built-in commands.")] + public void RegisterDefaultCommands() + { + RegisterCommand("help", "Lists all available commands", args => + { + if (args.Length == 0) + { + Output("Available commands:"); + foreach (var cmd in _commands.Values.OrderBy(c => c.Name)) + { + Output($" {cmd.Name} - {cmd.Description}"); + } + } + else + { + string cmdName = args[0]; + if (_commands.TryGetValue(cmdName, out var cmd)) + { + Output($"{cmd.Name}: {cmd.Description}"); + Output($"Min args: {cmd.MinArgs}, Max args: {(cmd.MaxArgs >= 0 ? cmd.MaxArgs.ToString() : "unlimited")}"); + } + else + { + Error($"Unknown command: {cmdName}"); + } + } + }, 0, 1); + + RegisterCommand("clear", "Clears command history", _ => ClearHistory()); + + RegisterCommand("echo", "Echoes back the input", args => + { + Output(string.Join(" ", args)); + }, 1); + + RegisterCommand("commands", "Lists all registered commands", _ => + { + Output($"Total commands: {_commands.Count}"); + Output(string.Join(", ", _commands.Keys.OrderBy(k => k))); + }); + } + } +} \ No newline at end of file diff --git a/OmegaLeo.HelperLib.Game/Services/DelayedActionManager.cs b/OmegaLeo.HelperLib.Game/Services/DelayedActionManager.cs new file mode 100644 index 0000000..dcb1f0d --- /dev/null +++ b/OmegaLeo.HelperLib.Game/Services/DelayedActionManager.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using NetFlow.DocumentationHelper.Library.Attributes; +using OmegaLeo.HelperLib.Game.Handlers; +using OmegaLeo.HelperLib.Game.Models; + +namespace OmegaLeo.HelperLib.Game.Services +{ + public class DelayedActionManager + { + private readonly List _pendingActions = new List(); + private readonly List _actionsToRemove = new List(); + + [Documentation(nameof(RunAfter), "Schedules an action to run after a specified delay.")] + public void RunAfter(float delaySeconds, Action action) + { + _pendingActions.Add(new DelayedAction(delaySeconds, action, false)); + } + + [Documentation(nameof(RunRepeating), "Schedules an action to run repeatedly at specified intervals.")] + public void RunRepeating(float intervalSeconds, Action action) + { + _pendingActions.Add(new DelayedAction(intervalSeconds, action, true)); + } + + [Documentation(nameof(RunAfterCancellable), "Schedules an action to run after a delay and returns a handler to cancel it if needed.")] + public DelayedActionHandler RunAfterCancellable(float delaySeconds, Action action) + { + var delayedAction = new DelayedAction(delaySeconds, action, false); + _pendingActions.Add(delayedAction); + return new DelayedActionHandler(delayedAction); + } + + public void Update(float deltaTime) + { + _actionsToRemove.Clear(); + + foreach (var action in _pendingActions) + { + action.TimeRemaining -= deltaTime; + + if (action.TimeRemaining <= 0) + { + if (!action.IsCancelled) + action.Action?.Invoke(); + + if (action.IsRepeating && !action.IsCancelled) + { + action.TimeRemaining = action.InitialDelay; + } + else + { + _actionsToRemove.Add(action); + } + } + } + + foreach (var action in _actionsToRemove) + { + _pendingActions.Remove(action); + } + } + + [Documentation(nameof(CancelAll), "Cancels all pending delayed actions.")] + public void CancelAll() + { + _pendingActions.Clear(); + } + + [Documentation(nameof(PendingActionsCount), "Gets the count of pending delayed actions.")] + public int PendingActionsCount => _pendingActions.Count; + } +} \ No newline at end of file diff --git a/OmegaLeo.HelperLib.Game/Services/SaveManager.cs b/OmegaLeo.HelperLib.Game/Services/SaveManager.cs new file mode 100644 index 0000000..5dc6edf --- /dev/null +++ b/OmegaLeo.HelperLib.Game/Services/SaveManager.cs @@ -0,0 +1,88 @@ +using System; +using System.IO; +using NetFlow.DocumentationHelper.Library.Attributes; +using Newtonsoft.Json; +using OmegaLeo.HelperLib.Game.Interfaces; + +namespace OmegaLeo.HelperLib.Game.Services +{ + public class SaveManager + { + private readonly ISaveFileProvider _fileProvider; + private readonly JsonSerializerSettings _jsonOptions; + + public SaveManager(ISaveFileProvider fileProvider) + { + _fileProvider = fileProvider ?? throw new ArgumentNullException(nameof(fileProvider)); + _jsonOptions = new JsonSerializerSettings + { + TypeNameHandling = TypeNameHandling.Auto, + Formatting = Formatting.Indented + }; + } + + [Documentation(nameof(Save), "Saves data to a JSON file.")] + public void Save(string fileName, T data) + { + string json = JsonConvert.SerializeObject(data, _jsonOptions); + string path = _fileProvider.GetSavePath(fileName); + string directory = Path.GetDirectoryName(path); + + if (!Directory.Exists(directory)) + Directory.CreateDirectory(directory); + + File.WriteAllText(path, json); + } + + [Documentation(nameof(Load), "Loads data from a JSON file.")] + public T Load(string fileName) + { + string path = _fileProvider.GetSavePath(fileName); + + if (!File.Exists(path)) + throw new FileNotFoundException($"Save file not found: {fileName}"); + + string json = File.ReadAllText(path); + return JsonConvert.DeserializeObject(json, _jsonOptions); + } + + [Documentation(nameof(TryLoad), "Attempts to load data from a JSON file, returning a success flag.")] + public bool TryLoad(string fileName, out T data) + { + try + { + data = Load(fileName); + return true; + } + catch + { + data = default; + return false; + } + } + + [Documentation(nameof(SaveExists), "Checks if a save file exists.")] + public bool SaveExists(string fileName) + { + return File.Exists(_fileProvider.GetSavePath(fileName)); + } + + [Documentation(nameof(DeleteSave), "Deletes a save file.")] + public void DeleteSave(string fileName) + { + string path = _fileProvider.GetSavePath(fileName); + if (File.Exists(path)) + File.Delete(path); + } + + [Documentation(nameof(GetAllSaveFiles), "Retrieves all save files in the save directory.")] + public string[] GetAllSaveFiles() + { + string savePath = _fileProvider.GetSaveDirectory(); + if (!Directory.Exists(savePath)) + return Array.Empty(); + + return Directory.GetFiles(savePath, "*.json"); + } + } +} \ No newline at end of file diff --git a/OmegaLeo.HelperLib.Game/UI/GodotConsoleUI.cs b/OmegaLeo.HelperLib.Game/UI/GodotConsoleUI.cs new file mode 100644 index 0000000..c7614b7 --- /dev/null +++ b/OmegaLeo.HelperLib.Game/UI/GodotConsoleUI.cs @@ -0,0 +1,153 @@ +// File: `OmegaLeo.HelperLib.Game/Commands/GodotConsoleUI.cs` +#if GODOT +using Godot; +using System; + +namespace OmegaLeo.HelperLib.Game.UI +{ + public partial class GodotConsoleUI : Control + { + private CommandConsole _console; + private LineEdit _inputField; + private RichTextLabel _outputText; + private ScrollContainer _scrollContainer; + private int _historyIndex = -1; + + public override void _Ready() + { + _console = new CommandConsole(); + _console.RegisterDefaultCommands(); + _console.OnOutput += AddOutput; + _console.OnError += AddError; + + SetupUI(); + RegisterGodotCommands(); + Visible = false; + } + + public override void _Input(InputEvent @event) + { + if (@event is InputEventKey keyEvent && keyEvent.Pressed) + { + if (keyEvent.Keycode == Key.Quoteleft) + { + ToggleConsole(); + GetViewport().SetInputAsHandled(); + } + else if (Visible && _inputField.HasFocus()) + { + if (keyEvent.Keycode == Key.Enter) + { + ExecuteInput(); + GetViewport().SetInputAsHandled(); + } + else if (keyEvent.Keycode == Key.Up) + { + NavigateHistory(-1); + GetViewport().SetInputAsHandled(); + } + else if (keyEvent.Keycode == Key.Down) + { + NavigateHistory(1); + GetViewport().SetInputAsHandled(); + } + } + } + } + + private void SetupUI() + { + // Create a dark panel background + var panel = new Panel(); + AddChild(panel); + panel.SetAnchorsPreset(LayoutPreset.FullRect); + + var vbox = new VBoxContainer(); + AddChild(vbox); + vbox.SetAnchorsPreset(LayoutPreset.FullRect); + + _scrollContainer = new ScrollContainer(); + _scrollContainer.SizeFlagsVertical = SizeFlags.ExpandFill; + vbox.AddChild(_scrollContainer); + + _outputText = new RichTextLabel(); + _outputText.BbcodeEnabled = true; + _outputText.ScrollFollowing = true; + _scrollContainer.AddChild(_outputText); + + _inputField = new LineEdit(); + _inputField.PlaceholderText = "Enter command..."; + vbox.AddChild(_inputField); + } + + private void ToggleConsole() + { + Visible = !Visible; + if (Visible) + { + _inputField.GrabFocus(); + } + } + + private void ExecuteInput() + { + string input = _inputField.Text; + if (!string.IsNullOrWhiteSpace(input)) + { + AddOutput($"> {input}"); + _console.ExecuteCommand(input); + _inputField.Text = ""; + _historyIndex = -1; + } + _inputField.GrabFocus(); + } + + private void NavigateHistory(int direction) + { + if (_console.History.Count == 0) return; + + _historyIndex = Mathf.Clamp(_historyIndex + direction, 0, _console.History.Count - 1); + _inputField.Text = _console.History[_console.History.Count - 1 - _historyIndex]; + _inputField.CaretColumn = _inputField.Text.Length; + } + + private void AddOutput(string message) + { + _outputText.Text += message + "\n"; + } + + private void AddError(string message) + { + AddOutput($"[color=red]ERROR: {message}[/color]"); + } + + private void RegisterGodotCommands() + { + _console.RegisterCommand("timescale", "Sets Engine.TimeScale", args => + { + if (float.TryParse(args[0], out float scale)) + { + Engine.TimeScale = scale; + _console.Output($"Time scale set to {scale}"); + } + else + { + _console.Error("Invalid number"); + } + }, 1, 1); + + _console.RegisterCommand("quit", "Quits the application", _ => + { + GetTree().Quit(); + }); + + _console.RegisterCommand("scene", "Changes to a scene", args => + { + GetTree().ChangeSceneToFile(args[0]); + }, 1, 1); + } + + public CommandConsole Console => _console; + } +} +#endif diff --git a/OmegaLeo.HelperLib.Game/UI/UnityConsoleUI.cs b/OmegaLeo.HelperLib.Game/UI/UnityConsoleUI.cs new file mode 100644 index 0000000..630b205 --- /dev/null +++ b/OmegaLeo.HelperLib.Game/UI/UnityConsoleUI.cs @@ -0,0 +1,148 @@ +#if UNITY_EDITOR || UNITY_STANDALONE +using UnityEngine; +using UnityEngine.UI; +using TMPro; + +namespace OmegaLeo.HelperLib.Game.UI +{ + public class UnityConsoleUI : MonoBehaviour + { + [SerializeField] private GameObject consolePanel; + [SerializeField] private TMP_InputField inputField; + [SerializeField] private TextMeshProUGUI outputText; + [SerializeField] private ScrollRect scrollRect; + [SerializeField] private KeyCode toggleKey = KeyCode.BackQuote; + + private CommandConsole _console; + private int _historyIndex = -1; + + private void Awake() + { + _console = new CommandConsole(); + _console.RegisterDefaultCommands(); + _console.OnOutput += AddOutput; + _console.OnError += AddError; + + RegisterUnityCommands(); + consolePanel.SetActive(false); + } + + private void Update() + { + if (Input.GetKeyDown(toggleKey)) + { + ToggleConsole(); + } + + if (consolePanel.activeSelf && inputField.isFocused) + { + if (Input.GetKeyDown(KeyCode.Return)) + { + ExecuteInput(); + } + else if (Input.GetKeyDown(KeyCode.UpArrow)) + { + NavigateHistory(-1); + } + else if (Input.GetKeyDown(KeyCode.DownArrow)) + { + NavigateHistory(1); + } + else if (Input.GetKeyDown(KeyCode.Tab)) + { + AutoComplete(); + } + } + } + + private void ToggleConsole() + { + consolePanel.SetActive(!consolePanel.activeSelf); + if (consolePanel.activeSelf) + { + inputField.ActivateInputField(); + } + } + + private void ExecuteInput() + { + string input = inputField.text; + if (!string.IsNullOrWhiteSpace(input)) + { + AddOutput($"> {input}"); + _console.ExecuteCommand(input); + inputField.text = ""; + _historyIndex = -1; + } + inputField.ActivateInputField(); + } + + private void NavigateHistory(int direction) + { + if (_console.History.Count == 0) return; + + _historyIndex += direction; + _historyIndex = Mathf.Clamp(_historyIndex, 0, _console.History.Count - 1); + inputField.text = _console.History[_console.History.Count - 1 - _historyIndex]; + inputField.caretPosition = inputField.text.Length; + } + + private void AutoComplete() + { + string[] suggestions = _console.GetAutoComplete(inputField.text); + if (suggestions.Length == 1) + { + inputField.text = suggestions[0]; + inputField.caretPosition = inputField.text.Length; + } + else if (suggestions.Length > 1) + { + AddOutput("Suggestions: " + string.Join(", ", suggestions)); + } + } + + private void AddOutput(string message) + { + outputText.text += message + "\n"; + Canvas.ForceUpdateCanvases(); + scrollRect.verticalNormalizedPosition = 0f; + } + + private void AddError(string message) + { + AddOutput($"ERROR: {message}"); + } + + private void RegisterUnityCommands() + { + _console.RegisterCommand("timescale", "Sets Time.timeScale", args => + { + if (float.TryParse(args[0], out float scale)) + { + Time.timeScale = scale; + _console.Output($"Time scale set to {scale}"); + } + else + { + _console.Error("Invalid number"); + } + }, 1, 1); + + _console.RegisterCommand("quit", "Quits the application", _ => + { + Application.Quit(); + #if UNITY_EDITOR + UnityEditor.EditorApplication.isPlaying = false; + #endif + }); + + _console.RegisterCommand("scene", "Loads a scene by name", args => + { + UnityEngine.SceneManagement.SceneManager.LoadScene(args[0]); + }, 1, 1); + } + + public CommandConsole Console => _console; + } +} +#endif \ No newline at end of file diff --git a/OmegaLeo.HelperLib.Game/Utilities/WeightedPicker.cs b/OmegaLeo.HelperLib.Game/Utilities/WeightedPicker.cs new file mode 100644 index 0000000..81b75fb --- /dev/null +++ b/OmegaLeo.HelperLib.Game/Utilities/WeightedPicker.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Linq; +using NetFlow.DocumentationHelper.Library.Attributes; +using OmegaLeo.HelperLib.Game.Models; + +namespace OmegaLeo.HelperLib.Game.Utilities +{ + public static class WeightedPicker + { + [Documentation("WeightedPicker.Pick", "Picks a random item from a dictionary of items with associated weights.")] + public static T Pick(RandomNumberGenerator rng, Dictionary weights) + { + float total = weights.Values.Sum(); + float roll = rng.Range(0f, total); + + foreach (var pair in weights) + { + roll -= pair.Value; + if (roll <= 0) + return pair.Key; + } + + return weights.Keys.Last(); // fallback + } + } +} \ No newline at end of file diff --git a/OmegaLeo.HelperLib.Web/OmegaLeo.HelperLib.Web.csproj b/OmegaLeo.HelperLib.Web/OmegaLeo.HelperLib.Web.csproj new file mode 100644 index 0000000..d315613 --- /dev/null +++ b/OmegaLeo.HelperLib.Web/OmegaLeo.HelperLib.Web.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + diff --git a/OmegaLeo.HelperLib.sln b/OmegaLeo.HelperLib.sln index c73f60a..6026162 100644 --- a/OmegaLeo.HelperLib.sln +++ b/OmegaLeo.HelperLib.sln @@ -10,6 +10,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmegaLeo.HelperLib.Changelo EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmegaLeo.HelperLib.ChangelogHandler", "OmegaLeo.HelperLib.ChangelogHandler\OmegaLeo.HelperLib.ChangelogHandler.csproj", "{359536F7-02DE-43E9-A383-D83853227B0F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmegaLeo.HelperLib.Web", "OmegaLeo.HelperLib.Web\OmegaLeo.HelperLib.Web.csproj", "{1EA64C6B-D5D2-47EB-8255-5BAE2EE1385A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmegaLeo.HelperLib.Game", "OmegaLeo.HelperLib.Game\OmegaLeo.HelperLib.Game.csproj", "{488BC3EA-9B7A-4251-B802-22EB098D6494}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -36,5 +40,13 @@ Global {359536F7-02DE-43E9-A383-D83853227B0F}.Debug|Any CPU.Build.0 = Debug|Any CPU {359536F7-02DE-43E9-A383-D83853227B0F}.Release|Any CPU.ActiveCfg = Release|Any CPU {359536F7-02DE-43E9-A383-D83853227B0F}.Release|Any CPU.Build.0 = Release|Any CPU + {1EA64C6B-D5D2-47EB-8255-5BAE2EE1385A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1EA64C6B-D5D2-47EB-8255-5BAE2EE1385A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1EA64C6B-D5D2-47EB-8255-5BAE2EE1385A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1EA64C6B-D5D2-47EB-8255-5BAE2EE1385A}.Release|Any CPU.Build.0 = Release|Any CPU + {488BC3EA-9B7A-4251-B802-22EB098D6494}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {488BC3EA-9B7A-4251-B802-22EB098D6494}.Debug|Any CPU.Build.0 = Debug|Any CPU + {488BC3EA-9B7A-4251-B802-22EB098D6494}.Release|Any CPU.ActiveCfg = Release|Any CPU + {488BC3EA-9B7A-4251-B802-22EB098D6494}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/OmegaLeo.HelperLib.sln.DotSettings.user b/OmegaLeo.HelperLib.sln.DotSettings.user index 28e7949..8b4eb9b 100644 --- a/OmegaLeo.HelperLib.sln.DotSettings.user +++ b/OmegaLeo.HelperLib.sln.DotSettings.user @@ -1,4 +1,5 @@  + ForceIncluded ForceIncluded ForceIncluded ForceIncluded diff --git a/OmegaLeo.HelperLib/Helpers/BenchmarkUtility.cs b/OmegaLeo.HelperLib/Helpers/BenchmarkUtility.cs index a6ec962..6b2a1ff 100644 --- a/OmegaLeo.HelperLib/Helpers/BenchmarkUtility.cs +++ b/OmegaLeo.HelperLib/Helpers/BenchmarkUtility.cs @@ -11,12 +11,9 @@ namespace GameDevLibrary.Helpers private static Stopwatch GetStopwatch(string key) { - Stopwatch stopwatch = new Stopwatch(); + Stopwatch stopwatch; - if (_stopwatches.TryGetValue(key, out stopwatch)) - { - } - else + if (!_stopwatches.TryGetValue(key, out stopwatch)) { stopwatch = new Stopwatch(); _stopwatches.Add(key, stopwatch); diff --git a/OmegaLeo.HelperLib/Models/NeoDictionary.cs b/OmegaLeo.HelperLib/Models/NeoDictionary.cs index f160f4c..1498b88 100644 --- a/OmegaLeo.HelperLib/Models/NeoDictionary.cs +++ b/OmegaLeo.HelperLib/Models/NeoDictionary.cs @@ -17,6 +17,11 @@ namespace GameDevLibrary.Models ? Items.ToDictionary(x => x.Key, x => x.Value) : new Dictionary(); + public static implicit operator Dictionary(NeoDictionary dict) => (dict.Items.Any()) + ? dict.Items.ToDictionary(x => x.Key, x => x.Value) + : new Dictionary(); + + [Documentation("TryGetValue", "Tries to get the value from the NeoDictionary for the given key.")] public bool TryGetValue(TKey key, out TValue value) { var item = Items.FirstOrDefault(x => x.Key.Equals(key)); @@ -32,6 +37,7 @@ namespace GameDevLibrary.Models return false; } + [Documentation("TryGetValueFromIndex", "Tries to get the value from the NeoDictionary at the given index.")] public bool TryGetValueFromIndex(int index, out TValue value) { if (index < Items.Count) @@ -50,44 +56,57 @@ namespace GameDevLibrary.Models return false; } + [Documentation("Add", "Adds a new NeoDictionaryItem to the NeoDictionary.")] public void Add(TKey key, TValue value) { Items.Add(new NeoDictionaryItem(key, value)); } + [Documentation("AddRange", "Adds a range of NeoDictionaryItems from another NeoDictionary to the NeoDictionary.")] public void AddRange(NeoDictionary values) { Items.AddRange(values.Items); } + [Documentation("AddRange", "Adds a range of NeoDictionaryItems to the NeoDictionary.")] public void AddRange(IEnumerable> values) { Items.AddRange(values); } + [Documentation("Any", "Checks if the NeoDictionary has any items.")] public bool Any() => Items.Any(); + [Documentation("Any", "Checks if any item in the NeoDictionary satisfies a condition.")] public bool Any(Func, bool> predicate) => Items.Any(predicate); + [Documentation("Where", "Filters the NeoDictionary items based on a predicate.")] public IEnumerable> Where(Func, bool> predicate) => Items.Where(predicate); + [Documentation("FirstOrDefault", "Returns the first item of the NeoDictionary or a default value if the NeoDictionary is empty.")] public NeoDictionaryItem? FirstOrDefault() => Items.FirstOrDefault(); + [Documentation("FirstOrDefault", "Returns the first item of the NeoDictionary that satisfies a condition or a default value if no such item is found.")] public NeoDictionaryItem? FirstOrDefault(Func, bool> predicate) => Items.FirstOrDefault(predicate); + [Documentation("LastOrDefault", "Returns the last item of the NeoDictionary or a default value if the NeoDictionary is empty.")] public NeoDictionaryItem? LastOrDefault() => Items.LastOrDefault(); + [Documentation("LastOrDefault", "Returns the last item of the NeoDictionary that satisfies a condition or a default value if no such item is found.")] public NeoDictionaryItem? LastOrDefault(Func, bool> predicate) => Items.LastOrDefault(predicate); + [Documentation("Select", "Projects each item of the NeoDictionary into a new form.")] public IEnumerable> Select(Func, NeoDictionaryItem> selector) => Items.Select(selector); + [Documentation("IndexOf", "Returns the index of the given NeoDictionaryItem.")] public int IndexOf(NeoDictionaryItem selector) => Items.IndexOf(selector); + [Documentation("Remove", "Removes the item with the given key.")] public void Remove(TKey key) { var index = Items.FindIndex(x => x.Key.Equals(key)); @@ -95,8 +114,10 @@ namespace GameDevLibrary.Models Items.RemoveAt(index); } + [Documentation("RemoveAt", "Removes the item at the given index.")] public void RemoveAt(int index) => Items.RemoveAt(index); + [Documentation("Replace", "Replaces the value for the given key if it exists.")] public void Replace(TKey key, TValue value) { if (HasKey(key)) @@ -105,6 +126,7 @@ namespace GameDevLibrary.Models } } + [Documentation("HasKey", "Checks if the NeoDictionary contains the given key.")] public bool HasKey(TKey key) => Items.Any(x => x.Key.Equals(key)); public TValue this[TKey key] @@ -143,7 +165,93 @@ namespace GameDevLibrary.Models } } + public NeoDictionaryItem this[int index] + { + get => Items[index]; + set => Items[index] = value; + } + + [Documentation("Clear", "Clears all items from the NeoDictionary.")] public void Clear() => Items.Clear(); + + [Documentation("Sort", "Sorts the NeoDictionary items using the given comparison.")] + public void Sort(Comparison> comparison) => Items.Sort(comparison); + + [Documentation("Count", "Returns the number of items in the NeoDictionary.")] + public int Count() => Items.Count; + + [Documentation("ForEach", "Performs the given action on each item in the NeoDictionary.")] + public void ForEach(Action> action) => Items.ForEach(action); + + [Documentation("ToList", "Converts the NeoDictionary items to a List.")] + public List> ToList() => Items.ToList(); + + [Documentation("Clone", "Creates a shallow copy of the NeoDictionary.")] + public NeoDictionary Clone() + { + var newDict = new NeoDictionary(); + newDict.Items = new List>(Items); + return newDict; + } + + [Documentation("Reverse", "Reverses the order of the items in the NeoDictionary.")] + public void Reverse() => Items.Reverse(); + + [Documentation("Insert", "Inserts an item at the given index.")] + public void Insert(int index, TKey key, TValue value) => + Items.Insert(index, new NeoDictionaryItem(key, value)); + + [Documentation("InsertRange", "Inserts a range of items starting from the given index.")] + public void InsertRange(int index, IEnumerable> values) => + Items.InsertRange(index, values); + + [Documentation("RemoveRange", "Removes a range of items starting from the given index.")] + public void RemoveRange(int index, int count) => + Items.RemoveRange(index, count); + + [Documentation("RemoveAll", "Removes all items matching the given predicate.")] + public void RemoveAll(Func, bool> predicate) => + Items.RemoveAll(new Predicate>(predicate)); + + [Documentation("FindIndex", "Finds the index of the first item matching the given predicate.")] + public int FindIndex(Func, bool> predicate) => + Items.FindIndex(new Predicate>(predicate)); + + [Documentation("Find", "Finds the first item matching the given predicate.")] + public NeoDictionaryItem Find(Func, bool> predicate) => + Items.Find(new Predicate>(predicate)); + + [Documentation("FindAll", "Finds all items matching the given predicate.")] + public IEnumerable> FindAll(Func, bool> predicate) => + Items.FindAll(new Predicate>(predicate)); + + [Documentation("Merge", "Merges another NeoDictionary into this one, ignoring duplicate keys.")] + public void Merge(NeoDictionary other) + { + foreach (var item in other.Items) + { + if (!HasKey(item.Key)) + { + Add(item.Key, item.Value); + } + } + } + + [Documentation("ToDictionarySafe", "Converts the NeoDictionary to a standard Dictionary while safely handling duplicate keys by ignoring them.")] + public Dictionary ToDictionarySafe() + { + var dict = new Dictionary(); + + foreach (var item in Items) + { + if (!dict.ContainsKey(item.Key)) + { + dict.Add(item.Key, item.Value); + } + } + + return dict; + } } [Serializable]