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

## 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
-

### World Map Viewer
-

### Script Editor
-

### Asset Viewer
-

## 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