feat(godot): add configurable logger templates

This commit is contained in:
GeWuYou 2026-05-02 19:33:00 +08:00
parent 6aa741114f
commit 36e1ae5f32
8 changed files with 597 additions and 66 deletions

View File

@ -0,0 +1,110 @@
using System;
using GFramework.Core.Abstractions.Logging;
using GFramework.Godot.Logging;
namespace GFramework.Godot.Tests.Logging;
[TestFixture]
public sealed class GodotLogTemplateTests
{
[Test]
public void Render_Should_Format_Timestamp_Level_Color_Category_And_Message()
{
var template = GodotLogTemplate.Parse("[{timestamp:yyyyMMdd}] [color={color}]{level:u3}[/color] [{category:l16}] {message}");
var context = new GodotLogRenderContext(
new DateTime(2026, 5, 2, 1, 2, 3, DateTimeKind.Utc),
LogLevel.Warning,
"Game.Services.Inventory",
"Loaded",
"orange");
var result = template.Render(context);
Assert.That(result, Is.EqualTo("[20260502] [color=orange]WRN[/color] [G.S.Inventory ] Loaded"));
}
[Test]
public void Render_Should_Support_Lowercase_Level_Format()
{
var template = GodotLogTemplate.Parse("{level:l3}:{message}");
var context = new GodotLogRenderContext(
new DateTime(2026, 5, 2, 1, 2, 3, DateTimeKind.Utc),
LogLevel.Debug,
"Game",
"Ready",
"cyan");
var result = template.Render(context);
Assert.That(result, Is.EqualTo("dbg:Ready"));
}
[Test]
public void Render_Should_Right_Align_Category()
{
var template = GodotLogTemplate.Parse("[{category:r10}]");
var context = new GodotLogRenderContext(
new DateTime(2026, 5, 2, 1, 2, 3, DateTimeKind.Utc),
LogLevel.Info,
"UI",
"Ready",
"white");
var result = template.Render(context);
Assert.That(result, Is.EqualTo("[ UI]"));
}
[Test]
public void Render_Should_Preserve_Unknown_Placeholders()
{
var template = GodotLogTemplate.Parse("{message} {unknown}");
var context = new GodotLogRenderContext(
new DateTime(2026, 5, 2, 1, 2, 3, DateTimeKind.Utc),
LogLevel.Info,
"Game",
"Ready",
"white");
var result = template.Render(context);
Assert.That(result, Is.EqualTo("Ready {unknown}"));
}
[Test]
public void Render_Should_Format_Padded_Level()
{
var template = GodotLogTemplate.Parse("{level:padded}");
var context = new GodotLogRenderContext(
new DateTime(2026, 5, 2, 1, 2, 3, DateTimeKind.Utc),
LogLevel.Info,
"Game",
"Ready",
"white");
var result = template.Render(context);
Assert.That(result, Is.EqualTo("INFO "));
}
[Test]
public void Options_ForMinimumLevel_Should_Preserve_Fixed_Minimum_Level()
{
var options = GodotLoggerOptions.ForMinimumLevel(LogLevel.Warning);
Assert.That(options.Mode, Is.EqualTo(GodotLoggerMode.Debug));
Assert.That(options.DebugMinLevel, Is.EqualTo(LogLevel.Warning));
Assert.That(options.ReleaseMinLevel, Is.EqualTo(LogLevel.Warning));
}
[Test]
public void Options_Should_Use_Default_Color_When_Configured_Color_Is_Missing()
{
var options = new GodotLoggerOptions();
options.Colors.Remove(LogLevel.Error);
var result = options.GetColor(LogLevel.Error);
Assert.That(result, Is.EqualTo("red"));
}
}

View File

@ -0,0 +1,11 @@
using System;
using GFramework.Core.Abstractions.Logging;
namespace GFramework.Godot.Logging;
internal readonly record struct GodotLogRenderContext(
DateTime Timestamp,
LogLevel Level,
string Category,
string Message,
string Color);

