diff --git a/README.md b/README.md index cf62693..ff97261 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,6 @@ A modern reimplementation of **Star Wars: Yoda Stories** (1997) built with C# an Yoda Stories NG is a fan-made recreation of the classic LucasArts desktop adventure game. It parses the original game data files and reimplements the game engine from scratch. - ![Title Screen](docs/screenshots/title-screen.png) ## Key Features @@ -22,25 +21,21 @@ Yoda Stories NG is a fan-made recreation of the classic LucasArts desktop advent - **Save/Load system** - JSON-based save files with full game state - **Widescreen UI** - Modern layout with sidebar HUD - **Xbox controller support** - Full gamepad controls -- **Configurable graphics** - 2x and 4x scaling options +- **Configurable graphics** - 1x, 2x, and 4x scaling options - **Automated Mission Bot** - AI-powered gameplay with A* pathfinding ## Screenshots ### Gameplay - ![Gameplay](docs/screenshots/gameplay.png) ### World Map Viewer - ![World Map](docs/screenshots/world-map.png) ### Script Editor - ![Script Editor](docs/screenshots/script-editor.png) ### Asset Viewer - ![Asset Viewer](docs/screenshots/asset-viewer.png) ## Requirements diff --git a/docs/screenshots/asset-viewer.png b/docs/screenshots/asset-viewer.png new file mode 100644 index 0000000..2eb1025 Binary files /dev/null and b/docs/screenshots/asset-viewer.png differ diff --git a/docs/screenshots/gameplay.png b/docs/screenshots/gameplay.png new file mode 100644 index 0000000..51e987a Binary files /dev/null and b/docs/screenshots/gameplay.png differ diff --git a/docs/screenshots/script-editor.png b/docs/screenshots/script-editor.png new file mode 100644 index 0000000..36769ba Binary files /dev/null and b/docs/screenshots/script-editor.png differ diff --git a/docs/screenshots/title-screen.png b/docs/screenshots/title-screen.png new file mode 100644 index 0000000..51cdcb1 Binary files /dev/null and b/docs/screenshots/title-screen.png differ diff --git a/docs/screenshots/world-map.png b/docs/screenshots/world-map.png new file mode 100644 index 0000000..eea9ff5 Binary files /dev/null and b/docs/screenshots/world-map.png differ diff --git a/src/YodaStoriesNG.Engine/Game/GameEngine.cs b/src/YodaStoriesNG.Engine/Game/GameEngine.cs index e37d3ee..a47f277 100644 --- a/src/YodaStoriesNG.Engine/Game/GameEngine.cs +++ b/src/YodaStoriesNG.Engine/Game/GameEngine.cs @@ -113,7 +113,7 @@ public unsafe class GameEngine : IDisposable _titleScreen.OnStartGame += () => { _showingTitleScreen = false; StartNewGame(); }; _menuBar = new MenuBar(_renderer.GetFont()); - _menuBar.SetRenderer(_renderer.GetRenderer()); + _menuBar.SetRenderer(_renderer.GetRenderer(), _renderer.GetWindowID()); _menuBar.OnNewGame += (size) => { _selectedWorldSize = size; StartNewGame(); }; _menuBar.OnSaveGame += SaveGame; _menuBar.OnSaveGameAs += SaveGameAs; diff --git a/src/YodaStoriesNG.Engine/Game/KeyBindings.cs b/src/YodaStoriesNG.Engine/Game/KeyBindings.cs new file mode 100644 index 0000000..c86f3da --- /dev/null +++ b/src/YodaStoriesNG.Engine/Game/KeyBindings.cs @@ -0,0 +1,296 @@ +using System.Text.Json; +using Hexa.NET.SDL2; + +namespace YodaStoriesNG.Engine.Game; + +/// +/// Manages customizable keyboard bindings for game actions. +/// +public class KeyBindings +{ + private static readonly string ConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "YodaStoriesNG", "keybindings.json"); + + // Action names (used as keys in the dictionary) + public const string MoveUp = "MoveUp"; + public const string MoveDown = "MoveDown"; + public const string MoveLeft = "MoveLeft"; + public const string MoveRight = "MoveRight"; + public const string Action = "Action"; + public const string ToggleWeapon = "ToggleWeapon"; + public const string Travel = "Travel"; + public const string Objective = "Objective"; + public const string Restart = "Restart"; + public const string ToggleSound = "ToggleSound"; + public const string Quit = "Quit"; + public const string DebugOverlay = "DebugOverlay"; + public const string MapViewer = "MapViewer"; + public const string ScriptEditor = "ScriptEditor"; + public const string AssetViewer = "AssetViewer"; + public const string NextZone = "NextZone"; + public const string PrevZone = "PrevZone"; + public const string FindZone = "FindZone"; + public const string Inspect = "Inspect"; + public const string ToggleBot = "ToggleBot"; + public const string Inventory1 = "Inventory1"; + public const string Inventory2 = "Inventory2"; + public const string Inventory3 = "Inventory3"; + public const string Inventory4 = "Inventory4"; + public const string Inventory5 = "Inventory5"; + public const string Inventory6 = "Inventory6"; + public const string Inventory7 = "Inventory7"; + public const string Inventory8 = "Inventory8"; + + // Primary and alternate key bindings + public Dictionary Primary { get; set; } = new(); + public Dictionary Alternate { get; set; } = new(); + + // Display names for the UI + public static readonly Dictionary DisplayNames = new() + { + { MoveUp, "Move Up" }, + { MoveDown, "Move Down" }, + { MoveLeft, "Move Left" }, + { MoveRight, "Move Right" }, + { Action, "Action / Attack / Talk" }, + { ToggleWeapon, "Toggle Weapon" }, + { Travel, "Travel (X-Wing)" }, + { Objective, "Show Objective" }, + { Restart, "Restart Game" }, + { ToggleSound, "Toggle Sound" }, + { Quit, "Quit" }, + { DebugOverlay, "Debug Overlay" }, + { MapViewer, "Map Viewer" }, + { ScriptEditor, "Script Editor" }, + { AssetViewer, "Asset Viewer" }, + { NextZone, "Next Zone" }, + { PrevZone, "Previous Zone" }, + { FindZone, "Find Zone" }, + { Inspect, "Inspect" }, + { ToggleBot, "Toggle Bot" }, + { Inventory1, "Inventory Slot 1" }, + { Inventory2, "Inventory Slot 2" }, + { Inventory3, "Inventory Slot 3" }, + { Inventory4, "Inventory Slot 4" }, + { Inventory5, "Inventory Slot 5" }, + { Inventory6, "Inventory Slot 6" }, + { Inventory7, "Inventory Slot 7" }, + { Inventory8, "Inventory Slot 8" }, + }; + + // Categories for grouping in UI + public static readonly (string category, string[] actions)[] Categories = new[] + { + ("Movement", new[] { MoveUp, MoveDown, MoveLeft, MoveRight }), + ("Actions", new[] { Action, ToggleWeapon, Travel, Objective }), + ("Inventory", new[] { Inventory1, Inventory2, Inventory3, Inventory4, Inventory5, Inventory6, Inventory7, Inventory8 }), + ("Game", new[] { Restart, ToggleSound, Quit }), + ("Debug", new[] { DebugOverlay, MapViewer, ScriptEditor, AssetViewer, NextZone, PrevZone, FindZone, Inspect, ToggleBot }), + }; + + public KeyBindings() + { + SetDefaults(); + } + + public void SetDefaults() + { + // Primary bindings (arrow keys / standard) + Primary = new Dictionary + { + { MoveUp, (int)SDLKeyCode.Up }, + { MoveDown, (int)SDLKeyCode.Down }, + { MoveLeft, (int)SDLKeyCode.Left }, + { MoveRight, (int)SDLKeyCode.Right }, + { Action, (int)SDLKeyCode.Space }, + { ToggleWeapon, (int)SDLKeyCode.Tab }, + { Travel, (int)SDLKeyCode.X }, + { Objective, (int)SDLKeyCode.O }, + { Restart, (int)SDLKeyCode.R }, + { ToggleSound, (int)SDLKeyCode.M }, + { Quit, (int)SDLKeyCode.Escape }, + { DebugOverlay, (int)SDLKeyCode.F1 }, + { MapViewer, (int)SDLKeyCode.F2 }, + { ScriptEditor, (int)SDLKeyCode.F3 }, + { AssetViewer, (int)SDLKeyCode.F4 }, + { NextZone, (int)SDLKeyCode.N }, + { PrevZone, (int)SDLKeyCode.P }, + { FindZone, (int)SDLKeyCode.F }, + { Inspect, (int)SDLKeyCode.I }, + { ToggleBot, (int)SDLKeyCode.B }, + { Inventory1, (int)SDLKeyCode.K1 }, + { Inventory2, (int)SDLKeyCode.K2 }, + { Inventory3, (int)SDLKeyCode.K3 }, + { Inventory4, (int)SDLKeyCode.K4 }, + { Inventory5, (int)SDLKeyCode.K5 }, + { Inventory6, (int)SDLKeyCode.K6 }, + { Inventory7, (int)SDLKeyCode.K7 }, + { Inventory8, (int)SDLKeyCode.K8 }, + }; + + // Alternate bindings (WASD) + Alternate = new Dictionary + { + { MoveUp, (int)SDLKeyCode.W }, + { MoveDown, (int)SDLKeyCode.S }, + { MoveLeft, (int)SDLKeyCode.A }, + { MoveRight, (int)SDLKeyCode.D }, + }; + } + + /// + /// Checks if a key matches the binding for an action. + /// + public bool IsPressed(string action, int keyCode) + { + if (Primary.TryGetValue(action, out var primary) && primary == keyCode) + return true; + if (Alternate.TryGetValue(action, out var alternate) && alternate == keyCode) + return true; + return false; + } + + /// + /// Sets the primary binding for an action. + /// + public void SetPrimary(string action, int keyCode) + { + Primary[action] = keyCode; + } + + /// + /// Sets the alternate binding for an action. + /// + public void SetAlternate(string action, int keyCode) + { + Alternate[action] = keyCode; + } + + /// + /// Clears the alternate binding for an action. + /// + public void ClearAlternate(string action) + { + Alternate.Remove(action); + } + + /// + /// Gets a display string for the current binding. + /// + public string GetBindingDisplay(string action) + { + var parts = new List(); + + if (Primary.TryGetValue(action, out var primary)) + parts.Add(GetKeyName(primary)); + + if (Alternate.TryGetValue(action, out var alternate)) + parts.Add(GetKeyName(alternate)); + + return parts.Count > 0 ? string.Join(" / ", parts) : "(unbound)"; + } + + /// + /// Gets the display name for an SDL key code. + /// + public static string GetKeyName(int keyCode) + { + return keyCode switch + { + (int)SDLKeyCode.Up => "Up", + (int)SDLKeyCode.Down => "Down", + (int)SDLKeyCode.Left => "Left", + (int)SDLKeyCode.Right => "Right", + (int)SDLKeyCode.Space => "Space", + (int)SDLKeyCode.Tab => "Tab", + (int)SDLKeyCode.Escape => "Escape", + (int)SDLKeyCode.Return => "Enter", + (int)SDLKeyCode.Backspace => "Backspace", + (int)SDLKeyCode.Delete => "Delete", + (int)SDLKeyCode.Insert => "Insert", + (int)SDLKeyCode.Home => "Home", + (int)SDLKeyCode.End => "End", + (int)SDLKeyCode.Pageup => "PageUp", + (int)SDLKeyCode.Pagedown => "PageDown", + (int)SDLKeyCode.F1 => "F1", + (int)SDLKeyCode.F2 => "F2", + (int)SDLKeyCode.F3 => "F3", + (int)SDLKeyCode.F4 => "F4", + (int)SDLKeyCode.F5 => "F5", + (int)SDLKeyCode.F6 => "F6", + (int)SDLKeyCode.F7 => "F7", + (int)SDLKeyCode.F8 => "F8", + (int)SDLKeyCode.F9 => "F9", + (int)SDLKeyCode.F10 => "F10", + (int)SDLKeyCode.F11 => "F11", + (int)SDLKeyCode.F12 => "F12", + (int)SDLKeyCode.K0 => "0", + (int)SDLKeyCode.K1 => "1", + (int)SDLKeyCode.K2 => "2", + (int)SDLKeyCode.K3 => "3", + (int)SDLKeyCode.K4 => "4", + (int)SDLKeyCode.K5 => "5", + (int)SDLKeyCode.K6 => "6", + (int)SDLKeyCode.K7 => "7", + (int)SDLKeyCode.K8 => "8", + (int)SDLKeyCode.K9 => "9", + (int)SDLKeyCode.Lshift => "LShift", + (int)SDLKeyCode.Rshift => "RShift", + (int)SDLKeyCode.Lctrl => "LCtrl", + (int)SDLKeyCode.Rctrl => "RCtrl", + (int)SDLKeyCode.Lalt => "LAlt", + (int)SDLKeyCode.Ralt => "RAlt", + _ => ((char)keyCode >= 'a' && (char)keyCode <= 'z') ? ((char)keyCode).ToString().ToUpper() : $"Key{keyCode}" + }; + } + + /// + /// Saves bindings to the config file. + /// + public void Save() + { + try + { + var dir = Path.GetDirectoryName(ConfigPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var options = new JsonSerializerOptions { WriteIndented = true }; + var json = JsonSerializer.Serialize(this, options); + File.WriteAllText(ConfigPath, json); + Console.WriteLine($"[KeyBindings] Saved to {ConfigPath}"); + } + catch (Exception ex) + { + Console.WriteLine($"[KeyBindings] Failed to save: {ex.Message}"); + } + } + + /// + /// Loads bindings from the config file. + /// + public static KeyBindings Load() + { + try + { + if (File.Exists(ConfigPath)) + { + var json = File.ReadAllText(ConfigPath); + var bindings = JsonSerializer.Deserialize(json); + if (bindings != null) + { + Console.WriteLine($"[KeyBindings] Loaded from {ConfigPath}"); + return bindings; + } + } + } + catch (Exception ex) + { + Console.WriteLine($"[KeyBindings] Failed to load: {ex.Message}"); + } + + Console.WriteLine("[KeyBindings] Using defaults"); + return new KeyBindings(); + } +} diff --git a/src/YodaStoriesNG.Engine/Rendering/GameRenderer.cs b/src/YodaStoriesNG.Engine/Rendering/GameRenderer.cs index 9326416..29b882a 100644 --- a/src/YodaStoriesNG.Engine/Rendering/GameRenderer.cs +++ b/src/YodaStoriesNG.Engine/Rendering/GameRenderer.cs @@ -44,6 +44,7 @@ public unsafe class GameRenderer : IDisposable public BitmapFont GetFont() => _font; public SDLRenderer* GetRenderer() => _renderer; + public uint GetWindowID() => _window != null ? SDL.GetWindowID(_window) : 0; /// /// Sets the window scale (2x or 4x). @@ -108,6 +109,9 @@ public unsafe class GameRenderer : IDisposable Console.WriteLine("Warning: Failed to initialize bitmap font"); } + // Set initial logical size for consistent scaling behavior + SDL.RenderSetLogicalSize(_renderer, WindowWidth, WindowHeight); + // Create tile atlas texture CreateTileAtlas(); diff --git a/src/YodaStoriesNG.Engine/UI/ControlsWindow.cs b/src/YodaStoriesNG.Engine/UI/ControlsWindow.cs index ebeb10c..1f74cda 100644 --- a/src/YodaStoriesNG.Engine/UI/ControlsWindow.cs +++ b/src/YodaStoriesNG.Engine/UI/ControlsWindow.cs @@ -1,10 +1,11 @@ using Hexa.NET.SDL2; +using YodaStoriesNG.Engine.Game; using YodaStoriesNG.Engine.Rendering; namespace YodaStoriesNG.Engine.UI; /// -/// Window that displays keyboard and controller control mappings. +/// Window that displays and allows editing keyboard and controller control mappings. /// public unsafe class ControlsWindow : IDisposable { @@ -15,49 +16,22 @@ public unsafe class ControlsWindow : IDisposable private uint _windowId; private int _scrollOffset = 0; - private const int WindowWidth = 600; - private const int WindowHeight = 500; + private const int WindowWidth = 650; + private const int WindowHeight = 550; + private const int LineHeight = 24; // Which control set to show private bool _showController = false; + // Keyboard binding editing + private KeyBindings _keyBindings; + private string? _editingAction = null; + private bool _editingAlternate = false; + private int _hoveredRow = -1; + public bool IsOpen => _isOpen; - // Keyboard controls - private static readonly (string action, string key)[] KeyboardControls = new[] - { - ("Movement", ""), - ("Move Up", "Arrow Up / W"), - ("Move Down", "Arrow Down / S"), - ("Move Left", "Arrow Left / A"), - ("Move Right", "Arrow Right / D"), - ("Pull Block", "Shift + Direction"), - ("", ""), - ("Actions", ""), - ("Use Item / Attack / Talk", "Space"), - ("Toggle Weapon", "Tab"), - ("Select Inventory 1-8", "1, 2, 3, 4, 5, 6, 7, 8"), - ("Travel (X-Wing)", "X"), - ("Show Objective", "O"), - ("", ""), - ("Game", ""), - ("New Game / Restart", "R"), - ("Toggle Sound", "M"), - ("Quit", "Escape"), - ("", ""), - ("Debug", ""), - ("Toggle Debug Overlay", "F1"), - ("Toggle Map Viewer", "F2"), - ("Toggle Script Editor", "F3"), - ("Toggle Asset Viewer", "F4"), - ("Next Zone", "N"), - ("Previous Zone", "P"), - ("Find Zone with Content", "F"), - ("Inspect (Console)", "I"), - ("Toggle Bot", "B"), - }; - - // Controller controls + // Controller controls (read-only display) private static readonly (string action, string button)[] ControllerControls = new[] { ("Movement", ""), @@ -79,6 +53,13 @@ public unsafe class ControlsWindow : IDisposable ("Movement Rate", "Varies with stick"), }; + public ControlsWindow() + { + _keyBindings = KeyBindings.Load(); + } + + public KeyBindings GetKeyBindings() => _keyBindings; + public void Open(bool showController = false) { if (_isOpen) @@ -88,8 +69,10 @@ public unsafe class ControlsWindow : IDisposable _showController = showController; _scrollOffset = 0; + _editingAction = null; + _hoveredRow = -1; - string title = showController ? "Controller Controls" : "Keyboard Controls"; + string title = showController ? "Controller Controls" : "Keyboard Controls (Click to Edit)"; _window = SDL.CreateWindow( title, @@ -162,9 +145,79 @@ public unsafe class ControlsWindow : IDisposable return true; } + if (evt->Type == (uint)SDLEventType.Mousemotion && evt->Motion.WindowID == _windowId) + { + if (!_showController && _editingAction == null) + { + int my = evt->Motion.Y; + _hoveredRow = GetRowAtY(my); + } + return false; + } + + if (evt->Type == (uint)SDLEventType.Mousebuttondown && evt->Button.WindowID == _windowId) + { + if (!_showController) + { + int mx = evt->Button.X; + int my = evt->Button.Y; + + // Check for reset button click + if (my >= WindowHeight - 35 && mx >= WindowWidth - 120 && mx < WindowWidth - 20) + { + _keyBindings.SetDefaults(); + _keyBindings.Save(); + return true; + } + + var clickedAction = GetActionAtY(my); + if (clickedAction != null && _editingAction == null) + { + _editingAction = clickedAction; + // Right half = alternate binding + _editingAlternate = mx > WindowWidth / 2 + 50; + return true; + } + } + return true; + } + if (evt->Type == (uint)SDLEventType.Keydown && evt->Key.WindowID == _windowId) { - if (evt->Key.Keysym.Sym == 27) // Escape + var keyCode = (int)evt->Key.Keysym.Sym; + + // If editing a binding, capture the key + if (_editingAction != null && !_showController) + { + if (keyCode == (int)SDLKeyCode.Escape) + { + // Cancel editing + _editingAction = null; + } + else if (keyCode == (int)SDLKeyCode.Delete || keyCode == (int)SDLKeyCode.Backspace) + { + // Clear the binding + if (_editingAlternate) + _keyBindings.ClearAlternate(_editingAction); + else + _keyBindings.SetPrimary(_editingAction, 0); + _keyBindings.Save(); + _editingAction = null; + } + else + { + // Set the new binding + if (_editingAlternate) + _keyBindings.SetAlternate(_editingAction, keyCode); + else + _keyBindings.SetPrimary(_editingAction, keyCode); + _keyBindings.Save(); + _editingAction = null; + } + return true; + } + + if (keyCode == (int)SDLKeyCode.Escape) { Close(); return true; @@ -174,6 +227,48 @@ public unsafe class ControlsWindow : IDisposable return false; } + private int GetRowAtY(int y) + { + int contentY = 50 - _scrollOffset * LineHeight; + int row = 0; + + foreach (var (category, actions) in KeyBindings.Categories) + { + if (y >= contentY && y < contentY + LineHeight) + return -1; // Category header + contentY += LineHeight; + + foreach (var action in actions) + { + if (y >= contentY && y < contentY + LineHeight) + return row; + contentY += LineHeight; + row++; + } + contentY += 10; // Gap between categories + } + return -1; + } + + private string? GetActionAtY(int y) + { + int contentY = 50 - _scrollOffset * LineHeight; + + foreach (var (category, actions) in KeyBindings.Categories) + { + contentY += LineHeight; // Skip category header + + foreach (var action in actions) + { + if (y >= contentY && y < contentY + LineHeight) + return action; + contentY += LineHeight; + } + contentY += 10; // Gap between categories + } + return null; + } + public void Render() { if (!_isOpen || _renderer == null || _font == null) return; @@ -184,24 +279,133 @@ public unsafe class ControlsWindow : IDisposable // Header SDL.SetRenderDrawColor(_renderer, 45, 48, 58, 255); - var headerRect = new SDLRect { X = 0, Y = 0, W = WindowWidth, H = 40 }; + var headerRect = new SDLRect { X = 0, Y = 0, W = WindowWidth, H = 45 }; SDL.RenderFillRect(_renderer, &headerRect); string title = _showController ? "CONTROLLER CONTROLS (Xbox)" : "KEYBOARD CONTROLS"; int titleWidth = _font.GetTextWidth(title); - _font.RenderText(_renderer, title, WindowWidth / 2 - titleWidth / 2, 12, 1, 255, 255, 100, 255); + _font.RenderText(_renderer, title, WindowWidth / 2 - titleWidth / 2, 10, 1, 255, 255, 100, 255); - // Controls list - int y = 50 - _scrollOffset * 20; + if (!_showController) + { + _font.RenderText(_renderer, "Click a binding to change it. Press DEL to clear.", 20, 28, 1, 120, 120, 150, 255); + } + + if (_showController) + { + RenderControllerControls(); + } + else + { + RenderKeyboardControls(); + } + + // Footer + SDL.SetRenderDrawColor(_renderer, 40, 42, 50, 255); + var footerRect = new SDLRect { X = 0, Y = WindowHeight - 35, W = WindowWidth, H = 35 }; + SDL.RenderFillRect(_renderer, &footerRect); + + _font.RenderText(_renderer, "Press ESC to close", 20, WindowHeight - 22, 1, 120, 120, 140, 255); + + if (!_showController) + { + // Reset button + SDL.SetRenderDrawColor(_renderer, 80, 60, 60, 255); + var resetRect = new SDLRect { X = WindowWidth - 120, Y = WindowHeight - 30, W = 100, H = 25 }; + SDL.RenderFillRect(_renderer, &resetRect); + _font.RenderText(_renderer, "Reset Defaults", WindowWidth - 115, WindowHeight - 22, 1, 200, 150, 150, 255); + } + + SDL.RenderPresent(_renderer); + } + + private void RenderKeyboardControls() + { + int y = 50 - _scrollOffset * LineHeight; + int leftCol = 30; + int primaryCol = 280; + int altCol = 450; + int row = 0; + + // Column headers + if (y > 40) + { + _font!.RenderText(_renderer, "Action", leftCol, y, 1, 150, 150, 180, 255); + _font!.RenderText(_renderer, "Primary", primaryCol, y, 1, 150, 150, 180, 255); + _font!.RenderText(_renderer, "Alternate", altCol, y, 1, 150, 150, 180, 255); + } + y += LineHeight + 5; + + foreach (var (category, actions) in KeyBindings.Categories) + { + if (y > 40 && y < WindowHeight - 40) + { + // Category header + SDL.SetRenderDrawColor(_renderer, 50, 55, 70, 255); + var sectionRect = new SDLRect { X = 20, Y = y - 2, W = WindowWidth - 40, H = LineHeight }; + SDL.RenderFillRect(_renderer, §ionRect); + _font!.RenderText(_renderer, category, leftCol, y, 1, 100, 200, 255, 255); + } + y += LineHeight; + + foreach (var action in actions) + { + if (y > 40 && y < WindowHeight - 40) + { + bool isEditing = _editingAction == action; + bool isHovered = _hoveredRow == row && _editingAction == null; + + // Highlight row + if (isEditing || isHovered) + { + SDL.SetRenderDrawColor(_renderer, isEditing ? (byte)70 : (byte)45, + isEditing ? (byte)70 : (byte)48, isEditing ? (byte)90 : (byte)58, 255); + var rowRect = new SDLRect { X = 20, Y = y - 2, W = WindowWidth - 40, H = LineHeight }; + SDL.RenderFillRect(_renderer, &rowRect); + } + + // Action name + string displayName = KeyBindings.DisplayNames.TryGetValue(action, out var dn) ? dn : action; + _font!.RenderText(_renderer, displayName, leftCol, y, 1, 200, 200, 200, 255); + + // Primary binding + if (isEditing && !_editingAlternate) + { + _font!.RenderText(_renderer, "Press a key...", primaryCol, y, 1, 255, 255, 100, 255); + } + else + { + var primaryKey = _keyBindings.Primary.TryGetValue(action, out var pk) ? KeyBindings.GetKeyName(pk) : "-"; + _font!.RenderText(_renderer, primaryKey, primaryCol, y, 1, 150, 255, 150, 255); + } + + // Alternate binding + if (isEditing && _editingAlternate) + { + _font!.RenderText(_renderer, "Press a key...", altCol, y, 1, 255, 255, 100, 255); + } + else + { + var altKey = _keyBindings.Alternate.TryGetValue(action, out var ak) ? KeyBindings.GetKeyName(ak) : "-"; + _font!.RenderText(_renderer, altKey, altCol, y, 1, 150, 200, 255, 255); + } + } + y += LineHeight; + row++; + } + y += 10; // Gap between categories + } + } + + private void RenderControllerControls() + { + int y = 55 - _scrollOffset * LineHeight; int leftCol = 30; int rightCol = 300; - int lineHeight = 22; - var controls = _showController ? ControllerControls : KeyboardControls; - - foreach (var (action, binding) in controls) + foreach (var (action, binding) in ControllerControls) { - if (y > 40 && y < WindowHeight - 30) + if (y > 40 && y < WindowHeight - 40) { if (string.IsNullOrEmpty(action) && string.IsNullOrEmpty(binding)) { @@ -211,29 +415,20 @@ public unsafe class ControlsWindow : IDisposable { // Section header SDL.SetRenderDrawColor(_renderer, 50, 55, 70, 255); - var sectionRect = new SDLRect { X = 20, Y = y - 2, W = WindowWidth - 40, H = lineHeight }; + var sectionRect = new SDLRect { X = 20, Y = y - 2, W = WindowWidth - 40, H = LineHeight }; SDL.RenderFillRect(_renderer, §ionRect); - _font.RenderText(_renderer, action, leftCol, y, 1, 100, 200, 255, 255); + _font!.RenderText(_renderer, action, leftCol, y, 1, 100, 200, 255, 255); } else { // Control binding - _font.RenderText(_renderer, action, leftCol, y, 1, 200, 200, 200, 255); - _font.RenderText(_renderer, binding, rightCol, y, 1, 150, 255, 150, 255); + _font!.RenderText(_renderer, action, leftCol, y, 1, 200, 200, 200, 255); + _font!.RenderText(_renderer, binding, rightCol, y, 1, 150, 255, 150, 255); } } - y += lineHeight; + y += LineHeight; } - - // Footer - SDL.SetRenderDrawColor(_renderer, 40, 42, 50, 255); - var footerRect = new SDLRect { X = 0, Y = WindowHeight - 35, W = WindowWidth, H = 35 }; - SDL.RenderFillRect(_renderer, &footerRect); - - _font.RenderText(_renderer, "Press ESC to close", WindowWidth / 2 - 70, WindowHeight - 22, 1, 120, 120, 140, 255); - - SDL.RenderPresent(_renderer); } public void Dispose() => Close(); diff --git a/src/YodaStoriesNG.Engine/UI/DebugOverlay.cs b/src/YodaStoriesNG.Engine/UI/DebugOverlay.cs new file mode 100644 index 0000000..b655da4 --- /dev/null +++ b/src/YodaStoriesNG.Engine/UI/DebugOverlay.cs @@ -0,0 +1,254 @@ +using YodaStoriesNG.Engine.Data; +using YodaStoriesNG.Engine.Game; + +namespace YodaStoriesNG.Engine.UI; + +/// +/// In-game debug overlay that renders on screen. +/// Press F1 to toggle, arrow keys to navigate, Enter to select. +/// +public class DebugOverlay +{ + private readonly GameData _gameData; + private readonly GameState _state; + private readonly WorldGenerator? _worldGenerator; + + public bool IsVisible { get; set; } = false; + public int CurrentTab { get; private set; } = 0; + public int ScrollOffset { get; private set; } = 0; + + private readonly string[] _tabs = { "State", "Zone", "Scripts", "Inventory", "Map" }; + + public DebugOverlay(GameData gameData, GameState state, WorldGenerator? worldGenerator = null) + { + _gameData = gameData; + _state = state; + _worldGenerator = worldGenerator; + } + + public void Toggle() => IsVisible = !IsVisible; + + public void NextTab() + { + CurrentTab = (CurrentTab + 1) % _tabs.Length; + ScrollOffset = 0; + } + + public void PrevTab() + { + CurrentTab = (CurrentTab - 1 + _tabs.Length) % _tabs.Length; + ScrollOffset = 0; + } + + public void ScrollUp() => ScrollOffset = Math.Max(0, ScrollOffset - 1); + public void ScrollDown() => ScrollOffset++; + + public string[] GetTabs() => _tabs; + + /// + /// Gets the content lines for a specific tab. + /// + public List GetTabContent(int tab) + { + return tab switch + { + 0 => GetStateContent(), + 1 => GetZoneContent(), + 2 => GetScriptsContent(), + 3 => GetInventoryContent(), + 4 => GetMapContent(), + _ => new List { "Unknown tab" } + }; + } + + private List GetStateContent() + { + var lines = new List + { + "=== GAME STATE ===", + "", + $"Zone ID: {_state.CurrentZoneId}", + $"Position: ({_state.PlayerX}, {_state.PlayerY})", + $"Direction: {_state.PlayerDirection}", + $"Health: {_state.Health} / {_state.MaxHealth}", + "", + $"Games Won: {_state.GamesWon}", + $"Game Over: {_state.IsGameOver}", + $"Game Won: {_state.IsGameWon}", + "", + "=== MISSION ===" + }; + + var mission = _worldGenerator?.CurrentWorld?.Mission; + if (mission != null) + { + lines.Add($"Mission {mission.MissionNumber}/15: {mission.Name}"); + lines.Add($"Planet: {mission.Planet}"); + lines.Add($"Step: {mission.CurrentStep + 1}/{mission.PuzzleChain.Count}"); + lines.Add($"Completed: {mission.IsCompleted}"); + } + else + { + lines.Add("No mission active"); + } + + return lines; + } + + private List GetZoneContent() + { + var lines = new List(); + var zone = _state.CurrentZone; + + if (zone == null) + { + lines.Add("No zone loaded"); + return lines; + } + + lines.Add($"=== ZONE {zone.Id} ==="); + lines.Add($"Size: {zone.Width}x{zone.Height}"); + lines.Add($"Planet: {zone.Planet}"); + lines.Add($"Type: {zone.Type}"); + lines.Add(""); + lines.Add($"=== OBJECTS ({zone.Objects.Count}) ==="); + + foreach (var obj in zone.Objects) + { + lines.Add($" {obj.Type} at ({obj.X},{obj.Y}) arg={obj.Argument}"); + } + + lines.Add(""); + lines.Add($"=== NPCs ({_state.ZoneNPCs.Count}) ==="); + + foreach (var npc in _state.ZoneNPCs) + { + string name = npc.CharacterId < _gameData.Characters.Count + ? _gameData.Characters[npc.CharacterId].Name ?? $"#{npc.CharacterId}" + : $"#{npc.CharacterId}"; + lines.Add($" {name} at ({npc.X},{npc.Y}) HP:{npc.Health}"); + } + + return lines; + } + + private List GetScriptsContent() + { + var lines = new List(); + var zone = _state.CurrentZone; + + if (zone == null) + { + lines.Add("No zone loaded"); + return lines; + } + + lines.Add($"=== IACT SCRIPTS ({zone.Actions.Count}) ==="); + lines.Add(""); + + for (int i = 0; i < zone.Actions.Count; i++) + { + var action = zone.Actions[i]; + lines.Add($"--- Action #{i} ---"); + lines.Add("IF:"); + + foreach (var cond in action.Conditions) + { + var args = string.Join(",", cond.Arguments); + lines.Add($" {cond.Opcode}({args})"); + } + + lines.Add("THEN:"); + + foreach (var instr in action.Instructions) + { + var args = string.Join(",", instr.Arguments); + var text = !string.IsNullOrEmpty(instr.Text) ? $" \"{Truncate(instr.Text, 30)}\"" : ""; + lines.Add($" {instr.Opcode}({args}){text}"); + } + + lines.Add(""); + } + + return lines; + } + + private List GetInventoryContent() + { + var lines = new List + { + "=== INVENTORY ===", + "" + }; + + lines.Add($"Items ({_state.Inventory.Count}):"); + for (int i = 0; i < _state.Inventory.Count; i++) + { + var itemId = _state.Inventory[i]; + var selected = _state.SelectedItem == itemId ? " [SELECTED]" : ""; + var name = _gameData.TileNames.TryGetValue(itemId, out var n) ? n : $"Item #{itemId}"; + lines.Add($" {i + 1}. {name}{selected}"); + } + + lines.Add(""); + lines.Add($"Weapons ({_state.Weapons.Count}):"); + for (int i = 0; i < _state.Weapons.Count; i++) + { + var weaponId = _state.Weapons[i]; + var equipped = i == _state.CurrentWeaponIndex ? " [EQUIPPED]" : ""; + var name = _gameData.TileNames.TryGetValue(weaponId, out var n) ? n : $"Weapon #{weaponId}"; + lines.Add($" {name}{equipped}"); + } + + return lines; + } + + private List GetMapContent() + { + var lines = new List(); + var world = _worldGenerator?.CurrentWorld; + + if (world?.Grid == null) + { + lines.Add("No world generated"); + return lines; + } + + lines.Add("=== WORLD MAP (10x10) ==="); + lines.Add("L=Landing G=Goal F=Force .=Empty"); + lines.Add(""); + + // Header + lines.Add(" 0 1 2 3 4 5 6 7 8 9"); + + for (int y = 0; y < 10; y++) + { + var row = $" {y} "; + for (int x = 0; x < 10; x++) + { + var zoneId = world.Grid[y, x]; + bool isLanding = (x == world.LandingPosition.x && y == world.LandingPosition.y); + bool isGoal = (x == world.ObjectivePosition.x && y == world.ObjectivePosition.y); + bool isForce = world.TheForceZoneId.HasValue && + (x == world.TheForcePosition.x && y == world.TheForcePosition.y); + + if (isLanding) row += " L "; + else if (isGoal) row += " G "; + else if (isForce) row += " F "; + else if (zoneId.HasValue) row += $"{zoneId.Value,4} "; + else row += " . "; + } + lines.Add(row); + } + + lines.Add(""); + lines.Add($"Landing: Zone {world.LandingZoneId} at ({world.LandingPosition.x},{world.LandingPosition.y})"); + lines.Add($"Goal: Zone {world.ObjectiveZoneId} at ({world.ObjectivePosition.x},{world.ObjectivePosition.y})"); + if (world.TheForceZoneId.HasValue) + lines.Add($"Force: Zone {world.TheForceZoneId} at ({world.TheForcePosition.x},{world.TheForcePosition.y})"); + + return lines; + } + + private string Truncate(string s, int max) => s.Length <= max ? s : s.Substring(0, max - 3) + "..."; +} diff --git a/src/YodaStoriesNG.Engine/UI/MenuBar.cs b/src/YodaStoriesNG.Engine/UI/MenuBar.cs index 8da37bf..9fe4198 100644 --- a/src/YodaStoriesNG.Engine/UI/MenuBar.cs +++ b/src/YodaStoriesNG.Engine/UI/MenuBar.cs @@ -11,6 +11,7 @@ public unsafe class MenuBar { private readonly BitmapFont _font; private SDLRenderer* _renderer; + private uint _windowId; public const int Height = 22; @@ -22,7 +23,7 @@ public unsafe class MenuBar private readonly string[][] _menuItems = { new[] { "New Game: Small", "New Game: Medium", "New Game: Large", "New Game: X-tra Large", "-", "Save Game", "Save As...", "Load Game", "-", "Exit" }, new[] { "Asset Viewer (F2)", "Script Editor (F3)", "Map Viewer (F4)", "-", "Enable Bot", "Disable Bot" }, - new[] { "Graphics: 2x Scale", "Graphics: 4x Scale", "-", "Keyboard Controls", "Controller Controls", "-", "Select Data File..." }, + new[] { "Graphics: 1x Scale", "Graphics: 2x Scale", "Graphics: 4x Scale", "-", "Keyboard Controls", "Controller Controls", "-", "Select Data File..." }, new[] { "About Yoda Stories NG", "GitHub Repository" } }; @@ -55,13 +56,22 @@ public unsafe class MenuBar _font = font; } - public void SetRenderer(SDLRenderer* renderer) + public void SetRenderer(SDLRenderer* renderer, uint windowId) { _renderer = renderer; + _windowId = windowId; } public bool HandleEvent(SDLEvent* evt) { + // Only handle events for our window + if (evt->Type == (uint)SDLEventType.Mousebuttondown && evt->Button.WindowID != _windowId) + return false; + if (evt->Type == (uint)SDLEventType.Mousemotion && evt->Motion.WindowID != _windowId) + return false; + if (evt->Type == (uint)SDLEventType.Keydown && evt->Key.WindowID != _windowId) + return false; + if (evt->Type == (uint)SDLEventType.Mousebuttondown) { int mx = evt->Button.X; @@ -216,11 +226,12 @@ public unsafe class MenuBar case 2: // Config switch (item) { - case 0: OnSetScale?.Invoke(2); break; - case 1: OnSetScale?.Invoke(4); break; - case 3: OnShowKeyboardControls?.Invoke(); break; - case 4: OnShowControllerControls?.Invoke(); break; - case 6: OnSelectDataFile?.Invoke(); break; + case 0: OnSetScale?.Invoke(1); break; + case 1: OnSetScale?.Invoke(2); break; + case 2: OnSetScale?.Invoke(4); break; + case 4: OnShowKeyboardControls?.Invoke(); break; + case 5: OnShowControllerControls?.Invoke(); break; + case 7: OnSelectDataFile?.Invoke(); break; } break; case 3: // About