GodotC# 项目初始化

在 GodotC# 的好处就是就是依靠引入 dotnet 大量支持, 相比 GDScript 更加现代化, 而目前主流单元测试框架以下方案:

这里采用比较简洁小巧的 xUnit, 避免引入过重测试单元

注意: GodotC# 和 GDScript 要求是不一样, GodotC# 更加需要严格工程化, 也就是 UI 层和功能层要拆分出来

另外还需要注意: 最好采用 Target Framework = net8.0, 强制版本升级到 net10.0 会导致很多组件更新出现异常

构建 GodotC# 项目应该拆分为以下目录, 这里假设以游戏项目 P21 为项目根目录:

  • P21/P21Game: GodotC# 创建游戏项目根目录, 由 Godot 直接创建

  • P21/P21Tests: GodotC# 内部测试单元目录,

  • P21/P21Utility: GodotC# 涉及到的 网络/Protobuf 等扩展工具

  • P21/P21Config: 策划配置生成的配表目录

  • P21/P21Resources: 美术资源放置目录, 程序打包的时候不会以其打入, 只是作为美术设计稿等客户端参考目录

  • P21/P21.csproj: 游戏项目的主要根目录 C# 工程文件, 用于引入其他相关模块

这里子工程命名规则为 {项目名目录}/{项目名}{子工程}, 也就是 ~/P21/P21Game 和 ~/P21/P21Tests(注意首字母必须是大小写英文)

GodotC# 项目更加追求的专业级的维护方式, 要求同级的目录需要声明不同的模块, 这也是为了后续的项目可维护性考虑

这里就是 GDScript 和 C# 作为开发主体区别最大的方法, 不同于以前直接 Godot 创建项目, 需要在命令行之中手动采用初始化项目:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 这里基于本地路径创建个目录
mkdir -p ~/P21 && cd ~/P21/ # 这里就是游戏项目根目录
mkdir -p {P21Utility,P21Tests,P21Game} # 目前只需要这些目录, 后面扩展其他

# 创建空类库文件, 参数说明:
# -n P21:指定工程名称为 P21(生成 P21.csproj)
# --framework net8.0: 指定目标框架, 但是项目兼容最好采用 net8.0-lts, 有些第三方还没有处理好 net10.0-lts 支持
# -o . : 避免在当前目录重新创建一层目录
# 如果提示 “net8.0”不是“-f”的有效值 代表目前本地没有 net8.0
# 我这边是 apt 安装的, 所以需要 apt 安装 net8.0 的支持, 执行如下命令即可
# sudo apt install -y dotnet-sdk-8.0
dotnet new classlib -n P21 --framework net8.0 -o .
dotnet new classlib -n P21Utility --framework net8.0 -o P21Utility
dotnet new classlib -n P21Tests --framework net8.0 -o P21Tests
# 这里就会生成
# - {用户目录}/P21/P21.csproj
# - {用户目录}/P21/P21Utility/P21Utility.csproj
# - {用户目录}/P21/P21Tests/P21Tests.csproj

# 删除掉多余的类文件
rm Class1.cs P21Utility/Class1.cs P21Tests/Class1.cs

# 构建生成 Sln 方案
dotnet new sln -n P21 # 生成主项目工程方案
dotnet sln add P21.csproj # 将根目录加入工程
dotnet sln add P21Tests/P21Tests.csproj # 将 Tests 加入工程
dotnet sln add P21Utility/P21Utility.csproj # 将 Utility 加入工程

测试单元初始化

之后就是直接对所有子模块进行初始化配置, 首先指定 Tests 测试单元依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 进入 Tests 单元目录配置
cd ~/P21/P21Tests
dotnet add package xunit.v3 # 添加 xunit 测试框架核心包
dotnet add package Microsoft.NET.Test.Sdk # 微软测试 SDK
dotnet add package coverlet.collector # 添加代码覆盖率工具, 这个组件可选

# 还有一点, 在 P21Tests.csproj 文件当中添加或者修改配置
# <PropertyGroup>
# <IsPackable>false</IsPackable>
# </PropertyGroup>
# 这个配置说明该项目不参与打包过程, 避免出包的时候做无意义打包

# 这里引入 P21Utility 项目, 让后续 P21Utility 内部功能也能被支持单元测试
# 其他后续 GodotC# 项目后续也是这样引入到内部测试单元测试
dotnet add reference ../P21Utility/P21Utility.csproj
dotnet restore # 最后确认依赖已经下载完成

注意: 如果 Rider 直接打开 P21 目录会发现命令行执行是正确, 但 IDE 内部报错无法执行; C# 项目没有多项目概念所以需要按单独项目打开