View File

@ -0,0 +1,226 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using GFramework.Core.Abstractions.Logging;
namespace GFramework.Godot.Logging;
internal sealed class GodotLogTemplate
{
private static readonly ConcurrentDictionary<string, GodotLogTemplate> Cache = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, string> _categoryCache = new(StringComparer.Ordinal);
private readonly int _literalLength;
private readonly Action<StringBuilder, GodotLogRenderContext>[] _segments;
private GodotLogTemplate(string template)
{
(_segments, _literalLength) = ParseCore(template);
}
public static GodotLogTemplate Parse(string template)
{
ArgumentNullException.ThrowIfNull(template);
return Cache.GetOrAdd(template, static value => new GodotLogTemplate(value));
}
public string Render(GodotLogRenderContext context)
{
var builder = new StringBuilder(_literalLength + context.Category.Length + context.Message.Length + 48);
foreach (var segment in _segments)
{
segment(builder, context);
}
return builder.ToString();
}
private (Action<StringBuilder, GodotLogRenderContext>[] Segments, int LiteralLength) ParseCore(string template)
{
var segments = new List<Action<StringBuilder, GodotLogRenderContext>>();
var literalLength = 0;
var position = 0;
while (position < template.Length)
{
var open = template.IndexOf('{', position);
if (open < 0)
{
AddLiteral(template[position..]);
break;
}
if (open > position)
{
AddLiteral(template[position..open]);
}
var close = template.IndexOf('}', open + 1);
if (close < 0)
{
AddLiteral(template[open..]);
break;
}
var key = template.Substring(open + 1, close - open - 1);
segments.Add(CreateSegment(key));
position = close + 1;
}
return ([.. segments], literalLength);
void AddLiteral(string literal)
{
if (literal.Length == 0)
{
return;
}
literalLength += literal.Length;
segments.Add((builder, _) => builder.Append(literal));
}
}
private Action<StringBuilder, GodotLogRenderContext> CreateSegment(string key)
{
return key switch
{
"category" => static (builder, context) => builder.Append(context.Category),
"color" => static (builder, context) => builder.Append(context.Color),
"level" => static (builder, context) => builder.Append(context.Level),
"message" => static (builder, context) => builder.Append(context.Message),
"timestamp" => static (builder, context) => builder.Append(context.Timestamp.ToString(
"yyyy-MM-dd HH:mm:ss.fff",
CultureInfo.InvariantCulture)),
not null when key.StartsWith("category:", StringComparison.Ordinal) => CreateCategorySegment(key[9..]),
not null when key.StartsWith("level:", StringComparison.Ordinal) => CreateLevelSegment(key[6..]),
not null when key.StartsWith("timestamp:", StringComparison.Ordinal) => CreateTimestampSegment(key[10..]),
_ => (builder, _) => builder.Append('{').Append(key).Append('}')
};
}
private Action<StringBuilder, GodotLogRenderContext> CreateTimestampSegment(string format)
{
if (string.IsNullOrWhiteSpace(format))
{
return static (builder, context) => builder.Append(context.Timestamp.ToString(
"yyyy-MM-dd HH:mm:ss.fff",
CultureInfo.InvariantCulture));
}
return (builder, context) => builder.Append(context.Timestamp.ToString(format, CultureInfo.InvariantCulture));
}
private static Action<StringBuilder, GodotLogRenderContext> CreateLevelSegment(string format)
{
return format switch
{
"u3" or "U3" => static (builder, context) => builder.Append(ToShortLevel(context.Level, upper: true)),
"l3" or "L3" => static (builder, context) => builder.Append(ToShortLevel(context.Level, upper: false)),
"padded" or "Padded" => static (builder, context) => builder.Append(ToPaddedLevel(context.Level)),
_ => static (builder, context) => builder.Append(context.Level)
};
}
private Action<StringBuilder, GodotLogRenderContext> CreateCategorySegment(string format)
{
if (format.Length < 2)
{
return static (builder, context) => builder.Append(context.Category);
}
var alignment = format[0];
if (alignment is not 'l' and not 'r')
{
return static (builder, context) => builder.Append(context.Category);
}
if (!int.TryParse(format[1..], NumberStyles.None, CultureInfo.InvariantCulture, out var width) || width <= 0)
{
return static (builder, context) => builder.Append(context.Category);
}
return alignment == 'l'
? (builder, context) => builder.Append(GetFormattedCategory(context.Category, format, width, padLeft: false))
: (builder, context) => builder.Append(GetFormattedCategory(context.Category, format, width, padLeft: true));
}
private string GetFormattedCategory(string category, string format, int width, bool padLeft)
{
var cacheKey = string.Concat(format, "\0", category);
return _categoryCache.GetOrAdd(cacheKey, _ =>
{
var abbreviated = AbbreviateCategory(category, width);
return padLeft ? abbreviated.PadLeft(width) : abbreviated.PadRight(width);
});
}
private static string AbbreviateCategory(string category, int maxLength)
{
if (category.Length <= maxLength)
{
return category;
}
var parts = category.Split('.');
if (parts.Length == 1)
{
return category[..maxLength];
}
for (var i = 0; i < parts.Length - 1; i++)
{
if (parts[i].Length > 1)
{
parts[i] = parts[i][..1];
}
}
var start = 0;
while (start < parts.Length - 1)
{
var joined = string.Join(".", parts, start, parts.Length - start);
if (joined.Length <= maxLength)
{
return joined;
}
start++;
}
var last = parts[^1];
return last.Length > maxLength ? last[..maxLength] : last;
}
private static string ToShortLevel(LogLevel level, bool upper)
{
var value = level switch
{
LogLevel.Trace => "trc",
LogLevel.Debug => "dbg",
LogLevel.Info => "inf",
LogLevel.Warning => "wrn",
LogLevel.Error => "err",
LogLevel.Fatal => "ftl",
_ => "unk"
};
return upper ? value.ToUpperInvariant() : value;
}
private static string ToPaddedLevel(LogLevel level)
{
return level switch
{
LogLevel.Trace => "TRACE ",
LogLevel.Debug => "DEBUG ",
LogLevel.Info => "INFO ",
LogLevel.Warning => "WARNING",
LogLevel.Error => "ERROR ",
LogLevel.Fatal => "FATAL ",
_ => level.ToString()
};
}
}

