Remove obsolete project files for MrGameEng.AI, MrGameEng.Assets, MrGameEng.Atlases, and MrGameEng.Collisions modules. Introduce new AssetManager and related classes for asset loading and management, including support for texture atlases and mod definitions. Enhance mod loading capabilities with DefDatabase and LanguageManager for JSON-based definitions and localization. Implement a shelf packing algorithm for efficient texture atlas creation.

This commit is contained in:
Leonid Pershin
2026-06-12 07:47:04 +03:00
parent 1f87fb0b74
commit c30e2ce764
70 changed files with 0 additions and 233 deletions
+324
View File
@@ -0,0 +1,324 @@
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 const int MaxHistory = 256;
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()
{
lock (_sync) // Revision инкрементируют и фоновые WriteLine — RMW только под локом
{
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);
if (_history.Count > MaxHistory)
{
_history.RemoveAt(0);
}
}
_historyCursor = _history.Count;
lock (_sync)
{
_scrollOffset = 0;
}
var parts = SplitArguments(input);
if (parts.Length == 0)
{
return;
}
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 —");
}
}
}
/// <summary>
/// Splits a command line on whitespace; double quotes group words into one argument
/// (<c>say "hello world"</c> → <c>say</c>, <c>hello world</c>). Unterminated quotes
/// run to the end of the line.
/// </summary>
internal static string[] SplitArguments(string input)
{
var parts = new List<string>();
var current = new StringBuilder();
var quoted = false;
var hasToken = false;
foreach (var c in input)
{
if (c == '"')
{
quoted = !quoted;
hasToken = true; // "" — пустой аргумент тоже аргумент
}
else if (char.IsWhiteSpace(c) && !quoted)
{
if (hasToken)
{
parts.Add(current.ToString());
current.Clear();
hasToken = false;
}
}
else
{
current.Append(c);
hasToken = true;
}
}
if (hasToken)
{
parts.Add(current.ToString());
}
return parts.ToArray();
}
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",
};
}
+242
View File
@@ -0,0 +1,242 @@
using System.Text;
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
using MrGameEng.Core;
using Myra;
using Myra.Graphics2D;
using Myra.Graphics2D.Brushes;
using Myra.Graphics2D.UI;
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. While the console is open, <see cref="InputCapture.Captured"/> is held,
/// suppressing game-facing input (the Input module and scene UI honor it).
/// </summary>
public sealed class DevConsoleSystem : BaseSystem
{
private readonly DevConsole _console;
private readonly DevConsoleUi _ui;
private readonly InputCapture _capture;
private KeyboardState _previousKeyboard;
private int _previousWheel;
internal DevConsoleSystem(DevConsole console, DevConsoleUi ui, InputCapture capture)
{
_console = console;
_ui = ui;
_capture = capture;
}
/// <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;
_capture.Captured = _console.IsOpen;
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 capture = services.GetOrDefault<InputCapture>();
if (capture is null)
{
capture = new InputCapture();
services.Add(capture);
}
var ui = services.Get<DevConsoleUi>();
scene.UpdateSystems.Insert(0, new DevConsoleSystem(console, ui, capture));
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());
}
}