需要 Rider 直接打开 /P21/P21Tests 项目, 这样 Rider 内部就会自动扫描到对应依赖, 在 Rider 添加 EasyTests.cs
文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
using Xunit;

// C# 新版本语法糖, 不再需要用花括号包裹
// 默认项目都是需要放置在自身工程名下的命名空间
// 这样的好处就是避免污染全局命名空间的函数和方法
namespace P21Tests;

/// <summary>
/// 简单的功能测试, Xunit 还提供日志打印功能, 方便输出一些日志报告
/// 这里采用 C# 新版本语法糖 class XXX(构建方法参数), 约等于如下实现:
/// <example>
/// public class EasyTests{
/// // 初始化构建
/// EasyTests(ITestOutputHelper helper){}
/// }
/// </example>
/// 默认 Xunit 会自动对构造方法的 ITestOutputHelper 做加载注入, 这个工具类就是测试打印功能
/// </summary>
public class EasyTests(ITestOutputHelper helper)
{
/// <summary>
/// 断言测试
/// </summary>
[Fact]
public void AssertTest()
{
Assert.True(true);
Assert.False(false);
Assert.Equal("Hello World!", "Hello World!");
Assert.NotEqual("Hello", "World");
Assert.Empty(Enumerable.Empty<string>());
helper.WriteLine("Assert Test Pass");
}
}

dotnet8 之后的语法糖确实很不错, C# 这部分功能扩展相比较 GDScript 脚本规范合理很多

工具扩展初始化

通用工具和消息序列化等底层功能不要和游戏工程放置一起, 而是要提取出来单独构建成工程, 这就是 P21Utility 的由来

为什么需要这样区分开? 这是为了游戏的职责隔离出来从而降低耦合, 具体体现在如下方面:

  • 游戏工程(P21Game): 只负责游戏逻辑、UI、动画 交互、节点管理, 并且负责调用底层扩展工具

  • 底层扩展(P21Utility): 封装 C# 底层功能暴露给游戏当中调用处理, 并支持 Protobuf 这类序列化消息文件

目前项目先引入 Protobuf 来进行构建用于做网络传输功能, 输入以下命令来初始化项目:

1
2
3
4
5
6
7
8
# 进入 Tests 单元目录配置
cd ~/P21/P21Utility
dotnet add package Google.Protobuf # Protobuf 核心库
dotnet add package Google.Protobuf.Tools # Protobuf 工具库
dotnet restore # 最后确认依赖已经下载完成

# 创建放置 *.proto 文件的目录, 编译打包的时候需要排除将这些文件打包进去
mkdir ProtoFiles

这里相对比较简单, 因为主要是 P21Utility.csproj 的配置文件需要加的东西比较多, 这里需要打开 P21Utility 工程修改以下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
<!-- P21Utility 底层通用工具库  -->
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<!-- 这里就是我自定义的全局属性 -->
<!-- ProtoFiles 代表服务端和客户端共享 *.proto 文件 -->
<!-- Protobuf 就是执行打包之后生成出来的序列化构建文件 -->
<ProtobufInputDir>ProtoFiles</ProtobufInputDir>
<ProtobufOutputDir>Protobuf</ProtobufOutputDir>

<!-- 这里就是通过官方文档获取的环境变量, 注意: C#配置通过 $(XXX) 可以加载到全局很多变量, 包括外部的环境变量 -->
<!-- 这里就是加载外部环境变量配置的三个值, 从外部加载可以避免硬编码后续不好调整 -->
<!-- Manual:https://chromium.googlesource.com/external/github.com/grpc/grpc/+/HEAD/src/csharp/BUILD-INTEGRATION.md -->
<!-- PROTOBUF_PROTOC 就是 Google 官方下载的 protoc.exe 编译器文件路径 -->
<!-- PROTOBUF_TOOLS_OS 就是对应 OS 平台, 基本就是 linux, macosx, windows 当中选择 -->
<!-- PROTOBUF_TOOLS_CPU 就是对应 CPU 平台, 基本就是 x86, x64, arm64 当中选择 -->
<!-- 也就是要求你管理员当中必须手动设置以三个环境变量, Linux 直接配置以下环境变量命令即可 -->
<!-- export PROTOBUF_PROTOC={自定义目录}/protoc -->
<!-- export PROTOBUF_TOOLS_OS=linux -->
<!-- export PROTOBUF_TOOLS_CPU=x64 -->
<!-- Protoc 下载地址: https://github.com/protocolbuffers/protobuf/releases -->
<ProtobufExecBIN>$(PROTOBUF_PROTOC)</ProtobufExecBIN>
<ProtobufExecOS>$(PROTOBUF_TOOLS_OS)</ProtobufExecOS>
<ProtobufExecCPU>$(PROTOBUF_TOOLS_CPU)</ProtobufExecCPU>
</PropertyGroup>

