GodotC# 的 Utility 扩展

之前定义过 P21 项目作为示例, 这里是从 P21 项目当中扩展出来的项目工程化补充说明

工程化项目当时创建过以下目录作为项目分层:

  • P21Game: 游戏引擎相关

  • P21Tests: 游戏内部测试单元

  • P21Utility: 内部扩展通用功能

这个篇章就是基于 P21Utility 这方面展开的, 简单说下为什么有 Utility

有时候项目需要用到底层工具库, 比如 Protobuf 序列化、网络、寻路算法 等仅需要纯 C# 的通用功能

这种通用功能只需要 C# 来处理成单独功能库, 那么就不适合放在对外的 P21Game 游戏引擎相关工程之中

并且基于 C# 保证脱离游戏引擎存在, 如果后面迁移到 Unity3d 之类使用 C# 平台还能复用相关代码库

把封装代码塞到 Game 层之中会让游戏工程变得臃肿, 核心游戏逻辑(UI、玩法、节点交互)被这些代码淹没导致后期找 Bug、改功能都会极其繁琐

所以 P21Utility 只做纯 C# 通用功能封装, 对外提供清晰、简洁的 API 从而让 P21Game 只需要 调用 而不用关心底层实现

对于 Utility 层的使用规则:

  1. 绝对不引用 Godot 相关程序集, 禁止在 P21Utility 中引用 Godot.dll, 不依赖任何外部游戏引擎

  2. 只做通用组件而不涉及游戏业务, 比如可以封装通用TCP客户端(支持 Protobuf 编解码), 但不能封装游戏玩家的登录请求发送逻辑

  3. 对外提供高内聚、低耦合的 API, 封装时隐藏底层实现细节, 比如避免将底层 Socket 原生句柄暴露(可以商榷, 有时候要底层处理)

  4. 自带完整的单元测试, Utility 层的每一个功能类、每一个方法都要在 Tests 层中做对应的单元测试, 保证功能可用

这样 P21 的 Utility 层架构其实简单来看就如下所示:

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
P21Utility/
├── P21Utility.csproj # 核心工程(配置Protobuf自动编译)
├── ProtoFiles/ # Protobuf消息文件(与服务端同步)
├── Protobuf/ # 自动生成的Protobuf C#类(无需手动改)
├── Serialization/ # 序列化模块(Protobuf/JSON/二进制)
│ └── ProtobufHelper.cs # 封装Protobuf序列化/反序列化API
├── Network/ # 网络模块(纯C#实现,脱离Godot)
│ ├── TcpClient.cs # 通用TCP客户端(传输、心跳、重连)
│ ├── UdpClient.cs # 通用UDP客户端(适合广播、轻量数据)
│ └── NetMessageHelper.cs # 网络消息的封装/解包(结合Protobuf)
├── Algorithm/ # 算法模块(游戏通用算法)
│ ├── AStar.cs # A*通用寻路算法(基于网格/点集)
│ ├── Dijkstra.cs # 迪杰斯特拉算法(适合最短路径)
│ └── RandomHelper.cs # 随机数工具(权重随机、种子随机)
├── Crypto/ # 加密解密模块(游戏安全)
│ ├── Md5Helper.cs # MD5加密(配表校验、资源校验)
│ ├── AesHelper.cs # AES加密(网络数据、本地配置)
│ └── Base64Helper.cs # Base64编码(轻量数据转码)
├── Data/ # 数据处理模块
│ ├── CsvHelper.cs # CSV解析(配表读取)
│ ├── JsonHelper.cs # JSON序列化(Newtonsoft.Json/System.Text.Json)
│ └── ByteHelper.cs # 字节数组操作(拼接、解析、转换)
└── Common/ # 基础通用工具
│ ├── StringHelper.cs # 字符串工具(格式化、解析、判断)
│ ├── NumberHelper.cs # 数字工具(数值转换、范围限制、插值)
│ └── TimeHelper.cs # 时间工具(时间戳、格式化、时区转换)
└── Exception/ # 封装的内部异常类
└── ProtobufException.cs # Protobuf 解析异常

