mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-03-23 03:04:29 +08:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf486cbeff | ||
|
|
8d656b90a7 | ||
|
|
fc386fb4bc | ||
|
|
bbf1dc8d0c | ||
|
|
b95c65a30e | ||
|
|
9ab09cf47b | ||
|
|
63b1d71a0e | ||
|
|
cdc49c319a | ||
|
|
d94d8deb29 | ||
|
|
49609d3821 | ||
|
|
003fe42ad8 | ||
|
|
a42ec0c282 | ||
|
|
884249649d | ||
|
|
d582dffe40 | ||
|
|
63a6c2e6f0 | ||
|
|
f3d45169cd | ||
|
|
86645d34cb | ||
|
|
ab04f0ace7 | ||
|
|
51492b1dcd |
43
GFramework.Core.Abstractions/Concurrency/LockInfo.cs
Normal file
43
GFramework.Core.Abstractions/Concurrency/LockInfo.cs
Normal file
@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2025 GeWuYou
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
namespace GFramework.Core.Abstractions.Concurrency;
|
||||
|
||||
/// <summary>
|
||||
/// 锁信息(用于调试)
|
||||
/// </summary>
|
||||
public readonly struct LockInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 锁的键。
|
||||
/// </summary>
|
||||
public string Key { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前引用计数。
|
||||
/// </summary>
|
||||
public int ReferenceCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最后访问时间戳(Environment.TickCount64)。
|
||||
/// </summary>
|
||||
public long LastAccessTicks { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 等待队列长度(近似值)。
|
||||
/// 注意:这是一个基于 SemaphoreSlim.CurrentCount 的近似指示器,
|
||||
/// 当 CurrentCount == 0 时表示锁被持有且可能有等待者,返回 1;
|
||||
/// 否则返回 0。这不是精确的等待者数量,仅用于调试参考。
|
||||
/// </summary>
|
||||
public int WaitingCount { get; init; }
|
||||
}
|
||||
@ -11,11 +11,14 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace GFramework.Core.Abstractions.Concurrency;
|
||||
|
||||
/// <summary>
|
||||
/// 锁统计信息
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
public readonly struct LockStatistics
|
||||
{
|
||||
/// <summary>
|
||||
@ -37,33 +40,4 @@ public readonly struct LockStatistics
|
||||
/// 累计清理的锁数量
|
||||
/// </summary>
|
||||
public int TotalCleaned { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 锁信息(用于调试)
|
||||
/// </summary>
|
||||
public readonly struct LockInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 锁的键
|
||||
/// </summary>
|
||||
public string Key { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前引用计数
|
||||
/// </summary>
|
||||
public int ReferenceCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最后访问时间戳(Environment.TickCount64)
|
||||
/// </summary>
|
||||
public long LastAccessTicks { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 等待队列长度(近似值)
|
||||
/// 注意:这是一个基于 SemaphoreSlim.CurrentCount 的近似指示器,
|
||||
/// 当 CurrentCount == 0 时表示锁被持有且可能有等待者,返回 1;
|
||||
/// 否则返回 0。这不是精确的等待者数量,仅用于调试参考。
|
||||
/// </summary>
|
||||
public int WaitingCount { get; init; }
|
||||
}
|
||||
@ -75,7 +75,9 @@ public interface IPauseStackManager : IContextUtility
|
||||
void UnregisterHandler(IPauseHandler handler);
|
||||
|
||||
/// <summary>
|
||||
/// 暂停状态变化事件
|
||||
/// 暂停状态变化事件。
|
||||
/// 事件遵循标准 .NET 事件模式,事件源为触发通知的暂停管理器实例,
|
||||
/// 事件数据由 <see cref="PauseStateChangedEventArgs"/> 提供。
|
||||
/// </summary>
|
||||
event Action<PauseGroup, bool>? OnPauseStateChanged;
|
||||
event EventHandler<PauseStateChangedEventArgs>? OnPauseStateChanged;
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
namespace GFramework.Core.Abstractions.Pause;
|
||||
|
||||
/// <summary>
|
||||
/// 表示暂停状态变化事件的数据。
|
||||
/// 该类型用于向事件订阅者传递暂停组以及该组变化后的暂停状态。
|
||||
/// </summary>
|
||||
public sealed class PauseStateChangedEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化 <see cref="PauseStateChangedEventArgs"/> 的新实例。
|
||||
/// </summary>
|
||||
/// <param name="group">发生状态变化的暂停组。</param>
|
||||
/// <param name="isPaused">暂停组变化后的新状态。</param>
|
||||
public PauseStateChangedEventArgs(PauseGroup group, bool isPaused)
|
||||
{
|
||||
Group = group;
|
||||
IsPaused = isPaused;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取发生状态变化的暂停组。
|
||||
/// </summary>
|
||||
public PauseGroup Group { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取暂停组变化后的新状态。
|
||||
/// 为 <see langword="true"/> 表示进入暂停,为 <see langword="false"/> 表示恢复运行。
|
||||
/// </summary>
|
||||
public bool IsPaused { get; }
|
||||
}
|
||||
@ -7,6 +7,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<WarningLevel>0</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Mediator.Abstractions" Version="3.0.1"/>
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
using GFramework.Core.Logging.Appenders;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GFramework.Core.Tests.Logging;
|
||||
|
||||
@ -152,8 +151,12 @@ public class AsyncLogAppenderTests
|
||||
[Test]
|
||||
public void Append_WhenInnerAppenderThrows_ShouldNotCrash()
|
||||
{
|
||||
var reportedExceptions = new List<Exception>();
|
||||
var innerAppender = new ThrowingAppender();
|
||||
using var asyncAppender = new AsyncLogAppender(innerAppender, bufferSize: 1000);
|
||||
using var asyncAppender = new AsyncLogAppender(
|
||||
innerAppender,
|
||||
bufferSize: 1000,
|
||||
processingErrorHandler: reportedExceptions.Add);
|
||||
|
||||
// 即使内部 Appender 抛出异常,也不应该影响调用线程
|
||||
Assert.DoesNotThrow(() =>
|
||||
@ -165,7 +168,56 @@ public class AsyncLogAppenderTests
|
||||
}
|
||||
});
|
||||
|
||||
Thread.Sleep(100); // 等待后台处理
|
||||
asyncAppender.Flush();
|
||||
|
||||
Assert.That(reportedExceptions, Has.Count.EqualTo(10));
|
||||
Assert.That(reportedExceptions, Has.All.TypeOf<InvalidOperationException>());
|
||||
Assert.That(reportedExceptions.Select(static exception => exception.Message),
|
||||
Has.All.EqualTo("Test exception"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Append_WhenProcessingErrorHandlerThrows_ShouldStillNotCrash()
|
||||
{
|
||||
var innerAppender = new ThrowingAppender();
|
||||
using var asyncAppender = new AsyncLogAppender(
|
||||
innerAppender,
|
||||
bufferSize: 1000,
|
||||
processingErrorHandler: static _ => throw new InvalidOperationException("Observer failure"));
|
||||
|
||||
Assert.DoesNotThrow(() =>
|
||||
{
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", $"Message {i}", null, null);
|
||||
asyncAppender.Append(entry);
|
||||
}
|
||||
});
|
||||
|
||||
Assert.That(asyncAppender.Flush(), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Append_WhenInnerAppenderThrowsOperationCanceledException_ShouldNotReportError()
|
||||
{
|
||||
var reportedExceptions = new List<Exception>();
|
||||
var innerAppender = new CancellationAppender();
|
||||
using var asyncAppender = new AsyncLogAppender(
|
||||
innerAppender,
|
||||
bufferSize: 1000,
|
||||
processingErrorHandler: reportedExceptions.Add);
|
||||
|
||||
Assert.DoesNotThrow(() =>
|
||||
{
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", $"Message {i}", null, null);
|
||||
asyncAppender.Append(entry);
|
||||
}
|
||||
});
|
||||
|
||||
Assert.That(asyncAppender.Flush(), Is.True);
|
||||
Assert.That(reportedExceptions, Is.Empty);
|
||||
}
|
||||
|
||||
// 辅助测试类
|
||||
@ -228,4 +280,20 @@ public class AsyncLogAppenderTests
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private class CancellationAppender : ILogAppender
|
||||
{
|
||||
public void Append(LogEntry entry)
|
||||
{
|
||||
throw new OperationCanceledException("Simulated cancellation");
|
||||
}
|
||||
|
||||
public void Flush()
|
||||
{
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,5 @@
|
||||
using GFramework.Core.Abstractions.Pause;
|
||||
using GFramework.Core.Pause;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GFramework.Core.Tests.Pause;
|
||||
|
||||
@ -220,11 +219,11 @@ public class PauseStackManagerTests
|
||||
PauseGroup? eventGroup = null;
|
||||
bool? eventIsPaused = null;
|
||||
|
||||
_manager.OnPauseStateChanged += (group, isPaused) =>
|
||||
_manager.OnPauseStateChanged += (_, e) =>
|
||||
{
|
||||
eventTriggered = true;
|
||||
eventGroup = group;
|
||||
eventIsPaused = isPaused;
|
||||
eventGroup = e.Group;
|
||||
eventIsPaused = e.IsPaused;
|
||||
};
|
||||
|
||||
_manager.Push("Test", PauseGroup.Gameplay);
|
||||
@ -243,10 +242,10 @@ public class PauseStackManagerTests
|
||||
var token = _manager.Push("Test");
|
||||
|
||||
bool eventTriggered = false;
|
||||
_manager.OnPauseStateChanged += (group, isPaused) =>
|
||||
_manager.OnPauseStateChanged += (_, e) =>
|
||||
{
|
||||
eventTriggered = true;
|
||||
Assert.That(isPaused, Is.False);
|
||||
Assert.That(e.IsPaused, Is.False);
|
||||
};
|
||||
|
||||
_manager.Pop(token);
|
||||
|
||||
@ -17,11 +17,6 @@ internal sealed class CoroutineSlot
|
||||
/// </summary>
|
||||
public CoroutineHandle Handle;
|
||||
|
||||
/// <summary>
|
||||
/// 协程是否已经开始执行
|
||||
/// </summary>
|
||||
public bool HasStarted;
|
||||
|
||||
/// <summary>
|
||||
/// 协程的优先级
|
||||
/// </summary>
|
||||
|
||||
@ -30,12 +30,6 @@ public sealed class WaitForAllCoroutines(
|
||||
/// 获取一个值,指示所有协程是否已完成执行
|
||||
/// </summary>
|
||||
/// <returns>当所有协程都已完成时返回true,否则返回false</returns>
|
||||
public bool IsDone
|
||||
{
|
||||
get
|
||||
{
|
||||
// 检查所有协程句柄是否都不在调度器中存活
|
||||
return _handles.All(handle => !_scheduler.IsCoroutineAlive(handle));
|
||||
}
|
||||
}
|
||||
// 检查所有协程句柄是否都不在调度器中存活
|
||||
public bool IsDone => _handles.All(handle => !_scheduler.IsCoroutineAlive(handle));
|
||||
}
|
||||
@ -208,13 +208,10 @@ public class PriorityEvent<T> : IEvent
|
||||
MergeAndSortHandlers(T t)
|
||||
{
|
||||
var (normalSnapshot, contextSnapshot) = CreateSnapshots();
|
||||
// 使用快照避免迭代期间修改
|
||||
// 使用统一的投影方法显式固定元组的可空标注,避免 LINQ 在 Concat 时推断出不兼容的签名。
|
||||
return normalSnapshot
|
||||
.Select(h => (h.Priority, Handler: (Action?)(() => h.Handler.Invoke(t)),
|
||||
ContextHandler: (Action<EventContext<T>>?)null, IsContext: false))
|
||||
.Concat(contextSnapshot
|
||||
.Select(h => (h.Priority, Handler: (Action?)null,
|
||||
ContextHandler: (Action<EventContext<T>>?)h.Handler, IsContext: true)))
|
||||
.Select(h => CreateNormalHandlerInvocation(h, t))
|
||||
.Concat(contextSnapshot.Select(CreateContextHandlerInvocation))
|
||||
.OrderByDescending(h => h.Priority)
|
||||
.ToList();
|
||||
}
|
||||
@ -289,6 +286,29 @@ public class PriorityEvent<T> : IEvent
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将普通事件处理器转换为统一的调用描述。
|
||||
/// </summary>
|
||||
/// <param name="handler">要包装的普通处理器。</param>
|
||||
/// <param name="t">当前触发的事件数据。</param>
|
||||
/// <returns>可与上下文处理器合并排序的统一调用描述。</returns>
|
||||
private static (int Priority, Action? Handler, Action<EventContext<T>>? ContextHandler, bool IsContext)
|
||||
CreateNormalHandlerInvocation(EventHandler handler, T t)
|
||||
{
|
||||
return (handler.Priority, () => handler.Handler.Invoke(t), null, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将上下文事件处理器转换为统一的调用描述。
|
||||
/// </summary>
|
||||
/// <param name="handler">要包装的上下文处理器。</param>
|
||||
/// <returns>可与普通处理器合并排序的统一调用描述。</returns>
|
||||
private static (int Priority, Action? Handler, Action<EventContext<T>>? ContextHandler, bool IsContext)
|
||||
CreateContextHandlerInvocation(ContextEventHandler handler)
|
||||
{
|
||||
return (handler.Priority, null, handler.Handler, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 事件处理器包装类,包含处理器和优先级
|
||||
/// </summary>
|
||||
|
||||
@ -15,4 +15,5 @@ global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.Linq;
|
||||
global using System.Threading;
|
||||
global using System.Threading.Tasks;
|
||||
global using System.Threading.Tasks;
|
||||
global using System.Threading.Channels;
|
||||
@ -1,17 +1,26 @@
|
||||
using System.Threading.Channels;
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
|
||||
namespace GFramework.Core.Logging.Appenders;
|
||||
|
||||
/// <summary>
|
||||
/// 异步日志输出器,使用 Channel 实现非阻塞日志写入
|
||||
/// 异步日志输出器,使用 <see cref="Channel" /> 将调用线程与慢速日志目标解耦。
|
||||
/// </summary>
|
||||
public sealed class AsyncLogAppender : ILogAppender, IDisposable
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 该输出器在后台线程中顺序消费日志条目,因此调用方不会因为文件 IO 或其他慢速输出目标而阻塞。
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 内部输出器抛出的异常不会重新抛回调用线程;如需观察后台处理失败,请在构造函数中提供
|
||||
/// <c>processingErrorHandler</c> 回调。
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AsyncLogAppender : ILogAppender
|
||||
{
|
||||
private readonly Channel<LogEntry> _channel;
|
||||
private readonly CancellationTokenSource _cts;
|
||||
private readonly SemaphoreSlim _flushSemaphore = new(0, 1);
|
||||
private readonly ILogAppender _innerAppender;
|
||||
private readonly Action<Exception>? _processingErrorHandler;
|
||||
private readonly Task _processingTask;
|
||||
private bool _disposed;
|
||||
private volatile bool _flushRequested;
|
||||
@ -21,9 +30,17 @@ public sealed class AsyncLogAppender : ILogAppender, IDisposable
|
||||
/// </summary>
|
||||
/// <param name="innerAppender">内部日志输出器</param>
|
||||
/// <param name="bufferSize">缓冲区大小(默认 10000)</param>
|
||||
public AsyncLogAppender(ILogAppender innerAppender, int bufferSize = 10000)
|
||||
/// <param name="processingErrorHandler">
|
||||
/// 后台处理日志时的错误回调。
|
||||
/// 默认值为 <see langword="null" />,表示吞掉内部异常以避免污染宿主标准错误输出。
|
||||
/// </param>
|
||||
public AsyncLogAppender(
|
||||
ILogAppender innerAppender,
|
||||
int bufferSize = 10000,
|
||||
Action<Exception>? processingErrorHandler = null)
|
||||
{
|
||||
_innerAppender = innerAppender ?? throw new ArgumentNullException(nameof(innerAppender));
|
||||
_processingErrorHandler = processingErrorHandler;
|
||||
|
||||
if (bufferSize <= 0)
|
||||
throw new ArgumentException("Buffer size must be greater than 0.", nameof(bufferSize));
|
||||
@ -138,7 +155,8 @@ public sealed class AsyncLogAppender : ILogAppender, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 后台处理日志的异步方法
|
||||
/// 后台处理日志的异步方法。
|
||||
/// 该循环必须始终保持存活,因此所有内部异常都通过回调上报并被吞掉。
|
||||
/// </summary>
|
||||
private async Task ProcessLogsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@ -152,8 +170,8 @@ public sealed class AsyncLogAppender : ILogAppender, IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 记录内部错误到控制台(避免递归)
|
||||
await Console.Error.WriteLineAsync($"[AsyncLogAppender] Error processing log entry: {ex.Message}");
|
||||
// 后台消费失败只通过显式回调暴露,避免测试宿主将 stderr 误判为测试告警。
|
||||
ReportProcessingError(ex);
|
||||
}
|
||||
|
||||
// 检查是否有刷新请求且通道已空
|
||||
@ -175,7 +193,7 @@ public sealed class AsyncLogAppender : ILogAppender, IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Console.Error.WriteLineAsync($"[AsyncLogAppender] Fatal error in processing task: {ex}");
|
||||
ReportProcessingError(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@ -184,10 +202,37 @@ public sealed class AsyncLogAppender : ILogAppender, IDisposable
|
||||
{
|
||||
_innerAppender.Flush();
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 忽略刷新错误
|
||||
ReportProcessingError(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 上报后台处理异常,同时隔离观察者自身抛出的错误,避免终止处理循环。
|
||||
/// 取消相关异常表示关闭流程中的预期控制流,不应被视为后台处理失败。
|
||||
/// </summary>
|
||||
/// <param name="exception">后台处理中捕获到的异常。</param>
|
||||
private void ReportProcessingError(Exception exception)
|
||||
{
|
||||
if (exception is OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_processingErrorHandler is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_processingErrorHandler(exception);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 错误观察者只用于诊断,绝不能反向影响日志处理线程的生命周期。
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -80,7 +80,7 @@ public class PauseStackManager : AbstractContextUtility, IPauseStackManager, IAs
|
||||
// 触发事件
|
||||
try
|
||||
{
|
||||
OnPauseStateChanged?.Invoke(group, false);
|
||||
RaisePauseStateChanged(group, false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@ -97,7 +97,7 @@ public class PauseStackManager : AbstractContextUtility, IPauseStackManager, IAs
|
||||
/// <summary>
|
||||
/// 暂停状态变化事件,当暂停状态发生改变时触发。
|
||||
/// </summary>
|
||||
public event Action<PauseGroup, bool>? OnPauseStateChanged;
|
||||
public event EventHandler<PauseStateChangedEventArgs>? OnPauseStateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// 推入一个新的暂停请求到指定的暂停组中。
|
||||
@ -488,7 +488,18 @@ public class PauseStackManager : AbstractContextUtility, IPauseStackManager, IAs
|
||||
}
|
||||
|
||||
// 触发事件
|
||||
OnPauseStateChanged?.Invoke(group, isPaused);
|
||||
RaisePauseStateChanged(group, isPaused);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 以标准事件模式发布暂停状态变化事件。
|
||||
/// 所有状态变更路径都通过该方法创建统一的事件参数,避免不同调用点出现不一致的载荷。
|
||||
/// </summary>
|
||||
/// <param name="group">发生状态变化的暂停组。</param>
|
||||
/// <param name="isPaused">暂停组变化后的新状态。</param>
|
||||
private void RaisePauseStateChanged(PauseGroup group, bool isPaused)
|
||||
{
|
||||
OnPauseStateChanged?.Invoke(this, new PauseStateChangedEventArgs(group, isPaused));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -21,12 +21,6 @@ namespace GFramework.Game.Abstractions.Scene;
|
||||
/// </summary>
|
||||
public interface ISceneBehavior : IRoute
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取场景的唯一标识符。
|
||||
/// 用于区分不同的场景实例。
|
||||
/// </summary>
|
||||
string Key { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取场景的原始对象。
|
||||
/// </summary>
|
||||
|
||||
@ -31,8 +31,9 @@ public interface ISceneRouteGuard : IRouteGuard<ISceneBehavior>
|
||||
|
||||
/// <summary>
|
||||
/// 异步检查是否允许离开指定场景。
|
||||
/// 该成员显式细化了通用路由守卫的离开检查,使场景守卫在 API 文档中保持场景语义。
|
||||
/// </summary>
|
||||
/// <param name="sceneKey">当前场景的唯一标识符。</param>
|
||||
/// <returns>如果允许离开则返回 true,否则返回 false。</returns>
|
||||
ValueTask<bool> CanLeaveAsync(string sceneKey);
|
||||
new ValueTask<bool> CanLeaveAsync(string sceneKey);
|
||||
}
|
||||
@ -46,14 +46,6 @@ public interface IUiPageBehavior : IRoute
|
||||
/// <returns>页面视图实例。</returns>
|
||||
object View { get; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 获取键值
|
||||
/// </summary>
|
||||
/// <value>返回当前对象的键标识符</value>
|
||||
string Key { get; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 获取页面是否处于活动状态
|
||||
/// </summary>
|
||||
|
||||
@ -17,9 +17,10 @@ public interface IUiRouteGuard : IRouteGuard<IUiPageBehavior>
|
||||
ValueTask<bool> CanEnterAsync(string uiKey, IUiPageEnterParam? param);
|
||||
|
||||
/// <summary>
|
||||
/// 离开UI前的检查
|
||||
/// 离开UI前的检查。
|
||||
/// 该成员显式细化了通用路由守卫的离开检查,使 UI 守卫在 API 文档中保持 UI 语义。
|
||||
/// </summary>
|
||||
/// <param name="uiKey">当前UI标识符</param>
|
||||
/// <returns>true表示允许离开,false表示拦截</returns>
|
||||
ValueTask<bool> CanLeaveAsync(string uiKey);
|
||||
new ValueTask<bool> CanLeaveAsync(string uiKey);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
<Project>
|
||||
<!-- import parent: https://docs.microsoft.com/en-us/visualstudio/msbuild/customize-your-build -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
|
||||
<EmbedUntrackedSources>true</EmbedUntrackedSources>
|
||||
<!--
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
#nullable enable
|
||||
namespace GFramework.Godot.SourceGenerators.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 标记 Godot 节点字段,Source Generator 会为其生成节点获取逻辑。
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
|
||||
public sealed class GetNodeAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化 <see cref="GetNodeAttribute" /> 的新实例。
|
||||
/// </summary>
|
||||
public GetNodeAttribute()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化 <see cref="GetNodeAttribute" /> 的新实例,并指定节点路径。
|
||||
/// </summary>
|
||||
/// <param name="path">节点路径。</param>
|
||||
public GetNodeAttribute(string path)
|
||||
{
|
||||
Path = path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置节点路径。未设置时将根据字段名推导。
|
||||
/// </summary>
|
||||
public string? Path { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置节点是否必填。默认为 true。
|
||||
/// </summary>
|
||||
public bool Required { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置节点查找模式。默认为 <see cref="NodeLookupMode.Auto" />。
|
||||
/// </summary>
|
||||
public NodeLookupMode Lookup { get; set; } = NodeLookupMode.Auto;
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
#nullable enable
|
||||
namespace GFramework.Godot.SourceGenerators.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 节点路径的查找模式。
|
||||
/// </summary>
|
||||
public enum NodeLookupMode
|
||||
{
|
||||
/// <summary>
|
||||
/// 自动推断。未显式设置路径时默认按唯一名查找。
|
||||
/// </summary>
|
||||
Auto = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 按唯一名查找,对应 Godot 的 %Name 语法。
|
||||
/// </summary>
|
||||
UniqueName = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 按相对路径查找。
|
||||
/// </summary>
|
||||
RelativePath = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 按绝对路径查找。
|
||||
/// </summary>
|
||||
AbsolutePath = 3
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
using Microsoft.CodeAnalysis.CSharp.Testing;
|
||||
using Microsoft.CodeAnalysis.Testing;
|
||||
|
||||
namespace GFramework.Godot.SourceGenerators.Tests.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 提供源代码生成器测试的通用功能。
|
||||
/// </summary>
|
||||
/// <typeparam name="TGenerator">要测试的源代码生成器类型,必须具有无参构造函数。</typeparam>
|
||||
public static class GeneratorTest<TGenerator>
|
||||
where TGenerator : new()
|
||||
{
|
||||
/// <summary>
|
||||
/// 运行源代码生成器测试。
|
||||
/// </summary>
|
||||
/// <param name="source">输入源代码。</param>
|
||||
/// <param name="generatedSources">期望生成的源文件集合。</param>
|
||||
public static async Task RunAsync(
|
||||
string source,
|
||||
params (string filename, string content)[] generatedSources)
|
||||
{
|
||||
var test = new CSharpSourceGeneratorTest<TGenerator, DefaultVerifier>
|
||||
{
|
||||
TestState =
|
||||
{
|
||||
Sources = { source }
|
||||
},
|
||||
DisabledDiagnostics = { "GF_Common_Trace_001" }
|
||||
};
|
||||
|
||||
foreach (var (filename, content) in generatedSources)
|
||||
test.TestState.GeneratedSources.Add(
|
||||
(typeof(TGenerator), filename, content));
|
||||
|
||||
await test.RunAsync();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TestTargetFrameworks Condition="'$(TestTargetFrameworks)' == ''">net10.0</TestTargetFrameworks>
|
||||
<TargetFrameworks>$(TestTargetFrameworks)</TargetFrameworks>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis" Version="4.14.0"/>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0"/>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.Common" Version="4.14.0"/>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0"/>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing" Version="1.1.3"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0"/>
|
||||
<PackageReference Include="NUnit" Version="4.5.1"/>
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.1.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GFramework.Godot.SourceGenerators\GFramework.Godot.SourceGenerators.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@ -0,0 +1,243 @@
|
||||
using GFramework.Godot.SourceGenerators.Tests.Core;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp.Testing;
|
||||
using Microsoft.CodeAnalysis.Testing;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GFramework.Godot.SourceGenerators.Tests.GetNode;
|
||||
|
||||
[TestFixture]
|
||||
public class GetNodeGeneratorTests
|
||||
{
|
||||
[Test]
|
||||
public async Task Generates_InferredUniqueNameBindings_And_ReadyHook_WhenReadyIsMissing()
|
||||
{
|
||||
const string source = """
|
||||
using System;
|
||||
using GFramework.Godot.SourceGenerators.Abstractions;
|
||||
using Godot;
|
||||
|
||||
namespace GFramework.Godot.SourceGenerators.Abstractions
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
|
||||
public sealed class GetNodeAttribute : Attribute
|
||||
{
|
||||
public GetNodeAttribute() {}
|
||||
public GetNodeAttribute(string path) { Path = path; }
|
||||
public string? Path { get; set; }
|
||||
public bool Required { get; set; } = true;
|
||||
public NodeLookupMode Lookup { get; set; } = NodeLookupMode.Auto;
|
||||
}
|
||||
|
||||
public enum NodeLookupMode
|
||||
{
|
||||
Auto = 0,
|
||||
UniqueName = 1,
|
||||
RelativePath = 2,
|
||||
AbsolutePath = 3
|
||||
}
|
||||
}
|
||||
|
||||
namespace Godot
|
||||
{
|
||||
public class Node
|
||||
{
|
||||
public virtual void _Ready() {}
|
||||
public T GetNode<T>(string path) where T : Node => throw new InvalidOperationException(path);
|
||||
public T? GetNodeOrNull<T>(string path) where T : Node => default;
|
||||
}
|
||||
|
||||
public class HBoxContainer : Node
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
namespace TestApp
|
||||
{
|
||||
public partial class TopBar : HBoxContainer
|
||||
{
|
||||
[GetNode]
|
||||
private HBoxContainer _leftContainer = null!;
|
||||
|
||||
[GetNode]
|
||||
private HBoxContainer m_rightContainer = null!;
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
const string expected = """
|
||||
// <auto-generated />
|
||||
#nullable enable
|
||||
|
||||
namespace TestApp;
|
||||
|
||||
partial class TopBar
|
||||
{
|
||||
private void __InjectGetNodes_Generated()
|
||||
{
|
||||
_leftContainer = GetNode<global::Godot.HBoxContainer>("%LeftContainer");
|
||||
m_rightContainer = GetNode<global::Godot.HBoxContainer>("%RightContainer");
|
||||
}
|
||||
|
||||
partial void OnGetNodeReadyGenerated();
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
__InjectGetNodes_Generated();
|
||||
OnGetNodeReadyGenerated();
|
||||
}
|
||||
}
|
||||
|
||||
""";
|
||||
|
||||
await GeneratorTest<GetNodeGenerator>.RunAsync(
|
||||
source,
|
||||
("TestApp_TopBar.GetNode.g.cs", expected));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Generates_ManualInjectionOnly_WhenReadyAlreadyExists()
|
||||
{
|
||||
const string source = """
|
||||
using System;
|
||||
using GFramework.Godot.SourceGenerators.Abstractions;
|
||||
using Godot;
|
||||
|
||||
namespace GFramework.Godot.SourceGenerators.Abstractions
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
|
||||
public sealed class GetNodeAttribute : Attribute
|
||||
{
|
||||
public GetNodeAttribute() {}
|
||||
public GetNodeAttribute(string path) { Path = path; }
|
||||
public string? Path { get; set; }
|
||||
public bool Required { get; set; } = true;
|
||||
public NodeLookupMode Lookup { get; set; } = NodeLookupMode.Auto;
|
||||
}
|
||||
|
||||
public enum NodeLookupMode
|
||||
{
|
||||
Auto = 0,
|
||||
UniqueName = 1,
|
||||
RelativePath = 2,
|
||||
AbsolutePath = 3
|
||||
}
|
||||
}
|
||||
|
||||
namespace Godot
|
||||
{
|
||||
public class Node
|
||||
{
|
||||
public virtual void _Ready() {}
|
||||
public T GetNode<T>(string path) where T : Node => throw new InvalidOperationException(path);
|
||||
public T? GetNodeOrNull<T>(string path) where T : Node => default;
|
||||
}
|
||||
|
||||
public class HBoxContainer : Node
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
namespace TestApp
|
||||
{
|
||||
public partial class TopBar : HBoxContainer
|
||||
{
|
||||
[GetNode("%LeftContainer")]
|
||||
private HBoxContainer _leftContainer = null!;
|
||||
|
||||
[GetNode(Required = false, Lookup = NodeLookupMode.RelativePath)]
|
||||
private HBoxContainer? _rightContainer;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
__InjectGetNodes_Generated();
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
const string expected = """
|
||||
// <auto-generated />
|
||||
#nullable enable
|
||||
|
||||
namespace TestApp;
|
||||
|
||||
partial class TopBar
|
||||
{
|
||||
private void __InjectGetNodes_Generated()
|
||||
{
|
||||
_leftContainer = GetNode<global::Godot.HBoxContainer>("%LeftContainer");
|
||||
_rightContainer = GetNodeOrNull<global::Godot.HBoxContainer>("RightContainer");
|
||||
}
|
||||
}
|
||||
|
||||
""";
|
||||
|
||||
await GeneratorTest<GetNodeGenerator>.RunAsync(
|
||||
source,
|
||||
("TestApp_TopBar.GetNode.g.cs", expected));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Reports_Diagnostic_When_FieldType_IsNotGodotNode()
|
||||
{
|
||||
const string source = """
|
||||
using System;
|
||||
using GFramework.Godot.SourceGenerators.Abstractions;
|
||||
using Godot;
|
||||
|
||||
namespace GFramework.Godot.SourceGenerators.Abstractions
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
|
||||
public sealed class GetNodeAttribute : Attribute
|
||||
{
|
||||
public string? Path { get; set; }
|
||||
public bool Required { get; set; } = true;
|
||||
public NodeLookupMode Lookup { get; set; } = NodeLookupMode.Auto;
|
||||
}
|
||||
|
||||
public enum NodeLookupMode
|
||||
{
|
||||
Auto = 0,
|
||||
UniqueName = 1,
|
||||
RelativePath = 2,
|
||||
AbsolutePath = 3
|
||||
}
|
||||
}
|
||||
|
||||
namespace Godot
|
||||
{
|
||||
public class Node
|
||||
{
|
||||
public virtual void _Ready() {}
|
||||
public T GetNode<T>(string path) where T : Node => throw new InvalidOperationException(path);
|
||||
public T? GetNodeOrNull<T>(string path) where T : Node => default;
|
||||
}
|
||||
}
|
||||
|
||||
namespace TestApp
|
||||
{
|
||||
public partial class TopBar : Node
|
||||
{
|
||||
[GetNode]
|
||||
private string _leftContainer = string.Empty;
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var test = new CSharpSourceGeneratorTest<GetNodeGenerator, DefaultVerifier>
|
||||
{
|
||||
TestState =
|
||||
{
|
||||
Sources = { source }
|
||||
},
|
||||
DisabledDiagnostics = { "GF_Common_Trace_001" }
|
||||
};
|
||||
|
||||
test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Godot_GetNode_004", DiagnosticSeverity.Error)
|
||||
.WithSpan(39, 24, 39, 38)
|
||||
.WithArguments("_leftContainer"));
|
||||
|
||||
await test.RunAsync();
|
||||
}
|
||||
}
|
||||
18
GFramework.Godot.SourceGenerators.Tests/GlobalUsings.cs
Normal file
18
GFramework.Godot.SourceGenerators.Tests/GlobalUsings.cs
Normal file
@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2025 GeWuYou
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.Linq;
|
||||
global using System.Threading;
|
||||
global using System.Threading.Tasks;
|
||||
@ -0,0 +1,3 @@
|
||||
; Shipped analyzer releases
|
||||
; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
; Unshipped analyzer release
|
||||
; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
|
||||
|
||||
### New Rules
|
||||
|
||||
Rule ID | Category | Severity | Notes
|
||||
----------------------|------------------|----------|--------------------
|
||||
GF_Godot_GetNode_001 | GFramework.Godot | Error | GetNodeDiagnostics
|
||||
GF_Godot_GetNode_002 | GFramework.Godot | Error | GetNodeDiagnostics
|
||||
GF_Godot_GetNode_003 | GFramework.Godot | Error | GetNodeDiagnostics
|
||||
GF_Godot_GetNode_004 | GFramework.Godot | Error | GetNodeDiagnostics
|
||||
GF_Godot_GetNode_005 | GFramework.Godot | Error | GetNodeDiagnostics
|
||||
GF_Godot_GetNode_006 | GFramework.Godot | Warning | GetNodeDiagnostics
|
||||
@ -0,0 +1,82 @@
|
||||
using GFramework.SourceGenerators.Common.Constants;
|
||||
using Microsoft.CodeAnalysis;
|
||||
|
||||
namespace GFramework.Godot.SourceGenerators.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// GetNode 生成器相关诊断。
|
||||
/// </summary>
|
||||
public static class GetNodeDiagnostics
|
||||
{
|
||||
/// <summary>
|
||||
/// 嵌套类型不受支持。
|
||||
/// </summary>
|
||||
public static readonly DiagnosticDescriptor NestedClassNotSupported =
|
||||
new(
|
||||
"GF_Godot_GetNode_001",
|
||||
"Nested classes are not supported",
|
||||
"Class '{0}' cannot use [GetNode] inside a nested type",
|
||||
PathContests.GodotNamespace,
|
||||
DiagnosticSeverity.Error,
|
||||
true);
|
||||
|
||||
/// <summary>
|
||||
/// static 字段不受支持。
|
||||
/// </summary>
|
||||
public static readonly DiagnosticDescriptor StaticFieldNotSupported =
|
||||
new(
|
||||
"GF_Godot_GetNode_002",
|
||||
"Static fields are not supported",
|
||||
"Field '{0}' cannot be static when using [GetNode]",
|
||||
PathContests.GodotNamespace,
|
||||
DiagnosticSeverity.Error,
|
||||
true);
|
||||
|
||||
/// <summary>
|
||||
/// readonly 字段不受支持。
|
||||
/// </summary>
|
||||
public static readonly DiagnosticDescriptor ReadOnlyFieldNotSupported =
|
||||
new(
|
||||
"GF_Godot_GetNode_003",
|
||||
"Readonly fields are not supported",
|
||||
"Field '{0}' cannot be readonly when using [GetNode]",
|
||||
PathContests.GodotNamespace,
|
||||
DiagnosticSeverity.Error,
|
||||
true);
|
||||
|
||||
/// <summary>
|
||||
/// 字段类型必须继承自 Godot.Node。
|
||||
/// </summary>
|
||||
public static readonly DiagnosticDescriptor FieldTypeMustDeriveFromNode =
|
||||
new(
|
||||
"GF_Godot_GetNode_004",
|
||||
"Field type must derive from Godot.Node",
|
||||
"Field '{0}' must be a Godot.Node type to use [GetNode]",
|
||||
PathContests.GodotNamespace,
|
||||
DiagnosticSeverity.Error,
|
||||
true);
|
||||
|
||||
/// <summary>
|
||||
/// 无法从字段名推导路径。
|
||||
/// </summary>
|
||||
public static readonly DiagnosticDescriptor CannotInferNodePath =
|
||||
new(
|
||||
"GF_Godot_GetNode_005",
|
||||
"Cannot infer node path",
|
||||
"Field '{0}' does not provide a path and its name cannot be converted to a node path",
|
||||
PathContests.GodotNamespace,
|
||||
DiagnosticSeverity.Error,
|
||||
true);
|
||||
|
||||
/// <summary>
|
||||
/// 现有 _Ready 中未调用生成注入逻辑。
|
||||
/// </summary>
|
||||
public static readonly DiagnosticDescriptor ManualReadyHookRequired =
|
||||
new(
|
||||
"GF_Godot_GetNode_006",
|
||||
"Call generated injection from _Ready",
|
||||
"Class '{0}' defines _Ready(); call __InjectGetNodes_Generated() there or remove _Ready() to use the generated hook",
|
||||
PathContests.GodotNamespace,
|
||||
DiagnosticSeverity.Warning,
|
||||
true);
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<PackageId>GeWuYou.$(AssemblyName)</PackageId>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
|
||||
<!-- 这是 Analyzer,不是运行时库 -->
|
||||
<IsRoslynAnalyzer>true</IsRoslynAnalyzer>
|
||||
@ -60,11 +60,6 @@
|
||||
<None Include="GeWuYou.$(AssemblyName).targets" Pack="true" PackagePath="build" Visible="false"/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="diagnostics\"/>
|
||||
<Folder Include="logging\"/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Remove="AnalyzerReleases.Shipped.md"/>
|
||||
<AdditionalFiles Remove="AnalyzerReleases.Unshipped.md"/>
|
||||
<Folder Include="Diagnostics\"/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
568
GFramework.Godot.SourceGenerators/GetNodeGenerator.cs
Normal file
568
GFramework.Godot.SourceGenerators/GetNodeGenerator.cs
Normal file
@ -0,0 +1,568 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using GFramework.Godot.SourceGenerators.Diagnostics;
|
||||
using GFramework.SourceGenerators.Common.Constants;
|
||||
using GFramework.SourceGenerators.Common.Diagnostics;
|
||||
using GFramework.SourceGenerators.Common.Extensions;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
|
||||
namespace GFramework.Godot.SourceGenerators;
|
||||
|
||||
/// <summary>
|
||||
/// 为带有 <c>[GetNode]</c> 的字段生成 Godot 节点获取逻辑。
|
||||
/// </summary>
|
||||
[Generator]
|
||||
public sealed class GetNodeGenerator : IIncrementalGenerator
|
||||
{
|
||||
private const string GodotAbsolutePathPrefix = "/";
|
||||
private const string GodotUniqueNamePrefix = "%";
|
||||
|
||||
private const string GetNodeAttributeMetadataName =
|
||||
$"{PathContests.GodotSourceGeneratorsAbstractionsPath}.GetNodeAttribute";
|
||||
|
||||
private const string GetNodeLookupModeMetadataName =
|
||||
$"{PathContests.GodotSourceGeneratorsAbstractionsPath}.NodeLookupMode";
|
||||
|
||||
private const string InjectionMethodName = "__InjectGetNodes_Generated";
|
||||
private const string ReadyHookMethodName = "OnGetNodeReadyGenerated";
|
||||
|
||||
public void Initialize(IncrementalGeneratorInitializationContext context)
|
||||
{
|
||||
var candidates = context.SyntaxProvider.CreateSyntaxProvider(
|
||||
static (node, _) => IsCandidate(node),
|
||||
static (ctx, _) => Transform(ctx))
|
||||
.Where(static candidate => candidate is not null);
|
||||
|
||||
var compilationAndCandidates = context.CompilationProvider.Combine(candidates.Collect());
|
||||
|
||||
context.RegisterSourceOutput(compilationAndCandidates,
|
||||
static (spc, pair) => { Execute(spc, pair.Left, pair.Right); });
|
||||
}
|
||||
|
||||
private static bool IsCandidate(SyntaxNode node)
|
||||
{
|
||||
if (node is not VariableDeclaratorSyntax
|
||||
{
|
||||
Parent: VariableDeclarationSyntax
|
||||
{
|
||||
Parent: FieldDeclarationSyntax fieldDeclaration
|
||||
}
|
||||
})
|
||||
return false;
|
||||
|
||||
return fieldDeclaration.AttributeLists
|
||||
.SelectMany(static list => list.Attributes)
|
||||
.Any(static attribute => attribute.Name.ToString().Contains("GetNode", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static FieldCandidate? Transform(GeneratorSyntaxContext context)
|
||||
{
|
||||
if (context.Node is not VariableDeclaratorSyntax variable)
|
||||
return null;
|
||||
|
||||
if (ModelExtensions.GetDeclaredSymbol(context.SemanticModel, variable) is not IFieldSymbol fieldSymbol)
|
||||
return null;
|
||||
|
||||
return new FieldCandidate(variable, fieldSymbol);
|
||||
}
|
||||
|
||||
private static void Execute(
|
||||
SourceProductionContext context,
|
||||
Compilation compilation,
|
||||
ImmutableArray<FieldCandidate?> candidates)
|
||||
{
|
||||
if (candidates.IsDefaultOrEmpty)
|
||||
return;
|
||||
|
||||
var getNodeAttribute = compilation.GetTypeByMetadataName(GetNodeAttributeMetadataName);
|
||||
var godotNodeSymbol = compilation.GetTypeByMetadataName("Godot.Node");
|
||||
|
||||
if (getNodeAttribute is null || godotNodeSymbol is null)
|
||||
return;
|
||||
|
||||
var fieldCandidates = candidates
|
||||
.Where(static candidate => candidate is not null)
|
||||
.Select(static candidate => candidate!)
|
||||
.Where(candidate => ResolveAttribute(candidate.FieldSymbol, getNodeAttribute) is not null)
|
||||
.ToList();
|
||||
|
||||
foreach (var group in GroupByContainingType(fieldCandidates))
|
||||
{
|
||||
var typeSymbol = group.TypeSymbol;
|
||||
|
||||
if (!CanGenerateForType(context, group, typeSymbol))
|
||||
continue;
|
||||
|
||||
var bindings = new List<NodeBindingInfo>();
|
||||
|
||||
foreach (var candidate in group.Fields)
|
||||
{
|
||||
var attribute = ResolveAttribute(candidate.FieldSymbol, getNodeAttribute);
|
||||
if (attribute is null)
|
||||
continue;
|
||||
|
||||
if (!TryCreateBinding(context, candidate, attribute, godotNodeSymbol, out var binding))
|
||||
continue;
|
||||
|
||||
bindings.Add(binding);
|
||||
}
|
||||
|
||||
if (bindings.Count == 0)
|
||||
continue;
|
||||
|
||||
ReportMissingReadyHookCall(context, group, typeSymbol);
|
||||
|
||||
var source = GenerateSource(typeSymbol, bindings, FindReadyMethod(typeSymbol) is null);
|
||||
context.AddSource(GetHintName(typeSymbol), source);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool CanGenerateForType(
|
||||
SourceProductionContext context,
|
||||
TypeGroup group,
|
||||
INamedTypeSymbol typeSymbol)
|
||||
{
|
||||
if (typeSymbol.ContainingType is not null)
|
||||
{
|
||||
context.ReportDiagnostic(Diagnostic.Create(
|
||||
GetNodeDiagnostics.NestedClassNotSupported,
|
||||
group.Fields[0].Variable.Identifier.GetLocation(),
|
||||
typeSymbol.Name));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsPartial(typeSymbol))
|
||||
return true;
|
||||
|
||||
context.ReportDiagnostic(Diagnostic.Create(
|
||||
CommonDiagnostics.ClassMustBePartial,
|
||||
group.Fields[0].Variable.Identifier.GetLocation(),
|
||||
typeSymbol.Name));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryCreateBinding(
|
||||
SourceProductionContext context,
|
||||
FieldCandidate candidate,
|
||||
AttributeData attribute,
|
||||
INamedTypeSymbol godotNodeSymbol,
|
||||
out NodeBindingInfo binding)
|
||||
{
|
||||
binding = default!;
|
||||
|
||||
if (candidate.FieldSymbol.IsStatic)
|
||||
{
|
||||
ReportFieldDiagnostic(context,
|
||||
GetNodeDiagnostics.StaticFieldNotSupported,
|
||||
candidate);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (candidate.FieldSymbol.IsReadOnly)
|
||||
{
|
||||
ReportFieldDiagnostic(context,
|
||||
GetNodeDiagnostics.ReadOnlyFieldNotSupported,
|
||||
candidate);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsGodotNodeType(candidate.FieldSymbol.Type, godotNodeSymbol))
|
||||
{
|
||||
ReportFieldDiagnostic(context,
|
||||
GetNodeDiagnostics.FieldTypeMustDeriveFromNode,
|
||||
candidate);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryResolvePath(candidate.FieldSymbol, attribute, out var path))
|
||||
{
|
||||
ReportFieldDiagnostic(context,
|
||||
GetNodeDiagnostics.CannotInferNodePath,
|
||||
candidate);
|
||||
return false;
|
||||
}
|
||||
|
||||
binding = new NodeBindingInfo(
|
||||
candidate.FieldSymbol,
|
||||
path,
|
||||
ResolveRequired(attribute));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ReportFieldDiagnostic(
|
||||
SourceProductionContext context,
|
||||
DiagnosticDescriptor descriptor,
|
||||
FieldCandidate candidate)
|
||||
{
|
||||
context.ReportDiagnostic(Diagnostic.Create(
|
||||
descriptor,
|
||||
candidate.Variable.Identifier.GetLocation(),
|
||||
candidate.FieldSymbol.Name));
|
||||
}
|
||||
|
||||
private static void ReportMissingReadyHookCall(
|
||||
SourceProductionContext context,
|
||||
TypeGroup group,
|
||||
INamedTypeSymbol typeSymbol)
|
||||
{
|
||||
var readyMethod = FindReadyMethod(typeSymbol);
|
||||
if (readyMethod is null || CallsGeneratedInjection(readyMethod))
|
||||
return;
|
||||
|
||||
context.ReportDiagnostic(Diagnostic.Create(
|
||||
GetNodeDiagnostics.ManualReadyHookRequired,
|
||||
readyMethod.Locations.FirstOrDefault() ?? group.Fields[0].Variable.Identifier.GetLocation(),
|
||||
typeSymbol.Name));
|
||||
}
|
||||
|
||||
private static AttributeData? ResolveAttribute(
|
||||
IFieldSymbol fieldSymbol,
|
||||
INamedTypeSymbol getNodeAttribute)
|
||||
{
|
||||
return fieldSymbol.GetAttributes()
|
||||
.FirstOrDefault(attribute =>
|
||||
SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, getNodeAttribute));
|
||||
}
|
||||
|
||||
private static bool IsPartial(INamedTypeSymbol typeSymbol)
|
||||
{
|
||||
return typeSymbol.DeclaringSyntaxReferences
|
||||
.Select(static reference => reference.GetSyntax())
|
||||
.OfType<ClassDeclarationSyntax>()
|
||||
.All(static declaration =>
|
||||
declaration.Modifiers.Any(static modifier => modifier.IsKind(SyntaxKind.PartialKeyword)));
|
||||
}
|
||||
|
||||
private static bool IsGodotNodeType(ITypeSymbol typeSymbol, INamedTypeSymbol godotNodeSymbol)
|
||||
{
|
||||
var current = typeSymbol as INamedTypeSymbol;
|
||||
while (current is not null)
|
||||
{
|
||||
if (SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, godotNodeSymbol) ||
|
||||
SymbolEqualityComparer.Default.Equals(current, godotNodeSymbol))
|
||||
return true;
|
||||
|
||||
current = current.BaseType;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IMethodSymbol? FindReadyMethod(INamedTypeSymbol typeSymbol)
|
||||
{
|
||||
return typeSymbol.GetMembers()
|
||||
.OfType<IMethodSymbol>()
|
||||
.FirstOrDefault(static method =>
|
||||
method.Name == "_Ready" &&
|
||||
!method.IsStatic &&
|
||||
method.Parameters.Length == 0 &&
|
||||
method.MethodKind == MethodKind.Ordinary);
|
||||
}
|
||||
|
||||
private static bool CallsGeneratedInjection(IMethodSymbol readyMethod)
|
||||
{
|
||||
foreach (var syntaxReference in readyMethod.DeclaringSyntaxReferences)
|
||||
{
|
||||
if (syntaxReference.GetSyntax() is not MethodDeclarationSyntax methodSyntax)
|
||||
continue;
|
||||
|
||||
if (methodSyntax.DescendantNodes()
|
||||
.OfType<InvocationExpressionSyntax>()
|
||||
.Any(IsGeneratedInjectionInvocation))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsGeneratedInjectionInvocation(InvocationExpressionSyntax invocation)
|
||||
{
|
||||
switch (invocation.Expression)
|
||||
{
|
||||
case IdentifierNameSyntax identifierName:
|
||||
return string.Equals(
|
||||
identifierName.Identifier.ValueText,
|
||||
InjectionMethodName,
|
||||
StringComparison.Ordinal);
|
||||
case MemberAccessExpressionSyntax memberAccess:
|
||||
return string.Equals(
|
||||
memberAccess.Name.Identifier.ValueText,
|
||||
InjectionMethodName,
|
||||
StringComparison.Ordinal);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ResolveRequired(AttributeData attribute)
|
||||
{
|
||||
return attribute.GetNamedArgument("Required", true);
|
||||
}
|
||||
|
||||
private static bool TryResolvePath(
|
||||
IFieldSymbol fieldSymbol,
|
||||
AttributeData attribute,
|
||||
out string path)
|
||||
{
|
||||
var explicitPath = ResolveExplicitPath(attribute);
|
||||
if (!string.IsNullOrWhiteSpace(explicitPath))
|
||||
return ReturnResolvedPath(explicitPath!, out path);
|
||||
|
||||
var inferredName = InferNodeName(fieldSymbol.Name);
|
||||
if (string.IsNullOrWhiteSpace(inferredName))
|
||||
{
|
||||
path = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
var resolvedName = inferredName!;
|
||||
return TryResolveInferredPath(attribute, resolvedName, out path);
|
||||
}
|
||||
|
||||
private static bool ReturnResolvedPath(string resolvedPath, out string path)
|
||||
{
|
||||
path = resolvedPath;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryResolveInferredPath(
|
||||
AttributeData attribute,
|
||||
string inferredName,
|
||||
out string path)
|
||||
{
|
||||
path = BuildPathPrefix(ResolveLookup(attribute)) + inferredName;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string BuildPathPrefix(NodeLookupModeValue lookupMode)
|
||||
{
|
||||
switch (lookupMode)
|
||||
{
|
||||
case NodeLookupModeValue.RelativePath:
|
||||
return string.Empty;
|
||||
case NodeLookupModeValue.AbsolutePath:
|
||||
return GodotAbsolutePathPrefix;
|
||||
default:
|
||||
return GodotUniqueNamePrefix;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveExplicitPath(AttributeData attribute)
|
||||
{
|
||||
var namedPath = attribute.GetNamedArgument<string>("Path");
|
||||
if (!string.IsNullOrWhiteSpace(namedPath))
|
||||
return namedPath;
|
||||
|
||||
if (attribute.ConstructorArguments.Length == 0)
|
||||
return null;
|
||||
|
||||
return attribute.ConstructorArguments[0].Value as string;
|
||||
}
|
||||
|
||||
private static NodeLookupModeValue ResolveLookup(AttributeData attribute)
|
||||
{
|
||||
foreach (var namedArgument in attribute.NamedArguments)
|
||||
{
|
||||
if (!string.Equals(namedArgument.Key, "Lookup", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
if (namedArgument.Value.Type?.ToDisplayString() != GetNodeLookupModeMetadataName)
|
||||
continue;
|
||||
|
||||
if (namedArgument.Value.Value is int value)
|
||||
return (NodeLookupModeValue)value;
|
||||
}
|
||||
|
||||
return NodeLookupModeValue.Auto;
|
||||
}
|
||||
|
||||
private static string? InferNodeName(string fieldName)
|
||||
{
|
||||
var workingName = fieldName.TrimStart('_');
|
||||
if (workingName.StartsWith("m_", StringComparison.OrdinalIgnoreCase))
|
||||
workingName = workingName.Substring(2);
|
||||
|
||||
workingName = workingName.TrimStart('_');
|
||||
if (string.IsNullOrWhiteSpace(workingName))
|
||||
return null;
|
||||
|
||||
if (workingName.IndexOfAny(['_', '-', ' ']) >= 0)
|
||||
{
|
||||
var parts = workingName
|
||||
.Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
return parts.Length == 0
|
||||
? null
|
||||
: string.Concat(parts.Select(ToPascalToken));
|
||||
}
|
||||
|
||||
return ToPascalToken(workingName);
|
||||
}
|
||||
|
||||
private static string ToPascalToken(string token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token))
|
||||
return token;
|
||||
|
||||
if (token.Length == 1)
|
||||
return token.ToUpperInvariant();
|
||||
|
||||
return char.ToUpperInvariant(token[0]) + token.Substring(1);
|
||||
}
|
||||
|
||||
private static string GenerateSource(
|
||||
INamedTypeSymbol typeSymbol,
|
||||
IReadOnlyList<NodeBindingInfo> bindings,
|
||||
bool generateReadyOverride)
|
||||
{
|
||||
var namespaceName = typeSymbol.GetNamespace();
|
||||
var generics = typeSymbol.ResolveGenerics();
|
||||
|
||||
var sb = new StringBuilder()
|
||||
.AppendLine("// <auto-generated />")
|
||||
.AppendLine("#nullable enable");
|
||||
|
||||
if (namespaceName is not null)
|
||||
{
|
||||
sb.AppendLine()
|
||||
.AppendLine($"namespace {namespaceName};");
|
||||
}
|
||||
|
||||
sb.AppendLine()
|
||||
.AppendLine($"partial class {typeSymbol.Name}{generics.Parameters}");
|
||||
|
||||
foreach (var constraint in generics.Constraints)
|
||||
sb.AppendLine($" {constraint}");
|
||||
|
||||
sb.AppendLine("{")
|
||||
.AppendLine($" private void {InjectionMethodName}()")
|
||||
.AppendLine(" {");
|
||||
|
||||
foreach (var binding in bindings)
|
||||
{
|
||||
var typeName = binding.FieldSymbol.Type
|
||||
.WithNullableAnnotation(NullableAnnotation.None)
|
||||
.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
|
||||
|
||||
var accessor = binding.Required ? "GetNode" : "GetNodeOrNull";
|
||||
var pathLiteral = EscapeStringLiteral(binding.Path);
|
||||
sb.AppendLine(
|
||||
$" {binding.FieldSymbol.Name} = {accessor}<{typeName}>(\"{pathLiteral}\");");
|
||||
}
|
||||
|
||||
sb.AppendLine(" }");
|
||||
|
||||
if (generateReadyOverride)
|
||||
{
|
||||
sb.AppendLine()
|
||||
.AppendLine($" partial void {ReadyHookMethodName}();")
|
||||
.AppendLine()
|
||||
.AppendLine(" public override void _Ready()")
|
||||
.AppendLine(" {")
|
||||
.AppendLine($" {InjectionMethodName}();")
|
||||
.AppendLine($" {ReadyHookMethodName}();")
|
||||
.AppendLine(" }");
|
||||
}
|
||||
|
||||
sb.AppendLine("}");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string GetHintName(INamedTypeSymbol typeSymbol)
|
||||
{
|
||||
return typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
|
||||
.Replace("global::", string.Empty)
|
||||
.Replace("<", "_")
|
||||
.Replace(">", "_")
|
||||
.Replace(",", "_")
|
||||
.Replace(" ", string.Empty)
|
||||
.Replace(".", "_") + ".GetNode.g.cs";
|
||||
}
|
||||
|
||||
private static string EscapeStringLiteral(string value)
|
||||
{
|
||||
return value
|
||||
.Replace("\\", "\\\\")
|
||||
.Replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
private static IReadOnlyList<TypeGroup> GroupByContainingType(IEnumerable<FieldCandidate> candidates)
|
||||
{
|
||||
var groupMap = new Dictionary<INamedTypeSymbol, TypeGroup>(SymbolEqualityComparer.Default);
|
||||
var orderedGroups = new List<TypeGroup>();
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var typeSymbol = candidate.FieldSymbol.ContainingType;
|
||||
if (!groupMap.TryGetValue(typeSymbol, out var group))
|
||||
{
|
||||
group = new TypeGroup(typeSymbol);
|
||||
groupMap.Add(typeSymbol, group);
|
||||
orderedGroups.Add(group);
|
||||
}
|
||||
|
||||
group.Fields.Add(candidate);
|
||||
}
|
||||
|
||||
return orderedGroups;
|
||||
}
|
||||
|
||||
private sealed class FieldCandidate
|
||||
{
|
||||
public FieldCandidate(
|
||||
VariableDeclaratorSyntax variable,
|
||||
IFieldSymbol fieldSymbol)
|
||||
{
|
||||
Variable = variable;
|
||||
FieldSymbol = fieldSymbol;
|
||||
}
|
||||
|
||||
public VariableDeclaratorSyntax Variable { get; }
|
||||
|
||||
public IFieldSymbol FieldSymbol { get; }
|
||||
}
|
||||
|
||||
private sealed class NodeBindingInfo
|
||||
{
|
||||
public NodeBindingInfo(
|
||||
IFieldSymbol fieldSymbol,
|
||||
string path,
|
||||
bool required)
|
||||
{
|
||||
FieldSymbol = fieldSymbol;
|
||||
Path = path;
|
||||
Required = required;
|
||||
}
|
||||
|
||||
public IFieldSymbol FieldSymbol { get; }
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public bool Required { get; }
|
||||
}
|
||||
|
||||
private enum NodeLookupModeValue
|
||||
{
|
||||
Auto = 0,
|
||||
UniqueName = 1,
|
||||
RelativePath = 2,
|
||||
AbsolutePath = 3
|
||||
}
|
||||
|
||||
private sealed class TypeGroup
|
||||
{
|
||||
public TypeGroup(INamedTypeSymbol typeSymbol)
|
||||
{
|
||||
TypeSymbol = typeSymbol;
|
||||
}
|
||||
|
||||
public INamedTypeSymbol TypeSymbol { get; }
|
||||
|
||||
public List<FieldCandidate> Fields { get; } = new();
|
||||
}
|
||||
}
|
||||
@ -6,8 +6,40 @@
|
||||
|
||||
- 与 Godot 场景相关的编译期生成能力
|
||||
- 基于 Roslyn 的增量生成器实现
|
||||
- `[GetNode]` 字段注入,减少 `_Ready()` 里的 `GetNode<T>()` 样板代码
|
||||
|
||||
## 使用建议
|
||||
|
||||
- 仅在 Godot + C# 项目中启用
|
||||
- 非 Godot 项目可只使用 GFramework.SourceGenerators
|
||||
|
||||
## GetNode 用法
|
||||
|
||||
```csharp
|
||||
using GFramework.Godot.SourceGenerators.Abstractions;
|
||||
using Godot;
|
||||
|
||||
public partial class TopBar : HBoxContainer
|
||||
{
|
||||
[GetNode]
|
||||
private HBoxContainer _leftContainer = null!;
|
||||
|
||||
[GetNode]
|
||||
private HBoxContainer _rightContainer = null!;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
__InjectGetNodes_Generated();
|
||||
OnReadyAfterGetNode();
|
||||
}
|
||||
|
||||
private void OnReadyAfterGetNode()
|
||||
{
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
当未显式填写路径时,生成器会默认将字段名推导为唯一名路径:
|
||||
|
||||
- `_leftContainer` -> `%LeftContainer`
|
||||
- `m_rightContainer` -> `%RightContainer`
|
||||
|
||||
@ -6,6 +6,9 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<!-- Godot.SourceGenerators expects this property from Godot.NET.Sdk.
|
||||
Provide a safe default so source generators can run in plain SDK-style builds as well. -->
|
||||
<GodotProjectDir Condition="'$(GodotProjectDir)' == ''">$(MSBuildProjectDirectory)</GodotProjectDir>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
namespace GFramework.SourceGenerators.Abstractions.Bases;
|
||||
|
||||
/// <summary>
|
||||
/// 标记类的优先级,自动生成 <see cref="GFramework.Core.Abstractions.Bases.IPrioritized"/> 接口实现
|
||||
/// 标记类的优先级,自动生成 <c>GFramework.Core.Abstractions.Bases.IPrioritized</c> 接口实现。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 使用此特性可以避免手动实现 IPrioritized 接口。
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<Project>
|
||||
<!-- import parent: https://docs.microsoft.com/en-us/visualstudio/msbuild/customize-your-build -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
|
||||
<EmbedUntrackedSources>true</EmbedUntrackedSources>
|
||||
<!--
|
||||
|
||||
@ -14,9 +14,6 @@
|
||||
<ItemGroup>
|
||||
<Using Include="GFramework.SourceGenerators.Abstractions"/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GFramework.Core.Abstractions\GFramework.Core.Abstractions.csproj" PrivateAssets="all"/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Meziantou.Analyzer" Version="3.0.25">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<Project>
|
||||
<!-- import parent: https://docs.microsoft.com/en-us/visualstudio/msbuild/customize-your-build -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
|
||||
<EmbedUntrackedSources>true</EmbedUntrackedSources>
|
||||
<!--
|
||||
|
||||
@ -56,7 +56,7 @@ public sealed class PriorityGenerator : MetadataAttributeClassGeneratorBase
|
||||
}
|
||||
|
||||
// 3. 必须是 partial
|
||||
if (syntax.Modifiers.All(m => m.Kind() != SyntaxKind.PartialKeyword))
|
||||
if (syntax.Modifiers.All(m => !m.IsKind(SyntaxKind.PartialKeyword)))
|
||||
{
|
||||
context.ReportDiagnostic(Diagnostic.Create(
|
||||
PriorityDiagnostic.MustBePartial,
|
||||
|
||||
@ -19,7 +19,7 @@ internal static class PriorityDiagnostic
|
||||
category: Category,
|
||||
defaultSeverity: DiagnosticSeverity.Error,
|
||||
isEnabledByDefault: true,
|
||||
description: "Priority 特性设计用于类级别的优先级标记,不支持其他类型。"
|
||||
description: "Priority 特性设计用于类级别的优先级标记,不支持其他类型."
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
@ -32,7 +32,7 @@ internal static class PriorityDiagnostic
|
||||
category: Category,
|
||||
defaultSeverity: DiagnosticSeverity.Warning,
|
||||
isEnabledByDefault: true,
|
||||
description: "当类已经手动实现 IPrioritized 接口时,源生成器将跳过代码生成以避免冲突。"
|
||||
description: "当类已经手动实现 IPrioritized 接口时,源生成器将跳过代码生成以避免冲突."
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
@ -45,7 +45,7 @@ internal static class PriorityDiagnostic
|
||||
category: Category,
|
||||
defaultSeverity: DiagnosticSeverity.Error,
|
||||
isEnabledByDefault: true,
|
||||
description: "源生成器需要在 partial 类中生成 IPrioritized 接口实现。"
|
||||
description: "源生成器需要在 partial 类中生成 IPrioritized 接口实现."
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
@ -58,7 +58,7 @@ internal static class PriorityDiagnostic
|
||||
category: Category,
|
||||
defaultSeverity: DiagnosticSeverity.Error,
|
||||
isEnabledByDefault: true,
|
||||
description: "Priority 特性必须提供一个有效的整数值。"
|
||||
description: "Priority 特性必须提供一个有效的整数值."
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
@ -71,7 +71,7 @@ internal static class PriorityDiagnostic
|
||||
category: Category,
|
||||
defaultSeverity: DiagnosticSeverity.Error,
|
||||
isEnabledByDefault: true,
|
||||
description: "Priority 特性仅支持顶层类,不支持嵌套类。请将嵌套类移至命名空间级别。"
|
||||
description: "Priority 特性仅支持顶层类,不支持嵌套类.请将嵌套类移至命名空间级别."
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
@ -84,6 +84,6 @@ internal static class PriorityDiagnostic
|
||||
category: "GFramework.Usage",
|
||||
defaultSeverity: DiagnosticSeverity.Info,
|
||||
isEnabledByDefault: true,
|
||||
description: "当获取实现了 IPrioritized 接口的服务时,应使用 GetAllByPriority 方法以确保按优先级排序。"
|
||||
description: "当获取实现了 IPrioritized 接口的服务时,应使用 GetAllByPriority 方法以确保按优先级排序."
|
||||
);
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<PackageId>GeWuYou.$(AssemblyName)</PackageId>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
|
||||
<!-- 这是 Analyzer,不是运行时库 -->
|
||||
<IsRoslynAnalyzer>true</IsRoslynAnalyzer>
|
||||
@ -30,7 +30,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\$(AssemblyName).Abstractions\$(AssemblyName).Abstractions.csproj" PrivateAssets="all"/>
|
||||
<ProjectReference Include="..\$(AssemblyName).Common\$(AssemblyName).Common.csproj" PrivateAssets="all"/>
|
||||
<ProjectReference Include="..\GFramework.Core.Abstractions\GFramework.Core.Abstractions.csproj" PrivateAssets="all"/>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- ★关键:只把 Generator DLL 放进 analyzers -->
|
||||
|
||||
@ -16,11 +16,16 @@
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
|
||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||
<!-- This package is a pure meta-package that only aggregates dependencies. -->
|
||||
<NoPackageAnalysis>false</NoPackageAnalysis>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- 排除不需要参与打包/编译的目录 -->
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath=""/>
|
||||
<None Include="packaging/_._" Pack="true" PackagePath="lib/net8.0/_._"/>
|
||||
<None Include="packaging/_._" Pack="true" PackagePath="lib/net9.0/_._"/>
|
||||
<None Include="packaging/_._" Pack="true" PackagePath="lib/net10.0/_._"/>
|
||||
<None Remove="GFramework.Core\**"/>
|
||||
<None Remove="GFramework.Game\**"/>
|
||||
<None Remove="GFramework.Godot\**"/>
|
||||
@ -41,6 +46,7 @@
|
||||
<None Remove="GFramework.SourceGenerators\**"/>
|
||||
<None Remove="GFramework.SourceGenerators.Common\**"/>
|
||||
<None Remove="GFramework.SourceGenerators.Tests\**"/>
|
||||
<None Remove="GFramework.Godot.SourceGenerators.Tests\**"/>
|
||||
<None Remove="GFramework.Godot.SourceGenerators.Abstractions\**"/>
|
||||
<None Remove="GFramework.SourceGenerators.Abstractions\**"/>
|
||||
<None Remove="GFramework.Core.Abstractions\**"/>
|
||||
@ -80,6 +86,7 @@
|
||||
<Compile Remove="GFramework.SourceGenerators\**"/>
|
||||
<Compile Remove="GFramework.SourceGenerators.Common\**"/>
|
||||
<Compile Remove="GFramework.SourceGenerators.Tests\**"/>
|
||||
<Compile Remove="GFramework.Godot.SourceGenerators.Tests\**"/>
|
||||
<Compile Remove="GFramework.Godot.SourceGenerators.Abstractions\**"/>
|
||||
<Compile Remove="GFramework.SourceGenerators.Abstractions\**"/>
|
||||
<Compile Remove="GFramework.Core.Abstractions\**"/>
|
||||
@ -105,6 +112,7 @@
|
||||
<EmbeddedResource Remove="GFramework.SourceGenerators\**"/>
|
||||
<EmbeddedResource Remove="GFramework.SourceGenerators.Common\**"/>
|
||||
<EmbeddedResource Remove="GFramework.SourceGenerators.Tests\**"/>
|
||||
<EmbeddedResource Remove="GFramework.Godot.SourceGenerators.Tests\**"/>
|
||||
<EmbeddedResource Remove="GFramework.Godot.SourceGenerators.Abstractions\**"/>
|
||||
<EmbeddedResource Remove="GFramework.SourceGenerators.Abstractions\**"/>
|
||||
<EmbeddedResource Remove="GFramework.Core.Abstractions\**"/>
|
||||
|
||||
@ -34,6 +34,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GFramework.Ecs.Arch.Tests",
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GFramework.Game.Tests", "GFramework.Game.Tests\GFramework.Game.Tests.csproj", "{738DC58A-0387-4D75-AA96-1C1D8C29D350}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GFramework.Godot.SourceGenerators.Tests", "GFramework.Godot.SourceGenerators.Tests\GFramework.Godot.SourceGenerators.Tests.csproj", "{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@ -248,6 +250,18 @@ Global
|
||||
{738DC58A-0387-4D75-AA96-1C1D8C29D350}.Release|x64.Build.0 = Release|Any CPU
|
||||
{738DC58A-0387-4D75-AA96-1C1D8C29D350}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{738DC58A-0387-4D75-AA96-1C1D8C29D350}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
// meta-description: 负责管理场景的生命周期和架构关联
|
||||
using Godot;
|
||||
using GFramework.Core.Abstractions.Controller;
|
||||
using GFramework.Godot.SourceGenerators.Abstractions;
|
||||
using GFramework.SourceGenerators.Abstractions.Logging;
|
||||
using GFramework.SourceGenerators.Abstractions.Rule;
|
||||
|
||||
@ -16,7 +17,15 @@ public partial class _CLASS_ :_BASE_,IController
|
||||
/// </summary>
|
||||
public override void _Ready()
|
||||
{
|
||||
|
||||
__InjectGetNodes_Generated();
|
||||
OnReadyAfterGetNode();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 节点注入完成后的初始化钩子。
|
||||
/// </summary>
|
||||
private void OnReadyAfterGetNode()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ using GFramework.Core.Abstractions.Controller;
|
||||
using GFramework.Core.Extensions;
|
||||
using GFramework.Game.Abstractions.UI;
|
||||
using GFramework.Godot.UI;
|
||||
using GFramework.Godot.SourceGenerators.Abstractions;
|
||||
using GFramework.SourceGenerators.Abstractions.Logging;
|
||||
using GFramework.SourceGenerators.Abstractions.Rule;
|
||||
|
||||
@ -19,7 +20,15 @@ public partial class _CLASS_ :_BASE_,IController,IUiPageBehaviorProvider,IUiPage
|
||||
/// </summary>
|
||||
public override void _Ready()
|
||||
{
|
||||
|
||||
__InjectGetNodes_Generated();
|
||||
OnReadyAfterGetNode();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 节点注入完成后的初始化钩子。
|
||||
/// </summary>
|
||||
private void OnReadyAfterGetNode()
|
||||
{
|
||||
}
|
||||
/// <summary>
|
||||
/// 页面行为实例的私有字段
|
||||
@ -84,4 +93,4 @@ public partial class _CLASS_ :_BASE_,IController,IUiPageBehaviorProvider,IUiPage
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -228,4 +228,3 @@ architecture.RegisterLifecycleHook(new MyLifecycleHook());
|
||||
**相关文档**:
|
||||
|
||||
- [核心框架概述](./index.md)
|
||||
- [ADR-001: 拆分 Architecture 核心类](/docs/adr/001-split-architecture-class.md)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -100,8 +100,8 @@ void ClearAll();
|
||||
void RegisterHandler(IPauseHandler handler);
|
||||
void UnregisterHandler(IPauseHandler handler);
|
||||
|
||||
// 状态变化事件
|
||||
event Action<PauseGroup, bool>? OnPauseStateChanged;
|
||||
// 状态变化事件
|
||||
event EventHandler<PauseStateChangedEventArgs>? OnPauseStateChanged;
|
||||
```
|
||||
|
||||
## 基本用法
|
||||
@ -377,16 +377,16 @@ public partial class PauseIndicator : IController
|
||||
_pauseManager.OnPauseStateChanged += OnPauseStateChanged;
|
||||
}
|
||||
|
||||
private void OnPauseStateChanged(PauseGroup group, bool isPaused)
|
||||
{
|
||||
Console.WriteLine($"暂停状态变化: 组={group}, 暂停={isPaused}");
|
||||
|
||||
if (group == PauseGroup.Global)
|
||||
{
|
||||
if (isPaused)
|
||||
{
|
||||
ShowPauseIndicator();
|
||||
}
|
||||
private void OnPauseStateChanged(object? sender, PauseStateChangedEventArgs e)
|
||||
{
|
||||
Console.WriteLine($"暂停状态变化: 组={e.Group}, 暂停={e.IsPaused}");
|
||||
|
||||
if (e.Group == PauseGroup.Global)
|
||||
{
|
||||
if (e.IsPaused)
|
||||
{
|
||||
ShowPauseIndicator();
|
||||
}
|
||||
else
|
||||
{
|
||||
HidePauseIndicator();
|
||||
@ -705,7 +705,7 @@ public partial class ProperCleanup : IController
|
||||
_pauseManager.OnPauseStateChanged -= OnPauseChanged;
|
||||
}
|
||||
|
||||
private void OnPauseChanged(PauseGroup group, bool isPaused) { }
|
||||
private void OnPauseChanged(object? sender, PauseStateChangedEventArgs e) { }
|
||||
}
|
||||
```
|
||||
|
||||
@ -743,13 +743,13 @@ public partial class PauseMenu : Control
|
||||
|
||||
// 方案 2: 监听暂停事件
|
||||
var pauseManager = this.GetUtility<IPauseStackManager>();
|
||||
pauseManager.OnPauseStateChanged += (group, isPaused) =>
|
||||
{
|
||||
if (group == PauseGroup.Global)
|
||||
{
|
||||
Visible = isPaused;
|
||||
}
|
||||
};
|
||||
pauseManager.OnPauseStateChanged += (_, e) =>
|
||||
{
|
||||
if (e.Group == PauseGroup.Global)
|
||||
{
|
||||
Visible = e.IsPaused;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -887,15 +887,15 @@ public class PauseEventBridge : AbstractSystem
|
||||
{
|
||||
var pauseManager = this.GetUtility<IPauseStackManager>();
|
||||
|
||||
pauseManager.OnPauseStateChanged += (group, isPaused) =>
|
||||
{
|
||||
// 发送暂停事件
|
||||
this.SendEvent(new GamePausedEvent
|
||||
{
|
||||
Group = group,
|
||||
IsPaused = isPaused
|
||||
});
|
||||
};
|
||||
pauseManager.OnPauseStateChanged += (_, e) =>
|
||||
{
|
||||
// 发送暂停事件
|
||||
this.SendEvent(new GamePausedEvent
|
||||
{
|
||||
Group = e.Group,
|
||||
IsPaused = e.IsPaused
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -44,7 +44,7 @@ public interface IPauseStackManager : IContextUtility
|
||||
int GetPauseDepth(PauseGroup group = PauseGroup.Global);
|
||||
|
||||
// 暂停状态变化事件
|
||||
event Action<PauseGroup, bool>? OnPauseStateChanged;
|
||||
event EventHandler<PauseStateChangedEventArgs>? OnPauseStateChanged;
|
||||
}
|
||||
```
|
||||
|
||||
@ -477,12 +477,12 @@ public partial class PauseIndicator : Label
|
||||
pauseManager.OnPauseStateChanged -= OnPauseStateChanged;
|
||||
}
|
||||
|
||||
private void OnPauseStateChanged(PauseGroup group, bool isPaused)
|
||||
private void OnPauseStateChanged(object? sender, PauseStateChangedEventArgs e)
|
||||
{
|
||||
if (group == PauseGroup.Global)
|
||||
if (e.Group == PauseGroup.Global)
|
||||
{
|
||||
Text = isPaused ? "游戏已暂停" : "游戏运行中";
|
||||
Visible = isPaused;
|
||||
Text = e.IsPaused ? "游戏已暂停" : "游戏运行中";
|
||||
Visible = e.IsPaused;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -502,16 +502,16 @@ public partial class PauseDebugger : Node
|
||||
pauseManager.OnPauseStateChanged += OnPauseStateChanged;
|
||||
}
|
||||
|
||||
private void OnPauseStateChanged(PauseGroup group, bool isPaused)
|
||||
private void OnPauseStateChanged(object? sender, PauseStateChangedEventArgs e)
|
||||
{
|
||||
var pauseManager = this.GetUtility<IPauseStackManager>();
|
||||
|
||||
GD.Print($"=== 暂停状态变化 ===");
|
||||
GD.Print($"组: {group}");
|
||||
GD.Print($"状态: {(isPaused ? "暂停" : "恢复")}");
|
||||
GD.Print($"深度: {pauseManager.GetPauseDepth(group)}");
|
||||
GD.Print($"组: {e.Group}");
|
||||
GD.Print($"状态: {(e.IsPaused ? "暂停" : "恢复")}");
|
||||
GD.Print($"深度: {pauseManager.GetPauseDepth(e.Group)}");
|
||||
|
||||
var reasons = pauseManager.GetPauseReasons(group);
|
||||
var reasons = pauseManager.GetPauseReasons(e.Group);
|
||||
if (reasons.Count > 0)
|
||||
{
|
||||
GD.Print($"原因:");
|
||||
@ -609,9 +609,9 @@ public partial class PauseDebugger : Node
|
||||
|
||||
7. **使用事件监听暂停状态**:实现响应式 UI
|
||||
```csharp
|
||||
pauseManager.OnPauseStateChanged += (group, isPaused) =>
|
||||
pauseManager.OnPauseStateChanged += (_, e) =>
|
||||
{
|
||||
UpdateUI(isPaused);
|
||||
UpdateUI(e.IsPaused);
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
1
packaging/_._
Normal file
1
packaging/_._
Normal file
@ -0,0 +1 @@
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user