<!-- 第三方工具库 -->
<ItemGroup>
<PackageReference Include="Google.Protobuf" Version="3.33.4"/>
<PackageReference Include="Google.Protobuf.Tools" Version="3.33.4"/>
</ItemGroup>

<!-- 引入目前项目的 ProtoFiles 相关 proto 文件, 展示在工程项目之中 -->
<ItemGroup>
<Content Include="$(ProtobufInputDir)/**/*.proto">
<!-- 排除在单文件发布之外 -->
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<!-- 不复制到输出目录(bin/Debug/net8.0 等) -->
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
<!-- 不包含到发布包中 -->
<ExcludeFromPublish>true</ExcludeFromPublish>
</Content>
</ItemGroup>


<!-- 编译时候触发的打印内容 -->
<Target Name="PrintVariables" BeforeTargets="CoreCompile">
<Message Importance="high" Text="Protobuf Execute Binary -> $(ProtobufExecBIN)"/>
<Message Importance="high" Text="Protobuf Execute OS -> $(ProtobufExecOS)"/>
<Message Importance="high" Text="Protobuf Execute CPU -> $(ProtobufExecCPU)"/>
<Message Importance="high" Text="Protobuf Input Files - > $(ProtobufInputDir)/**/*.proto"/>
<Message Importance="high" Text="Protobuf Output Directory -> $(ProtobufOutputDir)"/>
</Target>

<!-- 编译的时候触发的 Protobuf 打包命令 -->
<Target Name="ProtobufGenerate" BeforeTargets="CoreCompile">

<!-- 确认 Protobuf 输出目录是否存在, 如果不存在就是创建目录 -->
<MakeDir Directories="$(ProjectDir)$(ProtobufOutputDir)"
Condition="!Exists('$(ProjectDir)$(ProtobufOutputDir)')"/>

<!-- 读取 Protobuf 之中的所有 *.proto 文件, 准备后续进行打包生成 xxxx.cs 原生文件 -->
<ItemGroup>
<ProtobufFiles Include="$(ProtobufInputDir)/**/*.proto"/>
</ItemGroup>

<!-- 因此这里需要手动去编写打包命令, 方便直接生成对应 Protobuf 类文件 -->
<!-- 最终打包命令 -->
<Exec Command="$(ProtobufExecBIN) -I=$(ProjectDir)$(ProtobufInputDir) --csharp_out=$(ProjectDir)$(ProtobufOutputDir) @(ProtobufFiles->'%(FullPath)', ' ')"/>

</Target>
</Project>

之后就是去下载 Protoc 编译器来配置好环境变量, 这里用命令行安装下载配置到全局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 注意: Protoc 的编译器版本最好是和 Google.Protobuf.Version 声明的版本一致
# 我这边使用的 Google.Protobuf.Version = 3.33.4, 所以需要下载对应版本
# 如果安装缓慢可能需要工具自行处理
wget https://github.com/protocolbuffers/protobuf/releases/download/v33.4/protoc-33.4-linux-x86_64.zip -O /tmp/protoc.zip
unzip /tmp/protoc.zip -d /tmp/protoc

# 注意: protoc 如果是在用户环境变量没办法被 Rider 识别到, 所以需要将其作为全局可用对象来处理
# 这里就需要用到 Linux 开发经验, 将 protoc 提升到全局可执行目录并且写入到全局环境变量
# 需要将 protoc 二进制放置 /usr/local/bin 目录之中并且设置为可执行命令
sudo cp /tmp/protoc/bin/protoc /usr/local/bin
sudo chmod +x /usr/local/bin/protoc

# 写入环境变量到全局, 在 /etc/profile.d 目录下创建自定义的 protoc.conf 环境变量
echo 'export PROTOBUF_PROTOC=/usr/local/bin/protoc
export PROTOBUF_TOOLS_OS=linux
export PROTOBUF_TOOLS_CPU=x64'|sudo tee /etc/profile.d/protoc.sh
source ~/.bashrc
# 如果重启 Rider 还没加载环境变量就重启下电脑查看



# 先构建个测试文件来处理, 这里输出 tests.proto 文件方便 Tests 项目做单元测试
echo "syntax = 'proto3';
option csharp_namespace = 'P21Utility.Generated'; // 输出的命名空间

// 简单 Echo 命令
message Echo{
string message = 1;
}
" > ~/P21/P21Utility/ProtoFiles/tests.proto