这里就是简单的 Utility 层涵盖的大概会用到的功能类, 具体可以按照项目需求来扩展

全局依赖管理

在游戏开发之中你会遇到以下单独全局管理的功能:

  • 系统配置管理: 用于处理视频/音频/显示等配置

  • 场景关卡管理: 用于处理关卡场景切换业务

  • 界面 UI 管理: 用于渲染处理当前游戏玩家的界面 UI 功能

  • 网络连接管理: 用于网络数据传输交换

  • 全局配置管理: 用于加载策划配表的数值信息

  • Web 授权管理: 用于连接 Web 服务鉴权处理

  • …其他全局唯一句柄

Godot 内部就有提供持久化功能, 在菜单栏的 项目(Project) → 项目设置(Project Settings) → 全局(Globals) 可以设置启动脚本

但是不推荐直接采用 Godot 的全局脚本挂载, 而是最好编写自己的 CDI(Container Device Interface)

CDI 是计算机当中引入了设备作为资源的抽象概念, 大部分情况都是针对容器将复杂设备的资源进行了标准化抽象

CDI 是容器当中的概念, 这方面概念可以借鉴参考并引入到 C# 处理目前问题

这里可以将这些全局唯一的功能设置成容器当中通用抽象实现, 比如 系统设置管理(SettingManager)

需要定义通用层的 IManger 接口对象, 用于管理这个全局资源的加载顺序和生命周期处理:

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
namespace P21Utility.CDI;

/// <summary>
/// 所有 Manager 的统一接口, 用于规范生命周期
/// </summary>
public interface IManager
{
/// <summary>
/// 加载优先级(按优先级由数值从小到大初始化,0代表优先初始化,-1以下预留给系统优先级别启动)
/// 当全局启动的时候, 该值越小就越优先初始化
/// 当注销退出的时候, 该值越小就越慢调用 Dispose 退出
/// </summary>
int Priority { get; }

/// <summary>
/// 初始化(统一调用方法,仅执行一次)
/// </summary>
void Init();


/// <summary>
/// 销毁(统一调用方法,游戏退出时执行)
/// 用于释放资源、停止线程、注销回调等
/// </summary>
void Dispose();
}

这部分就是具体抽象出来的实现接口, 用于将游戏引擎当中的具体功能抽象出来包装容器, 之后就是全局容器注册中心:

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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
namespace P21Utility.CDI;