View File

@ -1,4 +1,4 @@
using System.Globalization;
using System;
using GFramework.Core.Abstractions.Logging;
using GFramework.Core.Logging;
using Godot;
@ -6,69 +6,79 @@ using Godot;
namespace GFramework.Godot.Logging;
/// <summary>
/// Godot平台的日志记录器实现。
/// 该类继承自 <see cref="AbstractLogger"/>,用于在 Godot 引擎中输出日志信息。
/// 支持不同日志级别的输出,并根据级别调用 Godot 的相应方法。
/// Godot platform logger implementation.
/// </summary>
/// <param name="name">日志记录器的名称,默认为根日志记录器名称。</param>
/// <param name="minLevel">最低日志级别,默认为 <see cref="LogLevel.Info"/>。</param>
public sealed class GodotLogger(
string? name = null,
LogLevel minLevel = LogLevel.Info) : AbstractLogger(name ?? RootLoggerName, minLevel)
public sealed class GodotLogger : AbstractLogger
{
// 静态缓存日志级别字符串,避免重复格式化
private static readonly string[] LevelStrings =
[
"TRACE ",
"DEBUG ",
"INFO ",
"WARNING",
"ERROR ",
"FATAL "
];
private readonly GodotLoggerOptions _options;
/// <summary>
/// 写入日志的核心方法。
/// 格式化日志消息并根据日志级别调用 Godot 的输出方法。
/// Initializes a logger that preserves the historical fixed-format template.
/// </summary>
/// <param name="level">日志级别。</param>
/// <param name="message">日志消息内容。</param>
/// <param name="exception">可选的异常信息。</param>
/// <param name="name">The logger name.</param>
/// <param name="minLevel">The minimum enabled log level.</param>
public GodotLogger(string? name = null, LogLevel minLevel = LogLevel.Info)
: this(name, GodotLoggerOptions.ForMinimumLevel(minLevel))
{
}
/// <summary>
/// Initializes a logger with Godot-specific formatting options.
/// </summary>
/// <param name="name">The logger name.</param>
/// <param name="options">The logger options.</param>
public GodotLogger(string? name, GodotLoggerOptions options)
: base(name ?? RootLoggerName, (options ?? throw new ArgumentNullException(nameof(options))).GetEffectiveMinLevel())
{
_options = options;
}
/// <summary>
/// Writes a log entry to Godot.
/// </summary>
/// <param name="level">The log level.</param>
/// <param name="message">The rendered message body.</param>
/// <param name="exception">The optional exception.</param>
protected override void Write(LogLevel level, string message, Exception? exception)
{
// 构造时间戳和日志前缀
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
var levelStr = LevelStrings[(int)level];
var logPrefix = $"[{timestamp}] {levelStr} [{Name()}]";
var templateText = _options.Mode == GodotLoggerMode.Debug
? _options.DebugOutputTemplate
: _options.ReleaseOutputTemplate;
var context = new GodotLogRenderContext(
DateTime.UtcNow,
level,
Name(),
message,
_options.GetColor(level));
var rendered = GodotLogTemplate.Parse(templateText).Render(context);
// 添加异常信息到日志消息中
if (exception != null) message += "\n" + exception;
if (_options.Mode == GodotLoggerMode.Debug)
{
WriteDebug(level, rendered);
}
else
{
GD.Print(rendered);
}
var logMessage = $"{logPrefix} {message}";
if (exception != null)
{
GD.PrintErr(exception.ToString());
}
}
private static void WriteDebug(LogLevel level, string rendered)
{
GD.PrintRich(rendered);
// 根据日志级别选择 Godot 输出方法
switch (level)
{
case LogLevel.Fatal:
GD.PushError(logMessage);
break;
case LogLevel.Error:
GD.PrintErr(logMessage);
GD.PushError(rendered);
break;
case LogLevel.Warning:
GD.PushWarning(logMessage);
break;
case LogLevel.Trace:
GD.PrintRich($"[color=gray]{logMessage}[/color]");
break;
case LogLevel.Debug:
GD.PrintRich($"[color=cyan]{logMessage}[/color]");
break;
case LogLevel.Info:
GD.Print(logMessage);
break;
default:
GD.Print(logMessage);
GD.PushWarning(rendered);
break;
}
}

View File

@ -1,20 +1,46 @@
using GFramework.Core.Abstractions.Logging;
using System;
using GFramework.Core.Abstractions.Logging;
namespace GFramework.Godot.Logging;
/// <summary>
/// Godot日志工厂类用于创建Godot平台专用的日志记录器实例
/// Creates Godot platform logger instances.
/// </summary>
public class GodotLoggerFactory : ILoggerFactory
public sealed class GodotLoggerFactory : ILoggerFactory
{
private readonly GodotLoggerOptions? _options;
/// <summary>
/// 获取指定名称的日志记录器实例
/// Initializes a factory that preserves the historical fixed-format logger behavior.
/// </summary>
/// <param name="name">日志记录器的名称</param>
/// <param name="minLevel">日志记录器的最小日志级别</param>
/// <returns>返回GodotLogger类型的日志记录器实例</returns>
public GodotLoggerFactory()
{
}
/// <summary>
/// Initializes a factory with Godot-specific formatting options.
/// </summary>
/// <param name="options">The logger options.</param>
public GodotLoggerFactory(GodotLoggerOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
/// <summary>
/// Gets a logger with the specified name.
/// </summary>
/// <param name="name">The logger name.</param>
/// <param name="minLevel">The minimum enabled level.</param>
/// <returns>A Godot logger instance.</returns>
public ILogger GetLogger(string name, LogLevel minLevel = LogLevel.Info)
{
return new GodotLogger(name, minLevel);
ArgumentNullException.ThrowIfNull(name);
if (_options == null)
{
return new GodotLogger(name, minLevel);
}
return new GodotLogger(name, _options.WithMinimumLevelFloor(minLevel));
}
}
}

View File

@ -1,35 +1,51 @@
using GFramework.Core.Abstractions.Logging;
using System;
using GFramework.Core.Abstractions.Logging;
using GFramework.Core.Logging;
namespace GFramework.Godot.Logging;
/// <summary>
/// Godot日志工厂提供程序用于创建Godot日志记录器实例
/// Provides cached Godot logger instances.
/// </summary>
public sealed class GodotLoggerFactoryProvider : ILoggerFactoryProvider
{
private readonly ILoggerFactory _cachedFactory;
/// <summary>
/// 初始化Godot日志记录器工厂提供程序
/// Initializes a Godot logger provider with the default logger factory.
/// </summary>
public GodotLoggerFactoryProvider()
{
_cachedFactory = new CachedLoggerFactory(new GodotLoggerFactory());
_cachedFactory = CreateCachedFactory(new GodotLoggerFactory());
}
/// <summary>
/// 获取或设置最小日志级别
/// Initializes a Godot logger provider with Godot-specific formatting options.
/// </summary>
/// <param name="options">The logger options.</param>
public GodotLoggerFactoryProvider(GodotLoggerOptions options)
{
_cachedFactory = CreateCachedFactory(new GodotLoggerFactory(options));
}
/// <summary>
/// Gets or sets the provider minimum level.
/// </summary>
public LogLevel MinLevel { get; set; }
/// <summary>
/// 创建指定名称的日志记录器实例(带缓存)
/// Creates a cached logger with the specified name.
/// </summary>
/// <param name="name">日志记录器的名称</param>
/// <returns>返回配置了最小日志级别的Godot日志记录器实例</returns>
/// <param name="name">The logger name.</param>
/// <returns>A logger configured with <see cref="MinLevel"/>.</returns>
public ILogger CreateLogger(string name)
{
return _cachedFactory.GetLogger(name, MinLevel);
}
}
private static ILoggerFactory CreateCachedFactory(ILoggerFactory innerFactory)
{
ArgumentNullException.ThrowIfNull(innerFactory);
return new CachedLoggerFactory(innerFactory);
}
}