# 你直接可以手动配置直接命令行处理打包, 在 P21Utility 目录下执行打包命令
dotnet build # 打包处理, 也可以直接在 Rider 运行编译命令

# 没有什么错误那么应该会输出如下内容:
# Protobuf net8.0 已成功 (0.1 秒) → bin/Debug/net8.0/Protobuf.dll

现在就是构建 P21Utility 测试单元来该功能是否可用, 如果可用就可以让游戏项目直接引入 P21Utility 工程使用.

Protobuf 创建测试

需要确保目前 Tests.csproj 配置当中已经包含有 Protobuf 相关依赖:

1
2
3
4
5
6
7

<Project Sdk="Microsoft.NET.Sdk">
<!-- 这里就代表已经有 P21Utility 依赖 -->
<ItemGroup>
<ProjectReference Include="..\P21Utility\P21Utility.csproj"/>
</ItemGroup>
</Project>

Tests 项目编译测试的时候应该会出现以下错误:

1
2
3
4
5
6
0>Tests.cs(9,21): Error CS0400 : 未能在全局命名空间中找到类型或命名空间名“Google”(是否缺少程序集引用?)
0>Tests.cs(10,21): Error CS0400 : 未能在全局命名空间中找到类型或命名空间名“Google”(是否缺少程序集引用?)
0>Tests.cs(8,20): Error CS0400 : 未能在全局命名空间中找到类型或命名空间名“Google”(是否缺少程序集引用?)
0>Tests.cs(62,28): Error CS0538 : '显式接口声明中的“Google.Protobuf.IMessage”不是接口
0>Tests.cs(154,10): Error CS0538 : '显式接口声明中的“Google.Protobuf.IBufferMessage”不是接口
0>Tests.cs(218,10): Error CS0538 : '显式接口声明中的“Google.Protobuf.IBufferMessage”不是接口

这是因为 Protobuf 内部只是生成数据构建的类文件, 底层还是依赖 Google.Protobuf 来实现序列化二进制

所以对于 Tests 这部分依旧还是引入 Google.Protobuf 功能, 这里在 Tests 项目之中执行命令行:

1
dotnet add package Google.Protobuf # 只要使用到 Protobuf 都要引入核心库

在此执行编译之后应该就不会报错, 之后就是编写对应测试单元用于测试是否能够正常序列化使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
using Google.Protobuf;
using Xunit;
using ProtobufGen = Protobuf.Generated; // 设置别名避免出现冲突

namespace Tests.Protobuf; // 目前处于 Tests/Protobuf 目录之中

/// <summary>
/// 对应 Protobuf.Tests 消息文件
/// ITestOutputHelper 是 Xunit 内置的打印输出流, 可以用于运行测试时候打印一些临时数据
/// </summary>
public class Tests(ITestOutputHelper logger)
{
/// <summary>
/// Protobuf 序列化处理
/// </summary>
[Fact]
public void EchoTest()
{
// 生成消息结构体
var echoMessage = new ProtobufGen.Echo
{
Message = "Hello World"
};


// 打包成二进制
var echoBytes = echoMessage.ToByteArray();
var echoByteString = $"[{string.Join(", ", echoBytes.Select(b => ((sbyte)b).ToString()))}]";
logger.WriteLine($"{nameof(ProtobufGen.Echo)} Serialized: {echoByteString}");


// 测试反序列化成消息结构, 注意这里反序列化的时候可能会抛出错误, 需要做好异常拦截处理
var newEchoMessage = ProtobufGen.Echo.Parser.ParseFrom(echoBytes);
logger.WriteLine($"{nameof(ProtobufGen.Echo)} Deserialized: {newEchoMessage}");
}
}

执行之后测试单元就会输出以下内容(这里的序列化输出内容格式是方便 Java 服务端的 Arrays.toString 做数据打印比较):

1
2
Echo Serialized: [10, 11, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100]
Echo Deserialized: { "message": "Hello World" }

这样就代表测试单元通过了, 工作当中就可以客户端同事来使用这个底层消息类, 方便后面和服务端做消息同步

Godot 初始化

现在就可以通过 GodotC# 来创建底层游戏迎请模块, 这里必须要创建在 P21/P21Game 目录中:

godot-init

不要将代码版本控制设置 P21Game 目录之中, 而是要在全局项目根目录之中管理

这里先随便搭建成项目生成 dotnet 相关项目, 直接生成个空节点之后挂个 C# 脚本, Godot 才会构建 C# 相关依赖