/// <summary>
/// 全局管理器的注册中心
/// 负责手动注册 Manager / 统一初始化/ 释放资源
/// </summary>
public class ManagerRegistry
{
/// <summary>
/// 已注册的管理器列表
/// </summary>
private static readonly Dictionary<
Type, (int Priority, Func<IManager> Handler)
> RegisteredManager = new();


/// <summary>
/// 已经实例化完成保存的 Manager
/// </summary>
private static readonly Dictionary<Type, IManager> InstanceManagers = new();


/// <summary>
/// 是否完成全局初始化属性
/// </summary>
public static bool Initialized { get; private set; }


/// <summary>
/// 注册 Manager 管理器
/// </summary>
/// <param name="creator">创建方法</param>
/// <typeparam name="TInterface">管理器接口: 必须实现 IManager</typeparam>
public static void Register<TInterface>(Func<TInterface> creator)
where TInterface : IManager
{
// 提取接口类型
ArgumentNullException.ThrowIfNull(creator);
var ty = typeof(TInterface);

// 验证是否已经注册过
if (RegisteredManager.ContainsKey(ty))
{
throw new InvalidOperationException($"Interface already registered for {ty}");
}

// 创建临时实例用于获取优先级和保存
var impl = creator.Invoke();
ArgumentNullException.ThrowIfNull(impl);
RegisteredManager.Add(ty, (
impl.Priority,
Handler: () => impl
));
}


/// <summary>
/// 精简注册 Manager
/// 利用特性 new 自动获取无参数实例化
/// </summary>
/// <typeparam name="TInterface">管理器接口: 必须实现 IManager</typeparam>
public static void Register<TInterface>()
where TInterface : IManager, new()
{
Register(()=>new TInterface());
}


/// <summary>
/// 统一按照优先级初始化 Manager
/// </summary>
public static void Init()
{
if (Initialized) throw new InvalidOperationException("Manager is already initialized");
if (RegisteredManager.Count == 0) return;

// 按照优先级从小到大排序
var sorted = RegisteredManager
.OrderBy(entry => entry.Value.Priority)
.ToList();


// 按照顺序遍历初始化
foreach (var (ty,tuple) in sorted)
{
try
{
// 唤醒获取实例化对象
var manager = tuple.Handler.Invoke();
ArgumentNullException.ThrowIfNull(manager);
manager.Init(); // 初始化
InstanceManagers.Add(ty, manager);
}
catch (Exception exception)
{
// 这里需要考虑, 初始化失败的策略
// - 单独跳过某个异常 Manager
// - 直接全局异常抛出错误
// 考虑之后全局管理是比较核心的功能, 建议要么全部成功, 要么直接异常错误
throw new InvalidOperationException(ty.FullName, exception);
}
}

// 设置完成初始化
Initialized = true;
}


/// <summary>
/// 全局获取管理器实例化对象
/// </summary>
/// <typeparam name="TInstance">必须继承IManager且已注册/初始化</typeparam>
/// <returns>Manager管理器</returns>
public static TInstance Get<TInstance>() where TInstance : IManager
{
if (!Initialized) throw new InvalidOperationException("ManagerRegistry is not initialized");

// 未注册则抛出异常
var ty = typeof(TInstance);
if (!InstanceManagers.TryGetValue(ty, out var manager))
{
throw new InvalidOperationException($"{ty.FullName} is not registered");
}

// 类型转换返回, 失败则抛出异常
return manager is not TInstance typedManager ?
throw new InvalidOperationException($"{ty.FullName} is not registered") :
typedManager;
}


/// <summary>
/// 统一释放所有 Manager 资源
/// </summary>
public static void Dispose()
{
if (!Initialized) return;

// 按照优先级做逆序释放
var sorted = InstanceManagers.OrderByDescending(entry => entry.Value.Priority)
.ToList();

// 遍历释放管理器资源
foreach (var (tym,manager) in sorted)
{
try
{
manager.Dispose();
}
catch (Exception exception)
{
throw new InvalidOperationException(tym.FullName, exception);
}
}

// 释放完成清理容器列表
InstanceManagers.Clear();
RegisteredManager.Clear();
Initialized = false;
}
}

这里想编写个测试单元处理来确认执行成功, 后续开始解析这部分代码功能和作用, 测试单元在 Tests 层之中编写:

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
using P21Utility.CDI;
using Xunit;

namespace P21Tests.CDI;

/// <summary>
/// CDI 管理器测试单元
/// </summary>
/// <param name="helper">日志打印器</param>
public class ManagerTests(ITestOutputHelper helper)
{

/// <summary>
/// 测试模拟构建 Godot 配置管理器
/// </summary>
/// <param name="helper">测试单元句柄</param>
public class GodotSettingsManager(ITestOutputHelper helper):IManager
{
/// <summary>
/// 系统级别设置 -1
/// </summary>
public int Priority => -1;

/// <summary>
/// 管理器初始化
/// </summary>
public void Init()
{
helper.WriteLine($"{nameof(GodotSettingsManager)} initialized");
}

/// <summary>
/// 管理器释放资源
/// </summary>
public void Dispose()
{
helper.WriteLine($"{nameof(GodotSettingsManager)} cleanup");
}


#region 控制游戏音量

/// <summary>
/// 游戏音量值
/// </summary>
private int _voice;

/// <summary>
/// 游戏音量属性
/// </summary>
public int Voice
{
get => _voice;
set
{
helper.WriteLine($"Game Voice: {value}");
_voice = value;
}
}

#endregion

}


/// <summary>
/// 测试 CDI 全局注册
/// </summary>
[Fact]
public void RegisterTest()
{
// 确认是否注册
Assert.False(ManagerRegistry.Initialized);

// 将 Manager 注册到容器之中
ManagerRegistry.Register(()=>new GodotSettingsManager(helper));

// 对全局注册中心内所有的 Manager 做初始化
ManagerRegistry.Init();

// 完成之后就能全局加载对应运行时的 Manager
var manager = ManagerRegistry.Get<GodotSettingsManager>();

// 这里假设现在就能处理游戏音量
manager.Voice += 2;


// 最后释放所有资源
// 注意: 释放资源需要手动处理
ManagerRegistry.Dispose();
}

}

