Add editable keybindings, 1x scale, screenshots, and UI fixes

- Add KeyBindings system with JSON persistence to %APPDATA%
- ControlsWindow now allows clicking to rebind keys (click left/right for primary/alternate)
- Press DEL/Backspace to clear a binding, Reset Defaults button to restore
- Add 1x graphics scale option (original tile size) alongside 2x and 4x
- Fix Asset Viewer click handling - MenuBar now checks window ID
- Add RenderSetLogicalSize at initialization for consistent scaling
- Add screenshots to README (title, gameplay, world map, script editor, asset viewer)
- Remove placeholder comments from README

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ned Heller
2026-01-31 00:44:58 -08:00
co-authored by Claude Opus 4.5
parent b76b2cbb17
commit 94258da064
12 changed files with 832 additions and 77 deletions
+1 -6
View File
@@ -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.
<!-- SCREENSHOT: Title Screen -->
![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
<!-- SCREENSHOT: Gameplay showing player in a zone with NPCs -->
![Gameplay](docs/screenshots/gameplay.png)
### World Map Viewer
<!-- SCREENSHOT: Debug Map Window showing 10x10 or 15x15 grid -->
![World Map](docs/screenshots/world-map.png)
### Script Editor
<!-- SCREENSHOT: Script Editor window showing IACT scripts -->
![Script Editor](docs/screenshots/script-editor.png)
### Asset Viewer
<!-- SCREENSHOT: Asset Viewer showing tiles/characters -->
![Asset Viewer](docs/screenshots/asset-viewer.png)
## Requirements
Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+1 -1
View File
@@ -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;
@@ -0,0 +1,296 @@
using System.Text.Json;
using Hexa.NET.SDL2;
namespace YodaStoriesNG.Engine.Game;
/// <summary>
/// Manages customizable keyboard bindings for game actions.
/// </summary>
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<string, int> Primary { get; set; } = new();
public Dictionary<string, int> Alternate { get; set; } = new();
// Display names for the UI
public static readonly Dictionary<string, string> 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<string, int>
{
{ 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<string, int>
{
{ MoveUp, (int)SDLKeyCode.W },
{ MoveDown, (int)SDLKeyCode.S },
{ MoveLeft, (int)SDLKeyCode.A },
{ MoveRight, (int)SDLKeyCode.D },
};
}
/// <summary>
/// Checks if a key matches the binding for an action.
/// </summary>
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;
}
/// <summary>
/// Sets the primary binding for an action.
/// </summary>
public void SetPrimary(string action, int keyCode)
{
Primary[action] = keyCode;
}
/// <summary>
/// Sets the alternate binding for an action.
/// </summary>
public void SetAlternate(string action, int keyCode)
{
Alternate[action] = keyCode;
}
/// <summary>
/// Clears the alternate binding for an action.
/// </summary>
public void ClearAlternate(string action)
{
Alternate.Remove(action);
}
/// <summary>
/// Gets a display string for the current binding.
/// </summary>
public string GetBindingDisplay(string action)
{
var parts = new List<string>();
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)";
}
/// <summary>
/// Gets the display name for an SDL key code.
/// </summary>
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}"
};
}
/// <summary>
/// Saves bindings to the config file.
/// </summary>
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}");
}
}
/// <summary>
/// Loads bindings from the config file.
/// </summary>
public static KeyBindings Load()
{
try
{
if (File.Exists(ConfigPath))
{
var json = File.ReadAllText(ConfigPath);
var bindings = JsonSerializer.Deserialize<KeyBindings>(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();
}
}
@@ -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;
/// <summary>
/// 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();
+258 -63
View File
@@ -1,10 +1,11 @@
using Hexa.NET.SDL2;
using YodaStoriesNG.Engine.Game;
using YodaStoriesNG.Engine.Rendering;
namespace YodaStoriesNG.Engine.UI;
/// <summary>
/// Window that displays keyboard and controller control mappings.
/// Window that displays and allows editing keyboard and controller control mappings.
/// </summary>
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, &sectionRect);
_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, &sectionRect);
_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();
+254
View File
@@ -0,0 +1,254 @@
using YodaStoriesNG.Engine.Data;
using YodaStoriesNG.Engine.Game;
namespace YodaStoriesNG.Engine.UI;
/// <summary>
/// In-game debug overlay that renders on screen.
/// Press F1 to toggle, arrow keys to navigate, Enter to select.
/// </summary>
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;
/// <summary>
/// Gets the content lines for a specific tab.
/// </summary>
public List<string> GetTabContent(int tab)
{
return tab switch
{
0 => GetStateContent(),
1 => GetZoneContent(),
2 => GetScriptsContent(),
3 => GetInventoryContent(),
4 => GetMapContent(),
_ => new List<string> { "Unknown tab" }
};
}
private List<string> GetStateContent()
{
var lines = new List<string>
{
"=== 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<string> GetZoneContent()
{
var lines = new List<string>();
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<string> GetScriptsContent()
{
var lines = new List<string>();
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<string> GetInventoryContent()
{
var lines = new List<string>
{
"=== 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<string> GetMapContent()
{
var lines = new List<string>();
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) + "...";
}
+18 -7
View File
@@ -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