目前 P21Utility 的 Protobuf 已通过单元测试, P21Game 游戏底层需要的话就必须像 Tests 引入对应组件, 所以需要执行以下命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 进入 Godot 游戏子工程
cd ~/P21/P21Game
# 这里不要执行安全 dotnet 组件命令
# 比如执行之前 dotnet add package Google.Protobuf 引入 Protobuf 就会报错
# 提示内容 'P21/P21Game/ 中找不到任何项目'
# 这里就是接下来需要讲 GodotC# 项目初始化另外的问题


# 只要使用到 Protobuf 都要引入核心库
dotnet add package Google.Protobuf

# 声明 Game 内部项目依赖 Protobuf 项目
dotnet add reference ../P21Utility/P21Utility.csproj

采用 GodotC# 启动器创建项目都是默认基于 GDScript 脚本的工程, 所以不会初始化 *.csproj 工程文件

如果要让 Godot 生成 csproj 工程项目, 需要按照以下流程处理:

  1. 创建空场景并设置空节点

  2. 在空节点上构建 C# 脚本

  3. Rider 识别之后会自动启动开发工具

  4. 到这里默认就会在游戏项目当中创建 csproj 工程文件

之后才能做 dotnet 的命令行第三方引入和依赖操作, 这里可以继续执行如下命令:

1
2
3
4
5
6
7
8
9
10
11
12
cd ~/P21/P21Game

# 只要使用到 Protobuf 都要引入核心库
dotnet add package Google.Protobuf

# 声明 Game 内部项目依赖 Utility 项目
dotnet add reference ../P21Utility/P21Utility.csproj
dotnet restore # 构建依赖

# 将游戏工程加入到主工程之中
cd ..
dotnet sln add P21Game/P21Game.csproj

这样就完成了 P21Utility 相关依赖, 从而可以编写 GodotC# 的脚本来确认是否引入是否成功:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
using System.Linq;
using Godot;
using Google.Protobuf;
using ProtobufGen = P21Utility.Generated;

// 归类到 P21Game 命名空间之中
namespace P21Game;

/// <summary>
/// 挂载游戏入口节点的 C# 脚本
/// </summary>
public partial class Main : Node
{
/// <summary>
/// 暴露给外部编辑器的填写的消息
/// </summary>
[Export]
public string Message { get; set; } = string.Empty;


/// <summary>
/// 启动回调
/// </summary>
public override void _Ready()
{
// 转化成二进制消息内容
var echoMessage = new ProtobufGen.Echo()
{
Message = Message
};

var echoBytes = echoMessage.ToByteArray();
var echoByteString = $"[{string.Join(", ", echoBytes.Select(b => ((sbyte)b).ToString()))}]";
GD.Print($"{nameof(ProtobufGen.Echo)} Serialized: {echoByteString}");
}


/// <summary>
/// 加入节点回调
/// </summary>
public override void _EnterTree()
{
GD.Print($"Entered Tree");
}


/// <summary>
/// 游戏帧更新回调
/// </summary>
/// <param name="delta">上一帧延迟</param>
public override void _Process(double delta)
{
SetProcess(false); // 停止更新
GD.Print($"Processing, Delta: {delta}");
}

/// <summary>
/// 物理帧更新回调
/// </summary>
/// <param name="delta">上一帧延迟</param>
public override void _PhysicsProcess(double delta)
{
SetPhysicsProcess(false);
GD.Print($"PhysicsProcessing, Delta: {delta}");
}


/// <summary>
/// 退出节点回调
/// </summary>
public override void _ExitTree()
{
GD.Print($"Exited Tree");
}
}

注意: 如果这里编译飘红字需要确认是不是 RIder 用到非 net8 的版本打包, 目前 Godot4.5 版本只支持 dotnet8 兼容

最后在编辑器面板输入消息并执行就能看到以下内容输出:

godot-protobuf

这样就可以开始规划网络库传输功能实现, 需要说明的是 GodotC# 之中不要用 Godot 底层网络库, 因为 C++ - GDScript - C# 调用链路.

C# 内部调用 GD 底层相关功能起始就是调用 GDScript 去执行更底层的 C++ 功能, 网络库数据返回会多走 GD 层数据拷贝(影响性能)

Logger 日志

一般在 Godot 需要打印下日志的时候你会怎么处理? 可能就是简单的采用 GD.Print 输出

这种方式打印日志方式简单但不合理, 现代开发之中都是统一采用专门日志库来处理游戏日志打印, 而 DotNet 社区之中也有很多日志管理方案.

现代 C# 日志方案推荐采用 Serilog 的社区的日志方案, 这个第三方库配置方便并且容易安装使用, 这边可以试用下该第三方库:

1
2
3
4
5
6
7
# 这里直接进入 P21Game 游戏工程 
cd ~/P21/P21Game