这就是将游戏引擎内部功能抽象成具体的全局容器管理层来操作的流程, 那么问题就来了: 为什么要搞得这么复杂?

明明 Godot 挂载全局脚本和 public static SettingsManager Instance = new() 这种静态全局单例就可以简单解决

这里就分析为什么会采用这种方法管理全局对象, 在这个分析过程可以边思考这样的处理方式是否正确, 整合和理解这套架构的设计思路

依赖关系和初始化顺序

游戏的全局管理器之间是有强依赖的关系, 比如下面的依赖关系:

  • NetworkManager(网络) 依赖 ConfigManager(全局配表) 的服务端 IP 和端口配置

  • UIManager(UI) 依赖 SettingsManager(系统配置) 的默认桌面模式和分辨率和音量配置

这种都是涉及到强关联依赖, Godot 可以设置加载顺序, 但是 C# 处理成全局单例却没办法决定启动顺序

统一的生命周期

游戏的全局管理器大多持有稀缺资源:

  • 网络管理器持有 Socket 连接

  • 配置管理器持有文件句柄

  • Web 鉴权持有 HTTP 客户端

这些都是都是要自己维护销毁逻辑, 而 Godot 的全局脚本即使写 _ExitTree 回调也无法保证销毁顺序

主要就是要将按照全局加载顺序的管理器进行逆向销毁, 比如 Config 配置管理器第一个加载, 那么销毁就必须是最后一个销毁

这种逆向销毁方式可以避免某个管理器销毁的时候, 由于依赖的另外管理器提前销毁导致找不到资源, 从而出现异常报错

让管理器逆序释放资源是必须要处理的, 否则无序释放很容易出互相之间的依赖丢失

管理器功能解耦

全局管理器容器抽象化其实不仅仅可以用于 Godot 游戏引擎上, 是具有跨游戏引擎运行的能力

将这些功能抽象出来方便后续其他 C# 平台直接就可以复用这套底层 Utility 扩展, 让团队项目之中可以直接上手开发

如果全局管理器用 Godot 的全局脚本实现的话, 会直接耦合 Godot 的 Node/GD/_Ready 等专属引擎的 API

这样好处就是只依赖 C# 方便后续移植到其他支持 C# 语言的游戏引擎平台上, 这样切换过去只需要对接游戏相关 API 即可

扩展 Mod 支持

对于独立单机游戏有时候需要暴露给外层 Mod 功能做脚本功能开发, 用来提供给玩家扩展游戏主体的游戏功能

脚本引擎这部分也是采用在 Utility 层引入 Lua/Js 脚本层, 就像下面一样构建通用脚本接口等具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
namespace P21Utility.CDI.Extension.Script;

/// <summary>
/// 脚本引擎接口
/// </summary>
public interface IScriptManager:IManager
{
/// <summary>
/// 脚本引擎不需要比系统级别更高, 所以直接设置0
/// </summary>
int IManager.Priority => 0;

/// <summary>
/// 运行脚本
/// </summary>
public void Execute();

// 其他略
// 后面可以自己去手动声明实现以下类似的脚本系统接入
// ManagerRegistry.Register<IScriptManager>(() => new LuaManager());
// ManagerRegistry.Register<IScriptManager>(() => new JsManager());
}

可以把对应系统功能暴露给脚本处理来实现 Mod 功能修改底层游戏逻辑, 也方便支持后续类似 Steam 创意公坊的扩展内容

