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
+2 -1
View File
@@ -19,7 +19,8 @@ docs/ architecture, conventions, roadmap (Russian)
Engine modules: `Core` (game loop, ECS world, scenes, time), `Graphics` (custom batched Engine modules: `Core` (game loop, ECS world, scenes, time), `Graphics` (custom batched
renderer, camera, sprites), `Input`, `Audio`, `Assets` (runtime loading, no content renderer, camera, sprites), `Input`, `Audio`, `Assets` (runtime loading, no content
pipeline), `Assets.Generator` (Roslyn source generator for typed asset handles), pipeline), `Assets.Generator` (Roslyn source generator for typed asset handles),
`UI` (Myra integration: `scene.UseUI()` after `UseRenderer2D()`). `UI` (Myra integration: `scene.UseUI()` after `UseRenderer2D()`), `DevConsole`
(in-game console capturing `Core.Log`; `scene.UseDevConsole()` last in OnLoad).
Dependency rule: every module may depend only on `Core`; `Core` depends only on Dependency rule: every module may depend only on `Core`; `Core` depends only on
MonoGame and Friflo.Engine.ECS. `Assets.Generator` is a netstandard2.0 analyzer. MonoGame and Friflo.Engine.ECS. `Assets.Generator` is a netstandard2.0 analyzer.
Documented exception: Myra renders with its own SpriteBatch internally. Documented exception: Myra renders with its own SpriteBatch internally.
+30
View File
@@ -33,6 +33,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Input.Tests", "te
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.UI", "src\MrGameEng.UI\MrGameEng.UI.csproj", "{17EB97D5-DCF8-47DF-B810-DA45AE314170}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.UI", "src\MrGameEng.UI\MrGameEng.UI.csproj", "{17EB97D5-DCF8-47DF-B810-DA45AE314170}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.DevConsole", "src\MrGameEng.DevConsole\MrGameEng.DevConsole.csproj", "{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.DevConsole.Tests", "tests\MrGameEng.DevConsole.Tests\MrGameEng.DevConsole.Tests.csproj", "{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -187,6 +191,30 @@ Global
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|x64.Build.0 = Release|Any CPU {17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|x64.Build.0 = Release|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|x86.ActiveCfg = Release|Any CPU {17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|x86.ActiveCfg = Release|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|x86.Build.0 = Release|Any CPU {17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|x86.Build.0 = Release|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|x64.ActiveCfg = Debug|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|x64.Build.0 = Debug|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|x86.ActiveCfg = Debug|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|x86.Build.0 = Debug|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|Any CPU.Build.0 = Release|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|x64.ActiveCfg = Release|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|x64.Build.0 = Release|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|x86.ActiveCfg = Release|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|x86.Build.0 = Release|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|x64.ActiveCfg = Debug|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|x64.Build.0 = Debug|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|x86.ActiveCfg = Debug|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|x86.Build.0 = Debug|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|Any CPU.Build.0 = Release|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|x64.ActiveCfg = Release|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|x64.Build.0 = Release|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|x86.ActiveCfg = Release|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -204,5 +232,7 @@ Global
{D55753A2-4CC0-4BCC-80D5-01E919FF97BE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {D55753A2-4CC0-4BCC-80D5-01E919FF97BE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{0E0710AB-6132-4E64-9AFC-03B0601F92C6} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {0E0710AB-6132-4E64-9AFC-03B0601F92C6} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{17EB97D5-DCF8-47DF-B810-DA45AE314170} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {17EB97D5-DCF8-47DF-B810-DA45AE314170} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal
+17
View File
@@ -34,6 +34,7 @@
| `MrGameEng.Assets` | Runtime-загрузка ресурсов без Content Pipeline, кэш, `AssetRef<T>` | | `MrGameEng.Assets` | Runtime-загрузка ресурсов без Content Pipeline, кэш, `AssetRef<T>` |
| `MrGameEng.Assets.Generator` | Roslyn incremental source generator: классы с типизированными хендлами ресурсов | | `MrGameEng.Assets.Generator` | Roslyn incremental source generator: классы с типизированными хендлами ресурсов |
| `MrGameEng.UI` | Игровой UI на [Myra](https://github.com/rds1983/Myra): `Desktop` на сцену, виджеты, скининг | | `MrGameEng.UI` | Игровой UI на [Myra](https://github.com/rds1983/Myra): `Desktop` на сцену, виджеты, скининг |
| `MrGameEng.DevConsole` | Ингейм-консоль разработчика: логи `Log`, команды, история, автодополнение |
Планируемые модули (по мере развития): `Physics2D`, `Tilemap`, `UI`, `Particles`. Планируемые модули (по мере развития): `Physics2D`, `Tilemap`, `UI`, `Particles`.
@@ -53,6 +54,22 @@ MrGameEng.Assets ─┘ └──► Friflo.Engine.ECS
он подключается к проекту игры как `Analyzer`, в рантайме не участвует и не зависит он подключается к проекту игры как `Analyzer`, в рантайме не участвует и не зависит
от других модулей движка. от других модулей движка.
## Консоль разработчика
`MrGameEng.DevConsole` — ингейм-консоль (тогглинг клавишей <code>`</code>):
- Перехватывает всё, что пишется через статический `MrGameEng.Core.Log`
(Debug/Info/Warning/Error; движок логирует ключевые события сам).
- Команды: `console.Register(name, description, handler)`; встроенные —
`help`, `clear`, `echo`, `timescale`, `close`, `quit`; игра добавляет свои.
- История ввода (↑/↓), автодополнение по Tab, скролл (PageUp/PageDown/End/колесо).
- Производительность: кольцевой буфер строк (2048), отрисовка — один Label,
текст которого перестраивается только при изменении (`Revision`); при закрытой
консоли — ноль работы в кадре.
- Ядро (`DevConsole`) — чистая логика без Myra, полностью покрыта headless-тестами;
Myra-обвязка отдельно. `scene.UseDevConsole()` — последним в `OnLoad`, чтобы
консоль рисовалась поверх UI. Сервис общий, живёт между сценами.
## UI (Myra) ## UI (Myra)
Игровой UI — интеграция [Myra](https://github.com/rds1983/Myra) (выбор: экосистема Игровой UI — интеграция [Myra](https://github.com/rds1983/Myra) (выбор: экосистема
@@ -12,6 +12,7 @@
<ProjectReference Include="..\..\src\MrGameEng.Audio\MrGameEng.Audio.csproj" /> <ProjectReference Include="..\..\src\MrGameEng.Audio\MrGameEng.Audio.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.Assets\MrGameEng.Assets.csproj" /> <ProjectReference Include="..\..\src\MrGameEng.Assets\MrGameEng.Assets.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.UI\MrGameEng.UI.csproj" /> <ProjectReference Include="..\..\src\MrGameEng.UI\MrGameEng.UI.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.DevConsole\MrGameEng.DevConsole.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj" <ProjectReference Include="..\..\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" /> OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup> </ItemGroup>
+12 -1
View File
@@ -12,10 +12,16 @@ namespace MrGameEng.Sample;
/// <summary>Управление игроком (WASD/стрелки) и прыжок-писк на пробел.</summary> /// <summary>Управление игроком (WASD/стрелки) и прыжок-писк на пробел.</summary>
public sealed class PlayerControlSystem( public sealed class PlayerControlSystem(
Entity player, ActionMap<SampleAction> actions, AudioManager audio, SoundEffect beep) : BaseSystem Entity player, ActionMap<SampleAction> actions, AudioManager audio, SoundEffect beep,
MrGameEng.DevConsole.DevConsole console) : BaseSystem
{ {
protected override void OnUpdateGroup() protected override void OnUpdateGroup()
{ {
if (console.IsOpen)
{
return;
}
ref var transform = ref player.GetComponent<Transform2D>(); ref var transform = ref player.GetComponent<Transform2D>();
var move = new Vector2( var move = new Vector2(
actions.GetAxis(SampleAction.MoveLeft, SampleAction.MoveRight), actions.GetAxis(SampleAction.MoveLeft, SampleAction.MoveRight),
@@ -131,6 +137,11 @@ public sealed class SceneHotkeysSystem(
{ {
protected override void OnUpdateGroup() protected override void OnUpdateGroup()
{ {
if (context.Services.Get<MrGameEng.DevConsole.DevConsole>().IsOpen)
{
return;
}
if (actions.IsPressed(SampleAction.Pause)) if (actions.IsPressed(SampleAction.Pause))
{ {
context.Clock.TimeScale = context.Clock.TimeScale > 0f ? 0f : 1f; context.Clock.TimeScale = context.Clock.TimeScale > 0f ? 0f : 1f;
+20 -1
View File
@@ -3,6 +3,7 @@ using Microsoft.Xna.Framework;
using MrGameEng.Assets; using MrGameEng.Assets;
using MrGameEng.Audio; using MrGameEng.Audio;
using MrGameEng.Core; using MrGameEng.Core;
using MrGameEng.DevConsole;
using MrGameEng.Graphics; using MrGameEng.Graphics;
using MrGameEng.Input; using MrGameEng.Input;
using MrGameEng.UI; using MrGameEng.UI;
@@ -113,7 +114,25 @@ public sealed class MainScene : Scene
panel.Widgets.Add(switchButton); panel.Widgets.Add(switchButton);
desktop.Root = panel; desktop.Root = panel;
UpdateSystems.Add(new PlayerControlSystem(player, actions, audio, beep)); // Консоль разработчика (клавиша `) — последней, чтобы рисовалась поверх UI.
var console = this.UseDevConsole();
console.Register("stress", "switch to the stress scene", (_, _) =>
{
if (!Context.Scenes.IsTransitioning)
{
Context.Scenes.Switch(new StressScene(), Transition.Fade(0.8f));
}
});
console.Register("main", "switch to the main scene", (_, _) =>
{
if (!Context.Scenes.IsTransitioning)
{
Context.Scenes.Switch(new MainScene(), Transition.Fade(0.8f));
}
});
console.Register("beep", "play the beep sound", (_, _) => audio.Play(beep));
UpdateSystems.Add(new PlayerControlSystem(player, actions, audio, beep, console));
UpdateSystems.Add(new BounceSystem(WorldBounds)); UpdateSystems.Add(new BounceSystem(WorldBounds));
UpdateSystems.Add(new CameraControlSystem(camera, player, input)); UpdateSystems.Add(new CameraControlSystem(camera, player, input));
UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new StressScene(), Transition.Fade(0.8f))); UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new StressScene(), Transition.Fade(0.8f)));
@@ -2,6 +2,7 @@ using Friflo.Engine.ECS;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using MrGameEng.Assets; using MrGameEng.Assets;
using MrGameEng.Core; using MrGameEng.Core;
using MrGameEng.DevConsole;
using MrGameEng.Graphics; using MrGameEng.Graphics;
using MrGameEng.Input; using MrGameEng.Input;
using MrGameEng.UI; using MrGameEng.UI;
@@ -71,6 +72,8 @@ public sealed class StressScene : Scene
panel.Widgets.Add(backButton); panel.Widgets.Add(backButton);
desktop.Root = panel; desktop.Root = panel;
this.UseDevConsole(); // поверх UI
UpdateSystems.Add(new BounceSystem(WorldBounds)); UpdateSystems.Add(new BounceSystem(WorldBounds));
UpdateSystems.Add(new StressCameraSystem(camera, input)); UpdateSystems.Add(new StressCameraSystem(camera, input));
UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new MainScene(), Transition.Wipe(0.8f, Color.DarkSlateBlue))); UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new MainScene(), Transition.Wipe(0.8f, Color.DarkSlateBlue)));
+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; Current = _pending;
_pending = null; _pending = null;
Current?.Load(_context); Current?.Load(_context);
Log.Info($"Scene switched to {Current?.GetType().Name ?? "<none>"}");
} }
private static float Advance(float coverage, float direction, float duration, float deltaTime) => 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>
@@ -0,0 +1,147 @@
using System.Text;
using MrGameEng.Core;
using Xunit;
namespace MrGameEng.DevConsole.Tests;
public class DevConsoleTests
{
private static string Visible(DevConsole console, int lines = 50)
{
var sb = new StringBuilder();
console.BuildVisibleText(sb, lines);
return sb.ToString();
}
[Fact]
public void Execute_RunsRegisteredCommand_WithArguments()
{
using var console = new DevConsole();
string[]? received = null;
console.Register("test", "test command", (_, args) => received = args);
console.Execute("test one two");
Assert.NotNull(received);
Assert.Equal(["one", "two"], received!);
Assert.Contains("> test one two", Visible(console));
}
[Fact]
public void Execute_UnknownCommand_ReportsWithoutThrowing()
{
using var console = new DevConsole();
console.Execute("nosuchcommand");
Assert.Contains("unknown command 'nosuchcommand'", Visible(console));
}
[Fact]
public void Execute_HandlerException_IsCaughtAndLogged()
{
using var console = new DevConsole();
console.Register("boom", "throws", (_, _) => throw new InvalidOperationException("kaboom"));
console.Execute("boom");
Assert.Contains("[err] kaboom", Visible(console));
}
[Fact]
public void RingBuffer_OverflowDropsOldestLines()
{
using var console = new DevConsole(capacity: 4);
for (var i = 0; i < 10; i++)
{
console.WriteLine($"line{i}");
}
var visible = Visible(console);
Assert.DoesNotContain("line5", visible);
Assert.Contains("line6", visible);
Assert.Contains("line9", visible);
}
[Fact]
public void BuildVisibleText_RespectsWindowAndScroll()
{
using var console = new DevConsole();
for (var i = 0; i < 10; i++)
{
console.WriteLine($"line{i}");
}
var sb = new StringBuilder();
console.BuildVisibleText(sb, 3);
Assert.Equal("line7\nline8\nline9", sb.ToString());
console.Scroll(+2);
console.BuildVisibleText(sb, 3);
Assert.StartsWith("line5\nline6\nline7", sb.ToString());
}
[Fact]
public void History_NavigatesUpAndDown()
{
using var console = new DevConsole();
console.Register("a", "", (_, _) => { });
console.Execute("a 1");
console.Execute("a 2");
Assert.Equal("a 2", console.HistoryPrevious());
Assert.Equal("a 1", console.HistoryPrevious());
Assert.Equal("a 1", console.HistoryPrevious()); // упёрлись в начало
Assert.Equal("a 2", console.HistoryNext());
Assert.Equal("", console.HistoryNext()); // за последним — пустая строка
}
[Fact]
public void Complete_SingleMatch_CompletesWithTrailingSpace()
{
using var console = new DevConsole();
Assert.Equal("echo ", console.Complete("ec"));
}
[Fact]
public void Complete_MultipleMatches_ReturnsCommonPrefix_AndListsOptions()
{
using var console = new DevConsole();
console.Register("spawn", "", (_, _) => { });
console.Register("spawnall", "", (_, _) => { });
var completed = console.Complete("sp");
Assert.Equal("spawn", completed);
Assert.Contains("spawn spawnall", Visible(console));
}
[Fact]
public void LogMessages_AreCaptured_WithLevelTags()
{
using var console = new DevConsole();
Log.Info("plain info");
Log.Warning("careful");
var visible = Visible(console);
Assert.Contains("plain info", visible);
Assert.Contains("[warn] careful", visible);
}
[Fact]
public void Revision_ChangesOnlyOnVisibleChanges()
{
using var console = new DevConsole();
var before = console.Revision;
console.WriteLine("x");
Assert.NotEqual(before, console.Revision);
before = console.Revision;
_ = Visible(console); // чтение не меняет ревизию
Assert.Equal(before, console.Revision);
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MrGameEng.DevConsole\MrGameEng.DevConsole.csproj" />
</ItemGroup>
</Project>