# 安装对应库
dotnet add package Serilog # Serilog 日志库核心
dotnet add package Serilog.Sinks.Console # Serilog 命令行打印输出
dotnet add package Serilog.Sinks.File # Serilog 文件打印输出

一般开发过程只需要打印到命令行即可, 而如果正式环境则会保存到本地日志, 在游戏崩溃异常可以联系玩家提交或上报日志文件用于还原现场

Serilog 代码库: https://github.com/serilog/serilog

直接安装就行了, 整个过程没有一点复杂的操作, 之后就是在启动的时候初始化全局日志初始化配置, 按照之前 C#
脚本追加日志库支持:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
using System.Linq;
using Godot;
using Google.Protobuf;
using Serilog;
using ProtobufGen = P21Utility.Generated;

// 归类到 P21Game 命名空间之中
namespace P21Game;

/// <summary>
/// 挂载游戏入口节点的 C# 脚本
/// </summary>
public partial class Main : Node
{
/// <summary>
/// 暴露给外部编辑器的填写的消息
/// </summary>
[Export]
public string Message { get; set; } = string.Empty;


/// <summary>
/// 启动回调
/// </summary>
public override void _Ready()
{
// Godot 游戏引擎需要配置专享 user 目录, 起始就是要获取 Godot 的 users:// 目录变量, user:// 各自平台代表的变量
// - window: C:\Users\{你的用户名}\AppData\Roaming\Godot\app_userdata\{游戏项目名}\
// - macOS: ~/Library/Application Support/Godot/app_userdata/{游戏项目名}/
// - linux: ~/.local/share/godot/app_userdata/{游戏项目名}/
// - android: /storage/emulated/0/Android/data/{游戏包名}/files/ (外部存储,需开启存储权限)
// - ios: App Sandbox/Container/Data/Application/{随机ID}/Documents/ (沙盒目录,仅游戏可访问)
// 这里就是提取这部分变量之后追加 game.log 日志
var logFilename = ProjectSettings.GlobalizePath("user://game.log");
GD.Print($"Output Log Filename: {logFilename}");


// 初始化日志配置
Log.Logger = new LoggerConfiguration()
.WriteTo.Console() // 设置写入命令行打印
.WriteTo.File(
// 设置写入本地日志文件, 这里日志落地目录要结合 Godot 的本地
// 默认会生成 {logFilename}{日期}{.log:自定义后缀} 日志文件
logFilename,
// 日志文件压缩规则, 按照每日做最大限制压缩
rollingInterval: RollingInterval.Day,
rollOnFileSizeLimit: true,
fileSizeLimitBytes: 1024 * 1024 * 5, // 单个日志文件最大5MB
retainedFileCountLimit: 7 // 仅保留7天的日志文件
)
.CreateLogger();
Log.Information("Logger Initialized, Filename: {LogFilename}", logFilename);
// 如果商业化项目上线之后崩溃异常, 可以让用户重新启动之后填写崩溃报告, 之后将对应 *.log 上报提交到另外的服务端


// 转化成二进制消息内容
var echoMessage = new ProtobufGen.Echo()
{
Message = Message
};

var echoBytes = echoMessage.ToByteArray();
var echoByteString = $"[{string.Join(", ", echoBytes.Select(b => ((sbyte)b).ToString()))}]";

// 不再需要传统 GD 打印
//GD.Print($"{nameof(ProtobufGen.Echo)} Serialized: {echoByteString}");

// 采用最新版本日志库打印, 内部支持序列化变量
Log.Information("{EchoName} Serialized: {EchoByteString}", nameof(ProtobufGen.Echo), echoByteString);
}


/// <summary>
/// 加入节点回调
/// </summary>
public override void _EnterTree()
{
// GD.Print($"Entered Tree");
Log.Information("Entering Tree");
}


/// <summary>
/// 游戏帧更新回调
/// </summary>
/// <param name="delta">上一帧延迟</param>
public override void _Process(double delta)
{
SetProcess(false); // 停止更新
//GD.Print($"Processing, Delta: {delta}");
Log.Information("Processing, Delta: {Delta}", delta);
}

/// <summary>
/// 物理帧更新回调
/// </summary>
/// <param name="delta">上一帧延迟</param>
public override void _PhysicsProcess(double delta)
{
SetPhysicsProcess(false);
//GD.Print($"PhysicsProcessing, Delta: {delta}");
Log.Information("PhysicsProcessing, Delta: {Delta}", delta);
}


/// <summary>
/// 退出节点回调
/// </summary>
public override void _ExitTree()
{
//GD.Print($"Exited Tree");
Log.Information("Exiting Tree");

// 退出的场景经典将日志同步写入到本地
Log.CloseAndFlush();
}
}