注意: 脚本系统最多辅助运行那些动态化修改或者和服务端共享的业务逻辑, 不要将其作为核心让整体业务都在脚本上编写

所以这种抽象处理不是为了游戏项目复杂而多余的设计, 而是 为了更进一步让游戏项目标准化和工程化

Godot 接入

在单元测试编译通过之后就可以开始接入到游戏层, 这里在 Main 脚本之中挂载初始化全局管理器:

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
using System;
using Godot;
using P21Game.Utils.Logging;
using P21Utility.CDI;
using Serilog;

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

/// <summary>
/// 挂载游戏入口节点的 C# 脚本
/// </summary>
public partial class Main : Node
{
/// <summary>
/// 加入节点回调
/// _EnterTree 在 _Ready 之前执行, 所以日志句柄初始化最好在其中注册完成
/// </summary>
public override void _EnterTree()
{
// 避免Serilog日志器重复初始化
if (Log.Logger is not Serilog.Core.Logger)
{
// 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");

// 初始化日志配置
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);
}

// 执行 Manager 注册流程
try
{
RegisterManagers(); // 未初始化的时候注册相关 Manager
ManagerRegistry.Init(); // 开始初始化
Log.Information("Registered Managers Successes");
}
catch (Exception exception)
{
// 写入日志并且抛出异常退出
// 全局管理器涉及很多关联, 要么直接一次成功, 要么直接异常退出
Log.Error(exception, "An error occured during initialization");
OS.Alert(exception.Message, "An error occured during initialization");
GetTree().Quit(1);
}
}


/// <summary>
/// 注册相关的 Manager
/// </summary>
private static void RegisterManagers()
{
if (ManagerRegistry.Initialized) return;


// todo: 手动注册
}


