Add in-game developer console (MrGameEng.DevConsole)
Core gains a static Log (Debug/Info/Warning/Error + event); the engine logs key events like scene switches. The console captures Log output into a 2048-line ring buffer and executes registered commands with input history (up/down), Tab prefix completion and scrolling (PageUp/PageDown/End/wheel). Built-ins: help, clear, echo, timescale, close, quit; games register their own (sample: stress/main/beep). Console core is pure logic covered by headless tests; the Myra overlay renders a single label rebuilt only when the Revision counter moves - an idle or closed console costs nothing per frame. Toggled with the backquote key; sample gameplay hotkeys are suppressed while open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e06f24a319
commit
d498c70660
@@ -0,0 +1,45 @@
|
||||
namespace MrGameEng.Core;
|
||||
|
||||
/// <summary>Severity of a log message.</summary>
|
||||
public enum LogLevel
|
||||
{
|
||||
/// <summary>Verbose diagnostics.</summary>
|
||||
Debug,
|
||||
|
||||
/// <summary>Normal informational message.</summary>
|
||||
Info,
|
||||
|
||||
/// <summary>Something suspicious but recoverable.</summary>
|
||||
Warning,
|
||||
|
||||
/// <summary>An error.</summary>
|
||||
Error,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Engine-wide logger. Messages go to subscribers (the developer console subscribes here)
|
||||
/// and to the debugger output. Logging allocates — do not log every frame from hot paths.
|
||||
/// </summary>
|
||||
public static class Log
|
||||
{
|
||||
/// <summary>Raised for every message. May be invoked from any thread.</summary>
|
||||
public static event Action<LogLevel, string>? MessageLogged;
|
||||
|
||||
/// <summary>Logs a debug message.</summary>
|
||||
public static void Debug(string message) => Write(LogLevel.Debug, message);
|
||||
|
||||
/// <summary>Logs an informational message.</summary>
|
||||
public static void Info(string message) => Write(LogLevel.Info, message);
|
||||
|
||||
/// <summary>Logs a warning.</summary>
|
||||
public static void Warning(string message) => Write(LogLevel.Warning, message);
|
||||
|
||||
/// <summary>Logs an error.</summary>
|
||||
public static void Error(string message) => Write(LogLevel.Error, message);
|
||||
|
||||
private static void Write(LogLevel level, string message)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"[{level}] {message}");
|
||||
MessageLogged?.Invoke(level, message);
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,7 @@ public sealed class SceneManager
|
||||
Current = _pending;
|
||||
_pending = null;
|
||||
Current?.Load(_context);
|
||||
Log.Info($"Scene switched to {Current?.GetType().Name ?? "<none>"}");
|
||||
}
|
||||
|
||||
private static float Advance(float coverage, float direction, float duration, float deltaTime) =>
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
using System.Text;
|
||||
using MrGameEng.Core;
|
||||
|
||||
namespace MrGameEng.DevConsole;
|
||||
|
||||
/// <summary>Handler of a console command. <paramref name="args"/> excludes the command name.</summary>
|
||||
public delegate void ConsoleCommand(DevConsole console, string[] args);
|
||||
|
||||
/// <summary>
|
||||
/// The developer console core: a fixed ring buffer of log lines, a command registry,
|
||||
/// input history and prefix autocompletion. Pure logic — rendering lives in
|
||||
/// <see cref="DevConsoleRenderSystem"/>; this class is fully testable headless.
|
||||
/// Captures everything written through <see cref="Log"/>. Thread-safe for writes.
|
||||
/// </summary>
|
||||
public sealed class DevConsole : IDisposable
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
private readonly string[] _lines;
|
||||
private readonly Dictionary<string, (string Description, ConsoleCommand Handler)> _commands =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly List<string> _history = [];
|
||||
private int _head;
|
||||
private int _count;
|
||||
private int _historyCursor;
|
||||
private int _scrollOffset;
|
||||
|
||||
/// <summary>True while the console overlay is visible.</summary>
|
||||
public bool IsOpen { get; private set; }
|
||||
|
||||
/// <summary>Increments on every visible change — the UI rebuilds its text only when this moves.</summary>
|
||||
public int Revision { get; private set; }
|
||||
|
||||
/// <summary>Lines scrolled up from the bottom of the log.</summary>
|
||||
public int ScrollOffset => _scrollOffset;
|
||||
|
||||
/// <summary>Creates a console holding up to <paramref name="capacity"/> log lines.</summary>
|
||||
public DevConsole(int capacity = 2048)
|
||||
{
|
||||
_lines = new string[capacity];
|
||||
Register("help", "list available commands", static (console, _) =>
|
||||
{
|
||||
foreach (var (name, entry) in console._commands.OrderBy(p => p.Key, StringComparer.Ordinal))
|
||||
{
|
||||
console.WriteLine($" {name} — {entry.Description}");
|
||||
}
|
||||
});
|
||||
Register("clear", "clear the log", static (console, _) => console.Clear());
|
||||
Register("echo", "print the arguments", static (console, args) => console.WriteLine(string.Join(' ', args)));
|
||||
|
||||
Log.MessageLogged += OnLogMessage;
|
||||
}
|
||||
|
||||
/// <summary>Stops capturing <see cref="Log"/> messages.</summary>
|
||||
public void Dispose() => Log.MessageLogged -= OnLogMessage;
|
||||
|
||||
/// <summary>Opens or closes the console overlay.</summary>
|
||||
public void Toggle()
|
||||
{
|
||||
IsOpen = !IsOpen;
|
||||
Revision++;
|
||||
}
|
||||
|
||||
/// <summary>Registers (or replaces) a command. Name matching is case-insensitive.</summary>
|
||||
public void Register(string name, string description, ConsoleCommand handler) =>
|
||||
_commands[name] = (description, handler);
|
||||
|
||||
/// <summary>Appends a line to the log.</summary>
|
||||
public void WriteLine(string line)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_lines[(_head + _count) % _lines.Length] = line;
|
||||
if (_count < _lines.Length)
|
||||
{
|
||||
_count++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_head = (_head + 1) % _lines.Length;
|
||||
}
|
||||
|
||||
Revision++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes all log lines.</summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_head = 0;
|
||||
_count = 0;
|
||||
_scrollOffset = 0;
|
||||
Revision++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Echoes and executes one input line. Unknown commands and handler exceptions
|
||||
/// are reported into the log, never thrown.
|
||||
/// </summary>
|
||||
public void Execute(string input)
|
||||
{
|
||||
input = input.Trim();
|
||||
if (input.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
WriteLine($"> {input}");
|
||||
if (_history.Count == 0 || _history[^1] != input)
|
||||
{
|
||||
_history.Add(input);
|
||||
}
|
||||
|
||||
_historyCursor = _history.Count;
|
||||
_scrollOffset = 0;
|
||||
|
||||
var parts = input.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (!_commands.TryGetValue(parts[0], out var command))
|
||||
{
|
||||
WriteLine($"unknown command '{parts[0]}' — try 'help'");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
command.Handler(this, parts[1..]);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
WriteLine($"[err] {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Steps back through input history; null when there is none.</summary>
|
||||
public string? HistoryPrevious()
|
||||
{
|
||||
if (_history.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_historyCursor = Math.Max(0, _historyCursor - 1);
|
||||
return _history[_historyCursor];
|
||||
}
|
||||
|
||||
/// <summary>Steps forward through input history; empty string past the newest entry.</summary>
|
||||
public string? HistoryNext()
|
||||
{
|
||||
if (_history.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_historyCursor = Math.Min(_history.Count, _historyCursor + 1);
|
||||
return _historyCursor == _history.Count ? string.Empty : _history[_historyCursor];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes a command prefix: returns the longest unambiguous completion and lists
|
||||
/// the options in the log when several commands match. Returns the input unchanged
|
||||
/// when nothing matches.
|
||||
/// </summary>
|
||||
public string Complete(string prefix)
|
||||
{
|
||||
prefix = prefix.TrimStart();
|
||||
var matches = _commands.Keys
|
||||
.Where(name => name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
switch (matches.Length)
|
||||
{
|
||||
case 0:
|
||||
return prefix;
|
||||
case 1:
|
||||
return matches[0] + " ";
|
||||
default:
|
||||
WriteLine(string.Join(" ", matches));
|
||||
var common = matches[0];
|
||||
foreach (var match in matches[1..])
|
||||
{
|
||||
var length = 0;
|
||||
while (length < common.Length && length < match.Length &&
|
||||
char.ToLowerInvariant(common[length]) == char.ToLowerInvariant(match[length]))
|
||||
{
|
||||
length++;
|
||||
}
|
||||
|
||||
common = common[..length];
|
||||
}
|
||||
|
||||
return common;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Scrolls the log view; positive = older lines. Clamped to the buffer.</summary>
|
||||
public void Scroll(int deltaLines)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_scrollOffset = Math.Clamp(_scrollOffset + deltaLines, 0, Math.Max(0, _count - 1));
|
||||
Revision++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the visible window of the log (respecting scroll) into <paramref name="target"/>,
|
||||
/// most recent at the bottom.
|
||||
/// </summary>
|
||||
public void BuildVisibleText(StringBuilder target, int visibleLines)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
target.Clear();
|
||||
var end = _count - _scrollOffset;
|
||||
var start = Math.Max(0, end - visibleLines);
|
||||
for (var i = start; i < end; i++)
|
||||
{
|
||||
if (i > start)
|
||||
{
|
||||
target.Append('\n');
|
||||
}
|
||||
|
||||
target.Append(_lines[(_head + i) % _lines.Length]);
|
||||
}
|
||||
|
||||
if (_scrollOffset > 0)
|
||||
{
|
||||
target.Append($"\n— scrolled {_scrollOffset} line(s), End = bottom —");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLogMessage(LogLevel level, string message) =>
|
||||
WriteLine(level == LogLevel.Info ? message : $"[{LevelTag(level)}] {message}");
|
||||
|
||||
private static string LevelTag(LogLevel level) => level switch
|
||||
{
|
||||
LogLevel.Debug => "dbg",
|
||||
LogLevel.Warning => "warn",
|
||||
LogLevel.Error => "err",
|
||||
_ => "info",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using System.Text;
|
||||
using Friflo.Engine.ECS.Systems;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Myra;
|
||||
using Myra.Graphics2D;
|
||||
using Myra.Graphics2D.Brushes;
|
||||
using Myra.Graphics2D.UI;
|
||||
using MrGameEng.Core;
|
||||
|
||||
namespace MrGameEng.DevConsole;
|
||||
|
||||
/// <summary>Myra overlay of the console: translucent top panel with the log view and an input line.</summary>
|
||||
internal sealed class DevConsoleUi
|
||||
{
|
||||
private const int VisibleLines = 20;
|
||||
|
||||
private readonly DevConsole _console;
|
||||
private readonly Desktop _desktop;
|
||||
private readonly Label _log;
|
||||
private readonly TextBox _input;
|
||||
private readonly StringBuilder _text = new();
|
||||
private int _lastRevision = -1;
|
||||
|
||||
public DevConsoleUi(DevConsole console)
|
||||
{
|
||||
_console = console;
|
||||
|
||||
_log = new Label
|
||||
{
|
||||
Text = string.Empty,
|
||||
Wrap = false,
|
||||
};
|
||||
|
||||
_input = new TextBox
|
||||
{
|
||||
HintText = "command ('help')",
|
||||
};
|
||||
_input.TextChanged += (_, _) =>
|
||||
{
|
||||
// Клавиша-тогглер (`) не должна попадать в строку ввода.
|
||||
if (_input.Text?.Contains('`') == true)
|
||||
{
|
||||
_input.Text = _input.Text.Replace("`", "");
|
||||
}
|
||||
};
|
||||
_input.KeyDown += (_, args) => OnInputKey(args.Data);
|
||||
|
||||
var panel = new VerticalStackPanel
|
||||
{
|
||||
Spacing = 4,
|
||||
Padding = new Thickness(8),
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Height = 420,
|
||||
Background = new SolidBrush(new Color(8, 10, 14, 230)),
|
||||
};
|
||||
panel.Widgets.Add(_log);
|
||||
panel.Widgets.Add(new HorizontalSeparator());
|
||||
panel.Widgets.Add(_input);
|
||||
|
||||
_desktop = new Desktop { Root = panel };
|
||||
}
|
||||
|
||||
public void Render()
|
||||
{
|
||||
if (!_console.IsOpen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_console.Revision != _lastRevision)
|
||||
{
|
||||
_lastRevision = _console.Revision;
|
||||
_console.BuildVisibleText(_text, VisibleLines);
|
||||
_log.Text = _text.ToString();
|
||||
}
|
||||
|
||||
_desktop.Render();
|
||||
}
|
||||
|
||||
public void FocusInput()
|
||||
{
|
||||
_input.Text = string.Empty;
|
||||
_desktop.FocusedKeyboardWidget = _input;
|
||||
}
|
||||
|
||||
private void OnInputKey(Keys key)
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case Keys.Enter:
|
||||
_console.Execute(_input.Text ?? string.Empty);
|
||||
_input.Text = string.Empty;
|
||||
break;
|
||||
case Keys.Up:
|
||||
SetInput(_console.HistoryPrevious());
|
||||
break;
|
||||
case Keys.Down:
|
||||
SetInput(_console.HistoryNext());
|
||||
break;
|
||||
case Keys.Tab:
|
||||
SetInput(_console.Complete(_input.Text ?? string.Empty));
|
||||
break;
|
||||
case Keys.PageUp:
|
||||
_console.Scroll(+VisibleLines / 2);
|
||||
break;
|
||||
case Keys.PageDown:
|
||||
_console.Scroll(-VisibleLines / 2);
|
||||
break;
|
||||
case Keys.End:
|
||||
_console.Scroll(int.MinValue / 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetInput(string? text)
|
||||
{
|
||||
if (text is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_input.Text = text;
|
||||
_input.CursorPosition = text.Length;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update-phase system: toggles the console with the backquote (`) key and scrolls the log
|
||||
/// with the mouse wheel while open. Polls the keyboard itself, so it works regardless of
|
||||
/// the input module.
|
||||
/// </summary>
|
||||
public sealed class DevConsoleSystem : BaseSystem
|
||||
{
|
||||
private readonly DevConsole _console;
|
||||
private readonly DevConsoleUi _ui;
|
||||
private KeyboardState _previousKeyboard;
|
||||
private int _previousWheel;
|
||||
|
||||
internal DevConsoleSystem(DevConsole console, DevConsoleUi ui)
|
||||
{
|
||||
_console = console;
|
||||
_ui = ui;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnUpdateGroup()
|
||||
{
|
||||
var keyboard = Keyboard.GetState();
|
||||
if (keyboard.IsKeyDown(Keys.OemTilde) && _previousKeyboard.IsKeyUp(Keys.OemTilde))
|
||||
{
|
||||
_console.Toggle();
|
||||
if (_console.IsOpen)
|
||||
{
|
||||
_ui.FocusInput();
|
||||
}
|
||||
}
|
||||
|
||||
_previousKeyboard = keyboard;
|
||||
|
||||
var wheel = Mouse.GetState().ScrollWheelValue;
|
||||
if (_console.IsOpen && wheel != _previousWheel)
|
||||
{
|
||||
_console.Scroll((wheel - _previousWheel) / 40);
|
||||
}
|
||||
|
||||
_previousWheel = wheel;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Draw-phase system rendering the console overlay. Registered last — on top of everything.</summary>
|
||||
public sealed class DevConsoleRenderSystem : BaseSystem
|
||||
{
|
||||
private readonly DevConsoleUi _ui;
|
||||
|
||||
internal DevConsoleRenderSystem(DevConsoleUi ui) => _ui = ui;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnUpdateGroup() => _ui.Render();
|
||||
}
|
||||
|
||||
/// <summary>Wires the developer console into a <see cref="Scene"/>.</summary>
|
||||
public static class SceneDevConsoleExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the shared <see cref="DevConsole"/> service (created on first use, together
|
||||
/// with the built-in engine commands) and registers its systems in this scene.
|
||||
/// Call from <c>OnLoad</c> <b>after</b> all other UI so the console draws on top.
|
||||
/// Toggle with the backquote (`) key.
|
||||
/// </summary>
|
||||
public static DevConsole UseDevConsole(this Scene scene)
|
||||
{
|
||||
var services = scene.Context.Services;
|
||||
var console = services.GetOrDefault<DevConsole>();
|
||||
if (console is null)
|
||||
{
|
||||
MyraEnvironment.Game = services.Get<Game>();
|
||||
console = new DevConsole();
|
||||
services.Add(console);
|
||||
services.Add(new DevConsoleUi(console));
|
||||
RegisterEngineCommands(console, scene.Context);
|
||||
Log.Info("Developer console ready — press ` to toggle, 'help' for commands");
|
||||
}
|
||||
|
||||
var ui = services.Get<DevConsoleUi>();
|
||||
scene.UpdateSystems.Insert(0, new DevConsoleSystem(console, ui));
|
||||
scene.DrawSystems.Add(new DevConsoleRenderSystem(ui));
|
||||
return console;
|
||||
}
|
||||
|
||||
private static void RegisterEngineCommands(DevConsole console, EngineContext context)
|
||||
{
|
||||
console.Register("timescale", "timescale [value] — show or set game speed", (c, args) =>
|
||||
{
|
||||
if (args.Length == 0)
|
||||
{
|
||||
c.WriteLine($"timescale = {context.Clock.TimeScale}");
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Clock.TimeScale = float.Parse(args[0], System.Globalization.CultureInfo.InvariantCulture);
|
||||
c.WriteLine($"timescale = {context.Clock.TimeScale}");
|
||||
}
|
||||
});
|
||||
|
||||
console.Register("close", "close the console", static (c, _) => c.Toggle());
|
||||
|
||||
console.Register("quit", "exit the game", (_, _) => context.Services.Get<Game>().Exit());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Myra" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user