启动之后就会打印类似以下日志在 Rider 的开发工具之中:

1
2
3
4
5
[16:04:47 INF] Logger Initialized, Filename: /home/meteorcat/.local/share/godot/app_userdata/P21Game/game.log
[16:04:48 INF] Echo Serialized: []
[16:04:48 INF] PhysicsProcessing, Delta: 0.016666666666666666
[16:04:48 INF] Processing, Delta: 0.14753366666666667
[16:04:52 INF] Exiting Tree

虽然已经实现日志打印内容, 但是可以看到 Godot 编辑器内部日志没有打印, 所以需要命令行打印时候输出到 Godot 的 Output 日志中

这里需要额外编写 Godot 的 Serilog 扩展, 让其获取日志内容打印到 Godot 的之中, 创建以下日志处理器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// 默认先不采用严格模式, 严格模式下不允许有 nullable 传递

#nullable enable
using System;
using System.IO;
using Godot;
using Serilog.Core;
using Serilog.Events;
using Serilog.Formatting;
using Serilog.Formatting.Display;

namespace P21Game.Utils.Logging;

/// <summary>
/// Serilog-Godot 日志扩展
/// </summary>
public class GodotSink : ILogEventSink
{
// 日志格式化器(使用 Serilog 默认的消息格式,兼容 {变量} 占位符)
private readonly ITextFormatter _formatter;

// 默认日志格式:[时间] 消息 (可自定义)
private const string DefaultOutputTemplate = "[{Timestamp:HH:mm:ss}] {Message:lj}{NewLine}{Exception}";


/// <summary>
/// 构造方法
/// </summary>
/// <param name="formatter">日志格式化器</param>
public GodotSink(ITextFormatter? formatter = null)
{
_formatter = formatter ?? new MessageTemplateTextFormatter(DefaultOutputTemplate);
}


/// <summary>
/// 核心拦截方法
/// </summary>
/// <param name="logEvent">日志事件</param>
public void Emit(LogEvent logEvent)
{
// 非空确认
ArgumentNullException.ThrowIfNull(logEvent);

// 将日志事件转化为字符串
using var stringWriter = new StringWriter();
_formatter.Format(logEvent, stringWriter);
var content = stringWriter.ToString().TrimEnd();

// 根据 Serilog 级别匹配 Godot 日志标记 + 调用 PrintRich
// GD.PrintRich 方法支持富文本格式和日志级别标记, 能让日志在 Output 面板显示对应颜色
// GD.PrintRich 的日志内容前添加特定标记, Godot 会自动识别并赋予对应颜色和日志级别
var (godotTag, logContent) = GetGodotLogContent(logEvent.Level, content);
GD.PrintRich($"{godotTag} {logContent}");
}

/// <summary>
/// 映射 Serilog 级别到 Godot 富文本标记
/// </summary>
private static (string Tag, string Content) GetGodotLogContent(LogEventLevel level, string logText)
{
return level switch
{
LogEventLevel.Fatal => ("[FATAL]", logText), // 亮红色: 致命错误
LogEventLevel.Error => ("[ERROR]", logText), // 红色: 普通错误
LogEventLevel.Warning => ("[WARN] ", logText), // 黄色: 警告
LogEventLevel.Information => ("[INFO] ", logText), // 白色: 普通信息
LogEventLevel.Debug => ("[INFO] ", logText), // 调试信息: 归为普通白色
LogEventLevel.Verbose => ("[INFO] ", logText), // 详细信息: 归为普通白色
_ => ("[INFO] ", logText) // 默认: 普通白色
};
}
}

之后就是将日志处理器注册到 Serilog 内部之中, 这种就是 C# 特色的扩展对象注入功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 默认先不采用严格模式, 严格模式下不允许有 nullable 传递

#nullable enable
using System;
using Serilog;
using Serilog.Configuration;
using Serilog.Events;
using Serilog.Formatting;

namespace P21Game.Utils.Logging;

// 默认先不采用严格模式, 严格模式下不允许有 nullable 传递

#nullable enable
using System;
using Serilog;
using Serilog.Configuration;
using Serilog.Events;
using Serilog.Formatting;

namespace P21Game.Utils.Logging;

