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
|
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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
|||||||
@@ -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;
|
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) =>
|
||||||
|
|||||||
@@ -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