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
@@ -19,7 +19,8 @@ docs/ architecture, conventions, roadmap (Russian)
|
||||
Engine modules: `Core` (game loop, ECS world, scenes, time), `Graphics` (custom batched
|
||||
renderer, camera, sprites), `Input`, `Audio`, `Assets` (runtime loading, no content
|
||||
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
|
||||
MonoGame and Friflo.Engine.ECS. `Assets.Generator` is a netstandard2.0 analyzer.
|
||||
Documented exception: Myra renders with its own SpriteBatch internally.
|
||||
|
||||
@@ -33,6 +33,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Input.Tests", "te
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.UI", "src\MrGameEng.UI\MrGameEng.UI.csproj", "{17EB97D5-DCF8-47DF-B810-DA45AE314170}"
|
||||
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
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
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|x86.ActiveCfg = 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
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -204,5 +232,7 @@ Global
|
||||
{D55753A2-4CC0-4BCC-80D5-01E919FF97BE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{0E0710AB-6132-4E64-9AFC-03B0601F92C6} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{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
|
||||
EndGlobal
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
| `MrGameEng.Assets` | Runtime-загрузка ресурсов без Content Pipeline, кэш, `AssetRef<T>` |
|
||||
| `MrGameEng.Assets.Generator` | Roslyn incremental source generator: классы с типизированными хендлами ресурсов |
|
||||
| `MrGameEng.UI` | Игровой UI на [Myra](https://github.com/rds1983/Myra): `Desktop` на сцену, виджеты, скининг |
|
||||
| `MrGameEng.DevConsole` | Ингейм-консоль разработчика: логи `Log`, команды, история, автодополнение |
|
||||
|
||||
Планируемые модули (по мере развития): `Physics2D`, `Tilemap`, `UI`, `Particles`.
|
||||
|
||||
@@ -53,6 +54,22 @@ MrGameEng.Assets ─┘ └──► Friflo.Engine.ECS
|
||||
он подключается к проекту игры как `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](https://github.com/rds1983/Myra) (выбор: экосистема
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<ProjectReference Include="..\..\src\MrGameEng.Audio\MrGameEng.Audio.csproj" />
|
||||
<ProjectReference Include="..\..\src\MrGameEng.Assets\MrGameEng.Assets.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"
|
||||
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -12,10 +12,16 @@ namespace MrGameEng.Sample;
|
||||
|
||||
/// <summary>Управление игроком (WASD/стрелки) и прыжок-писк на пробел.</summary>
|
||||
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()
|
||||
{
|
||||
if (console.IsOpen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ref var transform = ref player.GetComponent<Transform2D>();
|
||||
var move = new Vector2(
|
||||
actions.GetAxis(SampleAction.MoveLeft, SampleAction.MoveRight),
|
||||
@@ -131,6 +137,11 @@ public sealed class SceneHotkeysSystem(
|
||||
{
|
||||
protected override void OnUpdateGroup()
|
||||
{
|
||||
if (context.Services.Get<MrGameEng.DevConsole.DevConsole>().IsOpen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (actions.IsPressed(SampleAction.Pause))
|
||||
{
|
||||
context.Clock.TimeScale = context.Clock.TimeScale > 0f ? 0f : 1f;
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Assets;
|
||||
using MrGameEng.Audio;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.DevConsole;
|
||||
using MrGameEng.Graphics;
|
||||
using MrGameEng.Input;
|
||||
using MrGameEng.UI;
|
||||
@@ -113,7 +114,25 @@ public sealed class MainScene : Scene
|
||||
panel.Widgets.Add(switchButton);
|
||||
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 CameraControlSystem(camera, player, input));
|
||||
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 MrGameEng.Assets;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.DevConsole;
|
||||
using MrGameEng.Graphics;
|
||||
using MrGameEng.Input;
|
||||
using MrGameEng.UI;
|
||||
@@ -71,6 +72,8 @@ public sealed class StressScene : Scene
|
||||
panel.Widgets.Add(backButton);
|
||||
desktop.Root = panel;
|
||||
|
||||
this.UseDevConsole(); // поверх UI
|
||||
|
||||
UpdateSystems.Add(new BounceSystem(WorldBounds));
|
||||
UpdateSystems.Add(new StressCameraSystem(camera, input));
|
||||
UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new MainScene(), Transition.Wipe(0.8f, Color.DarkSlateBlue)));
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user