View File

@ -0,0 +1,17 @@
namespace GFramework.Godot.Logging;
/// <summary>
/// Selects the Godot logger output behavior.
/// </summary>
public enum GodotLoggerMode
{
/// <summary>
/// Uses rich BBCode console output and mirrors warnings/errors to the Godot debugger panel.
/// </summary>
Debug,
/// <summary>
/// Uses plain console output without rich text or debugger panel mirroring.
/// </summary>
Release
}

View File

@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using GFramework.Core.Abstractions.Logging;
namespace GFramework.Godot.Logging;
/// <summary>
/// Godot logger formatting and routing options.
/// </summary>
public sealed class GodotLoggerOptions
{
private static readonly IReadOnlyDictionary<LogLevel, string> DefaultColors = new Dictionary<LogLevel, string>
{
[LogLevel.Trace] = "gray",
[LogLevel.Debug] = "cyan",
[LogLevel.Info] = "white",
[LogLevel.Warning] = "orange",
[LogLevel.Error] = "red",
[LogLevel.Fatal] = "deep_pink"
};
/// <summary>
/// Gets or sets the output mode.
/// </summary>
public GodotLoggerMode Mode { get; set; } = GodotLoggerMode.Debug;
/// <summary>
/// Gets or sets the minimum level used by <see cref="GodotLoggerMode.Debug"/>.
/// </summary>
public LogLevel DebugMinLevel { get; set; } = LogLevel.Debug;
/// <summary>
/// Gets or sets the minimum level used by <see cref="GodotLoggerMode.Release"/>.
/// </summary>
public LogLevel ReleaseMinLevel { get; set; } = LogLevel.Info;
/// <summary>
/// Gets or sets the BBCode-capable template used by <see cref="GodotLoggerMode.Debug"/>.
/// </summary>
#pragma warning disable MA0016 // Keep configuration mutable for object initializer and serializer scenarios.
public string DebugOutputTemplate { get; set; } =
"[{timestamp:yyyy-MM-dd HH:mm:ss.fff}] [color={color}][{level:u3}][/color] [{category:l16}] {message}";
#pragma warning restore MA0016
/// <summary>
/// Gets or sets the plain text template used by <see cref="GodotLoggerMode.Release"/>.
/// </summary>
#pragma warning disable MA0016 // Keep configuration mutable for object initializer and serializer scenarios.
public string ReleaseOutputTemplate { get; set; } =
"[{timestamp:yyyy-MM-dd HH:mm:ss.fff}] [{level:u3}] [{category:l16}] {message}";
#pragma warning restore MA0016
/// <summary>
/// Gets or sets Godot named colors by log level.
/// </summary>
#pragma warning disable MA0016 // Keep configuration mutable for object initializer and serializer scenarios.
public Dictionary<LogLevel, string> Colors { get; set; } = new(DefaultColors);
#pragma warning restore MA0016
/// <summary>
/// Creates options that preserve the previous Godot logger defaults for a fixed minimum level.
/// </summary>
/// <param name="minLevel">The minimum enabled level.</param>
/// <returns>Options equivalent to the previous fixed-format logger behavior.</returns>
public static GodotLoggerOptions ForMinimumLevel(LogLevel minLevel)
{
return new GodotLoggerOptions
{
Mode = GodotLoggerMode.Debug,
DebugMinLevel = minLevel,
ReleaseMinLevel = minLevel,
DebugOutputTemplate = "[{timestamp:yyyy-MM-dd HH:mm:ss.fff}] {level:padded} [{category}] {message}",
ReleaseOutputTemplate = "[{timestamp:yyyy-MM-dd HH:mm:ss.fff}] {level:padded} [{category}] {message}",
Colors = new Dictionary<LogLevel, string>(DefaultColors)
};
}
/// <summary>
/// Returns the configured color for the specified level.
/// </summary>
/// <param name="level">The level.</param>
/// <returns>The Godot named color.</returns>
public string GetColor(LogLevel level)
{
if (Colors.TryGetValue(level, out var color) && !string.IsNullOrWhiteSpace(color))
{
return color;
}
return DefaultColors[level];
}
internal LogLevel GetEffectiveMinLevel()
{
return Mode == GodotLoggerMode.Debug ? DebugMinLevel : ReleaseMinLevel;
}
internal GodotLoggerOptions WithMinimumLevelFloor(LogLevel minLevel)
{
return new GodotLoggerOptions
{
Mode = Mode,
DebugMinLevel = Max(DebugMinLevel, minLevel),
ReleaseMinLevel = Max(ReleaseMinLevel, minLevel),
DebugOutputTemplate = DebugOutputTemplate,
ReleaseOutputTemplate = ReleaseOutputTemplate,
Colors = new Dictionary<LogLevel, string>(Colors)
};
}
private static LogLevel Max(LogLevel left, LogLevel right)
{
return left > right ? left : right;
}
}