diff --git a/CLAUDE.md b/CLAUDE.md index c065b94..dfaf5df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/MrGameEng.sln b/MrGameEng.sln index c525fa7..f3f4e1d 100644 --- a/MrGameEng.sln +++ b/MrGameEng.sln @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index 42dc1e8..3dc1df4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -34,6 +34,7 @@ | `MrGameEng.Assets` | Runtime-загрузка ресурсов без Content Pipeline, кэш, `AssetRef` | | `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` — ингейм-консоль (тогглинг клавишей `): + +- Перехватывает всё, что пишется через статический `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) (выбор: экосистема diff --git a/samples/MrGameEng.Sample/MrGameEng.Sample.csproj b/samples/MrGameEng.Sample/MrGameEng.Sample.csproj index 16e4820..35b622c 100644 --- a/samples/MrGameEng.Sample/MrGameEng.Sample.csproj +++ b/samples/MrGameEng.Sample/MrGameEng.Sample.csproj @@ -12,6 +12,7 @@ + diff --git a/samples/MrGameEng.Sample/SampleSystems.cs b/samples/MrGameEng.Sample/SampleSystems.cs index 045d914..454391c 100644 --- a/samples/MrGameEng.Sample/SampleSystems.cs +++ b/samples/MrGameEng.Sample/SampleSystems.cs @@ -12,10 +12,16 @@ namespace MrGameEng.Sample; /// Управление игроком (WASD/стрелки) и прыжок-писк на пробел. public sealed class PlayerControlSystem( - Entity player, ActionMap actions, AudioManager audio, SoundEffect beep) : BaseSystem + Entity player, ActionMap actions, AudioManager audio, SoundEffect beep, + MrGameEng.DevConsole.DevConsole console) : BaseSystem { protected override void OnUpdateGroup() { + if (console.IsOpen) + { + return; + } + ref var transform = ref player.GetComponent(); 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().IsOpen) + { + return; + } + if (actions.IsPressed(SampleAction.Pause)) { context.Clock.TimeScale = context.Clock.TimeScale > 0f ? 0f : 1f; diff --git a/samples/MrGameEng.Sample/Scenes/MainScene.cs b/samples/MrGameEng.Sample/Scenes/MainScene.cs index 8835203..1f4dea6 100644 --- a/samples/MrGameEng.Sample/Scenes/MainScene.cs +++ b/samples/MrGameEng.Sample/Scenes/MainScene.cs @@ -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))); diff --git a/samples/MrGameEng.Sample/Scenes/StressScene.cs b/samples/MrGameEng.Sample/Scenes/StressScene.cs index 51a88c0..d28a282 100644 --- a/samples/MrGameEng.Sample/Scenes/StressScene.cs +++ b/samples/MrGameEng.Sample/Scenes/StressScene.cs @@ -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))); diff --git a/src/MrGameEng.Core/Log.cs b/src/MrGameEng.Core/Log.cs new file mode 100644 index 0000000..5ade547 --- /dev/null +++ b/src/MrGameEng.Core/Log.cs @@ -0,0 +1,45 @@ +namespace MrGameEng.Core; + +/// Severity of a log message. +public enum LogLevel +{ + /// Verbose diagnostics. + Debug, + + /// Normal informational message. + Info, + + /// Something suspicious but recoverable. + Warning, + + /// An error. + Error, +} + +/// +/// 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. +/// +public static class Log +{ + /// Raised for every message. May be invoked from any thread. + public static event Action? MessageLogged; + + /// Logs a debug message. + public static void Debug(string message) => Write(LogLevel.Debug, message); + + /// Logs an informational message. + public static void Info(string message) => Write(LogLevel.Info, message); + + /// Logs a warning. + public static void Warning(string message) => Write(LogLevel.Warning, message); + + /// Logs an error. + 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); + } +} diff --git a/src/MrGameEng.Core/SceneManager.cs b/src/MrGameEng.Core/SceneManager.cs index 6cb9fff..5199910 100644 --- a/src/MrGameEng.Core/SceneManager.cs +++ b/src/MrGameEng.Core/SceneManager.cs @@ -111,6 +111,7 @@ public sealed class SceneManager Current = _pending; _pending = null; Current?.Load(_context); + Log.Info($"Scene switched to {Current?.GetType().Name ?? ""}"); } private static float Advance(float coverage, float direction, float duration, float deltaTime) => diff --git a/src/MrGameEng.DevConsole/DevConsole.cs b/src/MrGameEng.DevConsole/DevConsole.cs new file mode 100644 index 0000000..8e8d384 --- /dev/null +++ b/src/MrGameEng.DevConsole/DevConsole.cs @@ -0,0 +1,246 @@ +using System.Text; +using MrGameEng.Core; + +namespace MrGameEng.DevConsole; + +/// Handler of a console command. excludes the command name. +public delegate void ConsoleCommand(DevConsole console, string[] args); + +/// +/// The developer console core: a fixed ring buffer of log lines, a command registry, +/// input history and prefix autocompletion. Pure logic — rendering lives in +/// ; this class is fully testable headless. +/// Captures everything written through . Thread-safe for writes. +/// +public sealed class DevConsole : IDisposable +{ + private readonly object _sync = new(); + private readonly string[] _lines; + private readonly Dictionary _commands = + new(StringComparer.OrdinalIgnoreCase); + private readonly List _history = []; + private int _head; + private int _count; + private int _historyCursor; + private int _scrollOffset; + + /// True while the console overlay is visible. + public bool IsOpen { get; private set; } + + /// Increments on every visible change — the UI rebuilds its text only when this moves. + public int Revision { get; private set; } + + /// Lines scrolled up from the bottom of the log. + public int ScrollOffset => _scrollOffset; + + /// Creates a console holding up to log lines. + 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; + } + + /// Stops capturing messages. + public void Dispose() => Log.MessageLogged -= OnLogMessage; + + /// Opens or closes the console overlay. + public void Toggle() + { + IsOpen = !IsOpen; + Revision++; + } + + /// Registers (or replaces) a command. Name matching is case-insensitive. + public void Register(string name, string description, ConsoleCommand handler) => + _commands[name] = (description, handler); + + /// Appends a line to the log. + 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++; + } + } + + /// Removes all log lines. + public void Clear() + { + lock (_sync) + { + _head = 0; + _count = 0; + _scrollOffset = 0; + Revision++; + } + } + + /// + /// Echoes and executes one input line. Unknown commands and handler exceptions + /// are reported into the log, never thrown. + /// + 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}"); + } + } + + /// Steps back through input history; null when there is none. + public string? HistoryPrevious() + { + if (_history.Count == 0) + { + return null; + } + + _historyCursor = Math.Max(0, _historyCursor - 1); + return _history[_historyCursor]; + } + + /// Steps forward through input history; empty string past the newest entry. + public string? HistoryNext() + { + if (_history.Count == 0) + { + return null; + } + + _historyCursor = Math.Min(_history.Count, _historyCursor + 1); + return _historyCursor == _history.Count ? string.Empty : _history[_historyCursor]; + } + + /// + /// 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. + /// + 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; + } + } + + /// Scrolls the log view; positive = older lines. Clamped to the buffer. + public void Scroll(int deltaLines) + { + lock (_sync) + { + _scrollOffset = Math.Clamp(_scrollOffset + deltaLines, 0, Math.Max(0, _count - 1)); + Revision++; + } + } + + /// + /// Writes the visible window of the log (respecting scroll) into , + /// most recent at the bottom. + /// + 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", + }; +} diff --git a/src/MrGameEng.DevConsole/DevConsoleSystems.cs b/src/MrGameEng.DevConsole/DevConsoleSystems.cs new file mode 100644 index 0000000..68d58c0 --- /dev/null +++ b/src/MrGameEng.DevConsole/DevConsoleSystems.cs @@ -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; + +/// Myra overlay of the console: translucent top panel with the log view and an input line. +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; + } +} + +/// +/// 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. +/// +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; + } + + /// + 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; + } +} + +/// Draw-phase system rendering the console overlay. Registered last — on top of everything. +public sealed class DevConsoleRenderSystem : BaseSystem +{ + private readonly DevConsoleUi _ui; + + internal DevConsoleRenderSystem(DevConsoleUi ui) => _ui = ui; + + /// + protected override void OnUpdateGroup() => _ui.Render(); +} + +/// Wires the developer console into a . +public static class SceneDevConsoleExtensions +{ + /// + /// Returns the shared service (created on first use, together + /// with the built-in engine commands) and registers its systems in this scene. + /// Call from OnLoad after all other UI so the console draws on top. + /// Toggle with the backquote (`) key. + /// + public static DevConsole UseDevConsole(this Scene scene) + { + var services = scene.Context.Services; + var console = services.GetOrDefault(); + if (console is null) + { + MyraEnvironment.Game = services.Get(); + 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(); + 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().Exit()); + } +} diff --git a/src/MrGameEng.DevConsole/MrGameEng.DevConsole.csproj b/src/MrGameEng.DevConsole/MrGameEng.DevConsole.csproj new file mode 100644 index 0000000..b87d0bc --- /dev/null +++ b/src/MrGameEng.DevConsole/MrGameEng.DevConsole.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + + + + + + + + + + + diff --git a/tests/MrGameEng.DevConsole.Tests/DevConsoleTests.cs b/tests/MrGameEng.DevConsole.Tests/DevConsoleTests.cs new file mode 100644 index 0000000..977e871 --- /dev/null +++ b/tests/MrGameEng.DevConsole.Tests/DevConsoleTests.cs @@ -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); + } +} diff --git a/tests/MrGameEng.DevConsole.Tests/MrGameEng.DevConsole.Tests.csproj b/tests/MrGameEng.DevConsole.Tests/MrGameEng.DevConsole.Tests.csproj new file mode 100644 index 0000000..8909ee9 --- /dev/null +++ b/tests/MrGameEng.DevConsole.Tests/MrGameEng.DevConsole.Tests.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + false + + + + + + + + + + + + +