/// <summary>
/// Serilog 日志扩展方法注入, 让外部的 Serilog 对象支持扩展 *.WriteTo.Godot() 功能
/// </summary>
public static class GodotSinkExtensions
{
/// <summary>
/// 全局注入对象功能
/// </summary>
/// <param name="configuration">日志配置</param>
/// <param name="formatter">日志格式化</param>
/// <param name="level">日志事件等级</param>
/// <returns>全局配置</returns>
public static LoggerConfiguration Godot(
this LoggerSinkConfiguration configuration,
ITextFormatter? formatter = null,
LogEventLevel level = LevelAlias.Minimum
)
{
// 注册自定义 Sink 到 Serilog
ArgumentNullException.ThrowIfNull(configuration);
return configuration.Sink(
new GodotSink(formatter),
level);
}
}

现在因为追加内部扩展方法, 目前支持我们自定义 WriteTo.Godot() 配置, 这里 Serilog 初始化功能就变成如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/// <summary>
/// 挂载游戏入口节点的 C# 脚本
/// </summary>
public partial class Main : Node
{

/// <summary>
/// 启动回调
/// </summary>
public override void _Ready()
{
// 其他略

// 初始化日志配置
Log.Logger = new LoggerConfiguration()
.WriteTo.Console() // 设置写入命令行打印
.WriteTo.Godot() // 注入我们自己扩展的 Godot 输出
.WriteTo.File(
// 设置写入本地日志文件, 这里日志落地目录要结合 Godot 的本地
logFilename,
// 日志文件压缩规则, 按照每日做最大限制压缩
rollingInterval: RollingInterval.Day,
rollOnFileSizeLimit: true,
fileSizeLimitBytes: 1024 * 1024 * 5, // 单个日志文件最大5MB
retainedFileCountLimit: 7 // 仅保留7天的日志文件
)
.CreateLogger();


// 其他略
}
}

至此 Godot 的日志面板就会输出正确的日志内容:

1
2
3
4
5
6
Output Log Filename: /home/meteorcat/.local/share/godot/app_userdata/P21Game/game.log
[INFO] [16:40:17] Logger Initialized, Filename: /home/meteorcat/.local/share/godot/app_userdata/P21Game/game.log
[INFO] [16:40:18] Echo Serialized: []
[INFO] [16:40:18] PhysicsProcessing, Delta: 0.016666666666666666
[INFO] [16:40:18] Processing, Delta: 0.13333333333333333
--- Debugging process stopped ---

如果想让 Godot Output 只打印警告及以上日志, 而控制台/文件打印全部日志, 可给 Godot Sink 指定级别:

1
2
// Godot Output 仅打印 Warning/Error/Fatal
*.WriteTo.Godot(restrictedToMinimumLevel: LogEventLevel.Warning)

这里 Rider 看日志打印会发现相同日志输出两次, 这事因为 GD 和 Console 都是标准输出流, 可以屏蔽 .WriteTo.Console():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 初始化日志配置
Log.Logger = new LoggerConfiguration()
// .WriteTo.Console() // 设置写入命令行打印, 屏蔽避免重复输出
.WriteTo.Godot() // 注入我们自己扩展的 Godot 输出
.WriteTo.File(
// 设置写入本地日志文件, 这里日志落地目录要结合 Godot 的本地
logFilename,
// 日志文件压缩规则, 按照每日做最大限制压缩
rollingInterval: RollingInterval.Day,
rollOnFileSizeLimit: true,
fileSizeLimitBytes: 1024 * 1024 * 5, // 单个日志文件最大5MB
retainedFileCountLimit: 7 // 仅保留7天的日志文件
)
.CreateLogger();

另外为了避免日志重复初始化, 可以做是否判断来处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 避免Serilog日志器重复初始化
if (Log.Logger is not Serilog.Core.Logger)
{
// 初始化日志配置
Log.Logger = new LoggerConfiguration()
// .WriteTo.Console() // 设置写入命令行打印, 屏蔽避免重复输出
.WriteTo.Godot() // 注入我们自己扩展的 Godot 输出
.WriteTo.File(
// 设置写入本地日志文件, 这里日志落地目录要结合 Godot 的本地
logFilename,
// 日志文件压缩规则, 按照每日做最大限制压缩
rollingInterval: RollingInterval.Day,
rollOnFileSizeLimit: true,
fileSizeLimitBytes: 1024 * 1024 * 5, // 单个日志文件最大5MB
retainedFileCountLimit: 7 // 仅保留7天的日志文件
)
.CreateLogger();

Log.Information("Logger Initialized, Filename: {LogFilename}", logFilename);
}

剩下就是做宏条件编译处理而已, 比如编辑器默认命令行输出/正式出包打印到本地日志, 还有环境变量设置全局日志等级

另外需要的配置只要使用的时候查一下资料即可, 都没什么难度

依靠 C# 的社区就可以引入大量企业级的开发库, 这点就是 GodotC# 最不容忽视的地方.