/// <summary>
/// 退出节点回调
/// </summary>
public override void _ExitTree()
{
// 退出异常直接写入日志不需要上报
try
{
ManagerRegistry.Clear();
}
catch (Exception exception)
{
Log.Error(exception, "An error occured during exiting");
}

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

这里就初始化完成 Manager 全局容器, 之后就是在 RegisterManagers 方法之中注册游戏所需全局 Manager

其实还有另外注册全局方法, 也就是不利用 Main 脚本挂载全局, 而是依靠 Godot 的全局启动脚本挂载 AutoLoad.cs 脚本

把上面 Main 脚本的方法放到 AutoLoad.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
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
using System;
using Godot;
using P21Game.Utils.Logging;
using P21Utility.CDI;
using Serilog;

namespace P21Game;

/// <summary>
/// 全局自动加载功能
/// </summary>
public partial class AutoLoad:Node
{
/// <summary>
/// 加入节点回调
/// _EnterTree 在 _Ready 之前执行, 所以日志句柄初始化最好在其中注册完成
/// </summary>
public override void _EnterTree()
{
// 避免Serilog日志器重复初始化
if (Log.Logger is not Serilog.Core.Logger)
{
// 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");

// 初始化日志配置
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);
}

// 执行 Manager 注册流程
try
{
RegisterManagers(); // 未初始化的时候注册相关 Manager
ManagerRegistry.Init(); // 开始初始化
Log.Information("Registered Managers Successes");
}
catch (Exception exception)
{
// 写入日志并且抛出异常退出
// 全局管理器涉及很多关联, 要么直接一次成功, 要么直接异常退出
Log.Error(exception, "An error occured during initialization");
OS.Alert(exception.Message, "An error occured during initialization");
GetTree().Quit(1);
}
}


/// <summary>
/// 注册相关的 Manager
/// </summary>
private static void RegisterManagers()
{
if (ManagerRegistry.Initialized) return;


// todo: 手动注册
}


/// <summary>
/// 退出节点回调
/// </summary>
public override void _ExitTree()
{
// 退出异常直接写入日志不需要上报
try
{
ManagerRegistry.Clear();
}
catch (Exception exception)
{
Log.Error(exception, "An error occured during exiting");
}

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

之后在 Godot 的 项目(Project) → 项目设置(Project Settings) → 全局(Globals) 之中挂载单个脚本就行:

godot-global

这样就不依赖 Main 节点的脚本来自动做全局注册, Godot 会在启动的时候自动加载挂载于 Root 作为顶级脚本来运行

这两种方式用 AutoLoad 加载好点, 可以从命名当中直观理解其脚本主要作用

Godot 配置管理器

这里已经完成全局 Manager 容器管理, 现在就是要将 Godot 系统配置抽象成 Manager 提供管理从而实现以下功能:

  • 显示模式(DisplayMode), 支持 全屏|桌面化|无边框窗口 选择切换

  • 窗口比例(DisplayResolution), 1280x720(720P,16:9)|1600x900(900P,16:9)|1920x1080(1080P,16:9)|2560x1440(2K, 16:9)

这部分功能建议提取出 ISettingsManager 抽象接口放置在 Utility 层, 因为这部分功能比较通用, 所以可以单独提取维护

这里抽象出来功能配置: DisplayModeDisplayResolution

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
// 通用配置文件: {Utility}/CDI/Extension/Settings/DisplayMode.cs
namespace P21Utility.CDI.Extension.Settings;

/// <summary>
/// 显示模式枚举
/// </summary>
public enum DisplayMode
{
/// <summary>
/// 全屏模式
/// </summary>
Fullscreen = 0,

/// <summary>
/// 有边框窗口化
/// </summary>
Windowed = 1,

/// <summary>
/// 无边框窗口化
/// </summary>
Borderless = 2
}


// 通用配置文件: {Utility}/CDI/Extension/Settings/DisplayResolution.cs
namespace P21Utility.CDI.Extension.Settings;

/// <summary>
/// 预设分辨率枚举(固定常用比例, 避免自定义分辨率的适配问题)
/// </summary>
public enum DisplayResolution
{

/// <summary>
/// 1280x720(720P,16:9)
/// </summary>
P720 = 0,

/// <summary>
/// 1600x900(900P,16:9)
/// </summary>
P900 = 1,

/// <summary>
/// 1920x1080(1080P,16:9)
/// </summary>
P1080 = 2,

/// <summary>
/// 2560x1440(2K|1440P, 16:9)
/// </summary>
P1440 = 3,
}

这里就是简单调整出来通用配置枚举, 其他就是核心的 ISettingsManager 扩展:

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
namespace P21Utility.CDI.Extension.Settings;

/// <summary>
/// 通用游戏扩展配置管理器
/// </summary>
public interface ISettingsManager : IManager
{
/// <summary>
/// 初始化优先级(-1,系统级管理器, 最先初始化)
/// </summary>
int IManager.Priority => -1;


#region 显示设置

/// <summary>
/// 切换和获取目前的显示模式
/// </summary>
DisplayMode DisplayMode { get; set; }


/// <summary>
/// 切换和获取显示的比例
/// </summary>
DisplayResolution DisplayResolution { get; set; }

#endregion

#region 持久化处理

/// <summary>
/// 保存到本地持久化
/// </summary>
void SaveSettings();

/// <summary>
/// 加载本地配置内容
/// </summary>
void LoadSettings();

#endregion
}

这就是抽象出来的通用显示管理器配置接口, 最后就是在 Game 层级实现具体的功能:

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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
using System;
using System.Collections.Generic;
using Godot;
using P21Utility.CDI.Extension.Settings;
using Serilog;

namespace P21Game.Manager;

/// <summary>
/// Godot 衍生的系统配置管理器
/// </summary>
public class GodotSettingsManager : ISettingsManager
{
#region 显示设置

/// <summary>
/// 分辨率枚举与实际宽高的映射表
/// </summary>
private readonly Dictionary<DisplayResolution, (int Width, int Height)> _resolution = new()
{
{ DisplayResolution.P720, (1280, 720) },
{ DisplayResolution.P900, (1600, 900) },
{ DisplayResolution.P1080, (1920, 1080) },
{ DisplayResolution.P1440, (2560, 1440) }
};

/// <summary>
/// 加载对应比例
/// </summary>
/// <param name="resolution">显示比例</param>
/// <returns>宽高尺寸</returns>
private (int Width, int Height) GetResolutionSize(DisplayResolution resolution)
{
// 不存在则返回默认 720P
return _resolution.TryGetValue(resolution, out var size)
? size
: _resolution[DisplayResolution.P720];
}

/// <summary>
/// 检查显示器是否支持避免黑屏
/// </summary>
/// <param name="width">宽度</param>
/// <param name="height">高度</param>
private void CheckResolutionSize(int width, int height)
{
// 获取当前屏幕可用尺寸
var screenSize = DisplayServer.ScreenGetSize();
if (width > screenSize.X || height > screenSize.Y)
{
Log.Warning("Resolution {Width}x{Height} exceeds screen size {ScreenSize}", width, height, screenSize);
return;
}

DisplayServer.WindowSetSize(new Vector2I(width, height));
DisplayServer.WindowSetPosition((screenSize - new Vector2I(width, height)) / 2); // 居中窗口
}


/// <summary>
/// 显示模式, 默认为全屏
/// </summary>
private DisplayMode _displayMode;


/// <summary>
/// 显示模式修改
/// </summary>
public DisplayMode DisplayMode
{
get => _displayMode;
set
{
// 合法性校验: 确保是定义的枚举值
if (!Enum.IsDefined(typeof(DisplayMode), value))
{
Log.Warning("Invalid display mode, use fullscreen mode");
_displayMode = DisplayMode.Fullscreen;
}

// 避免重复修改
if (_displayMode == value) return;

// 修改 Godot 默认实际显示模式, 映射 Godot 配置值
_displayMode = value;
switch(value)
{
case DisplayMode.Borderless:
// 先设为窗口化,再去除边框
DisplayServer.WindowSetMode(DisplayServer.WindowMode.Windowed);
DisplayServer.WindowSetFlag(DisplayServer.WindowFlags.Borderless, true);
break;
case DisplayMode.Windowed:
// 设置窗口化, 追加边框
DisplayServer.WindowSetMode(DisplayServer.WindowMode.Windowed);
DisplayServer.WindowSetFlag(DisplayServer.WindowFlags.Borderless, false);
break;
case DisplayMode.Fullscreen:
default:
DisplayServer.WindowSetMode(DisplayServer.WindowMode.Fullscreen);
break;
}

// 切换全屏|无边框后, 强制重置分辨率, 避免画面拉伸
if (value is DisplayMode.Borderless or DisplayMode.Fullscreen)
{
var (width, height) = GetResolutionSize(_displayResolution);
CheckResolutionSize(width, height);
}
SaveSettings(); // 修改的时候保存本地
}
}


/// <summary>
/// 显示模式, 默认为 720P 比例
/// </summary>
private DisplayResolution _displayResolution = DisplayResolution.P720;

/// <summary>
/// 修改显示模式
/// </summary>
public DisplayResolution DisplayResolution
{
get => _displayResolution;
set
{
if (!Enum.IsDefined(typeof(DisplayResolution), value))
{
Log.Warning("Invalid display resolution, use 720p");
_displayResolution = DisplayResolution.P720;
}

// 避免重复修改
if (_displayResolution == value) return;


// 获取分辨率宽高, 修改Godot窗口大小, 对接 Godot 的 DisplayServer 全局配置
var (width, height) = GetResolutionSize(value);
CheckResolutionSize(width, height);

// 更新字段并同步更新本地
_displayResolution = value;
SaveSettings();
}
}

#endregion


/// <summary>
/// 初始化配置
/// </summary>
public void Init()
{
Log.Information("Initializing godot settings");
LoadSettings(); // 加载本地系统配置
}


/// <summary>
/// 管理器退出
/// </summary>
public void Dispose()
{
Log.Information("Disposing godot settings");
SaveSettings(); // 保存配置
}


/// <summary>
/// 保存系统设置
/// </summary>
public void SaveSettings()
{
Log.Information("Saving godot settings");
}

/// <summary>
/// 加载系统设置
/// </summary>
public void LoadSettings()
{
Log.Information("Loading godot settings");
}
}

这里的 SaveSettings 和 LoadSettings 就是保存到本地配置(目前暂时还为实现), 现在只有修改游戏画面模式和尺寸功能

先注册到全局管理器之后再写 UI 相关功能, 这里再 AutoLoad.cs 之中注册管理器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/// <summary>
/// 全局自动加载功能
/// </summary>
public partial class AutoLoad : Node
{

// 其他略

/// <summary>
/// 注册相关的 Manager
/// </summary>
private static void RegisterManagers()
{
if (ManagerRegistry.Initialized) return;

// 注册配置管理器
ManagerRegistry.Register<ISettingsManager>(() => new GodotSettingsManager());

// 后续扩展全局管理器再这里追加
}
}

先别急着写功能, 点击 Rider 编译处理看看是否成功编译, 没问题才继续下一步处理

界面测试

这里先随便构建几个按钮功能, 用于触发之后查看是否修改生效, 这里界面先随便生成按钮处理下:

godot-display

之后再 Main 脚本追加绑定按键功能:

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
using Godot;
using P21Utility.CDI;
using P21Utility.CDI.Extension.Settings;

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

/// <summary>
/// 挂载游戏入口节点的 C# 脚本
/// </summary>
public partial class Main : Node
{
[Export] public Button FullscreenButton;

[Export] public Button BorderlessButton;

[Export] public Button WindowedButton;

[Export] public Button P720Button;

[Export] public Button P900Button;

[Export] public Button P1080Button;

[Export] public Button P1440Button;


/// <summary>
/// 设置管理器
/// </summary>
private ISettingsManager _settings;


/// <summary>
/// 初始化完成
/// </summary>
public override void _Ready()
{
_settings = ManagerRegistry.Get<ISettingsManager>();

// 绑定全屏按钮事件
if (FullscreenButton != null)
{
FullscreenButton.Pressed += () => _settings.DisplayMode = DisplayMode.Fullscreen;
}

// 绑定无边框按钮事件
if (BorderlessButton != null)
{
BorderlessButton.Pressed += () => _settings.DisplayMode = DisplayMode.Borderless;
}

// 绑定窗口化按钮事件
if (WindowedButton != null)
{
WindowedButton.Pressed += () => _settings.DisplayMode = DisplayMode.Windowed;
}

// 切换 720p
if (P720Button != null)
{
// 如果是全屏就不需要修改分辨率
P720Button.Disabled = _settings.DisplayMode == DisplayMode.Fullscreen;
P720Button.Pressed += () => { _settings.DisplayResolution = DisplayResolution.P720; };
}

// 切换 900p
if (P900Button != null)
{
P900Button.Pressed += () => { _settings.DisplayResolution = DisplayResolution.P900; };
}

// 切换 1080p
if (P1080Button != null)
{
P1080Button.Pressed += () => { _settings.DisplayResolution = DisplayResolution.P1080; };
}

// 切换 1440p
if (P1440Button != null)
{
P1440Button.Pressed += () => _settings.DisplayResolution = DisplayResolution.P1440;
}
}


/// <summary>
/// 游戏帧更新
/// </summary>
/// <param name="delta"></param>
public override void _Process(double delta)
{
// 只有窗口化或者无边框窗口化才允许调整
var isWindowed = _settings.DisplayMode is DisplayMode.Windowed or DisplayMode.Borderless;
if (P720Button != null) P720Button.Disabled = !isWindowed;
if (P900Button != null) P900Button.Disabled = !isWindowed;
if (P1080Button != null) P1080Button.Disabled = !isWindowed;
if (P1440Button != null) P1440Button.Disabled = !isWindowed;
}
}

最后绑定完对应节点查看下效果即可, 具体效果如下:

godot-change

注意: 这里启动首次点击 Fullscreen 默认是不生效的, 可以思考下为什么不生效

这里可以看到虽然强制设置指定比例, 但还是可以通过鼠标实现拉伸改变界面比例, 这里 Godot 修改不允许拉伸即可:

godot-noresize