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:
Leonid Pershin
2026-06-11 05:17:08 +03:00
co-authored by Claude Fable 5
parent e06f24a319
commit d498c70660
14 changed files with 788 additions and 3 deletions
+45
View File
@@ -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);
}
}
+1
View File
@@ -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) =>
+246
View File
@@ -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>