Godot游戏客户端C#GodotC# 的 Utility 扩展
MeteorCat
之前定义过 P21 项目作为示例, 这里是从 P21 项目当中扩展出来的项目工程化补充说明
工程化项目当时创建过以下目录作为项目分层:
-
P21Game: 游戏引擎相关
-
P21Tests: 游戏内部测试单元
-
P21Utility: 内部扩展通用功能
这个篇章就是基于 P21Utility 这方面展开的, 简单说下为什么有 Utility 层
有时候项目需要用到底层工具库, 比如 Protobuf 序列化、网络、寻路算法 等仅需要纯 C# 的通用功能
这种通用功能只需要 C# 来处理成单独功能库, 那么就不适合放在对外的 P21Game 游戏引擎相关工程之中
并且基于 C# 保证脱离游戏引擎存在, 如果后面迁移到 Unity3d 之类使用 C# 平台还能复用相关代码库
把封装代码塞到 Game 层之中会让游戏工程变得臃肿, 核心游戏逻辑(UI、玩法、节点交互)被这些代码淹没导致后期找 Bug、改功能都会极其繁琐
所以 P21Utility 只做纯 C# 通用功能封装, 对外提供清晰、简洁的 API 从而让 P21Game 只需要 调用 而不用关心底层实现
对于 Utility 层的使用规则:
-
绝对不引用 Godot 相关程序集, 禁止在 P21Utility 中引用 Godot.dll, 不依赖任何外部游戏引擎
-
只做通用组件而不涉及游戏业务, 比如可以封装通用TCP客户端(支持 Protobuf 编解码), 但不能封装游戏玩家的登录请求发送逻辑
-
对外提供高内聚、低耦合的 API, 封装时隐藏底层实现细节, 比如避免将底层 Socket 原生句柄暴露(可以商榷, 有时候要底层处理)
-
自带完整的单元测试, 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 层涵盖的大概会用到的功能类, 具体可以按照项目需求来扩展
全局依赖管理
在游戏开发之中你会遇到以下单独全局管理的功能:
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;
public interface IManager { int Priority { get; }
void Init(); 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;
public class ManagerRegistry { private static readonly Dictionary< Type, (int Priority, Func<IManager> Handler) > RegisteredManager = new();
private static readonly Dictionary<Type, IManager> InstanceManagers = new();
public static bool Initialized { get; private set; }
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 )); }
public static void Register<TInterface>() where TInterface : IManager, new() { Register(()=>new TInterface()); }
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) { throw new InvalidOperationException(ty.FullName, exception); } }
Initialized = true; }
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; }
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;
public class ManagerTests(ITestOutputHelper helper) { public class GodotSettingsManager(ITestOutputHelper helper):IManager { public int Priority => -1;
public void Init() { helper.WriteLine($"{nameof(GodotSettingsManager)} initialized"); }
public void Dispose() { helper.WriteLine($"{nameof(GodotSettingsManager)} cleanup"); }
#region 控制游戏音量
private int _voice;
public int Voice { get => _voice; set { helper.WriteLine($"Game Voice: {value}"); _voice = value; } }
#endregion
}
[Fact] public void RegisterTest() { Assert.False(ManagerRegistry.Initialized); ManagerRegistry.Register(()=>new GodotSettingsManager(helper)); ManagerRegistry.Init(); var manager = ManagerRegistry.Get<GodotSettingsManager>(); manager.Voice += 2; ManagerRegistry.Dispose(); } }
|
这就是将游戏引擎内部功能抽象成具体的全局容器管理层来操作的流程, 那么问题就来了: 为什么要搞得这么复杂?
明明 Godot 挂载全局脚本和 public static SettingsManager Instance = new() 这种静态全局单例就可以简单解决
这里就分析为什么会采用这种方法管理全局对象, 在这个分析过程可以边思考这样的处理方式是否正确, 整合和理解这套架构的设计思路
依赖关系和初始化顺序
游戏的全局管理器之间是有强依赖的关系, 比如下面的依赖关系:
这种都是涉及到强关联依赖, 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;
public interface IScriptManager:IManager { int IManager.Priority => 0;
public void Execute(); }
|
可以把对应系统功能暴露给脚本处理来实现 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;
namespace P21Game;
public partial class Main : Node { public override void _EnterTree() { if (Log.Logger is not Serilog.Core.Logger) { var logFilename = ProjectSettings.GlobalizePath("user://game.log");
Log.Logger = new LoggerConfiguration() .WriteTo.Godot() .WriteTo.File( logFilename, rollingInterval: RollingInterval.Day, rollOnFileSizeLimit: true, fileSizeLimitBytes: 1024 * 1024 * 5, retainedFileCountLimit: 7 ) .CreateLogger();
Log.Information("Logger Initialized, Filename: {LogFilename}", logFilename); } try { RegisterManagers(); 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); } }
private static void RegisterManagers() { if (ManagerRegistry.Initialized) return;
}
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;
public partial class AutoLoad:Node { public override void _EnterTree() { if (Log.Logger is not Serilog.Core.Logger) { var logFilename = ProjectSettings.GlobalizePath("user://game.log");
Log.Logger = new LoggerConfiguration() .WriteTo.Godot() .WriteTo.File( logFilename, rollingInterval: RollingInterval.Day, rollOnFileSizeLimit: true, fileSizeLimitBytes: 1024 * 1024 * 5, retainedFileCountLimit: 7 ) .CreateLogger();
Log.Information("Logger Initialized, Filename: {LogFilename}", logFilename); } try { RegisterManagers(); 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); } }
private static void RegisterManagers() { if (ManagerRegistry.Initialized) return;
}
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) 之中挂载单个脚本就行:

这样就不依赖 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 层, 因为这部分功能比较通用, 所以可以单独提取维护
这里抽象出来功能配置: DisplayMode 和 DisplayResolution
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
| namespace P21Utility.CDI.Extension.Settings;
public enum DisplayMode { Fullscreen = 0,
Windowed = 1,
Borderless = 2 }
namespace P21Utility.CDI.Extension.Settings;
public enum DisplayResolution { P720 = 0, P900 = 1, P1080 = 2, 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;
public interface ISettingsManager : IManager { int IManager.Priority => -1;
#region 显示设置
DisplayMode DisplayMode { get; set; }
DisplayResolution DisplayResolution { get; set; }
#endregion #region 持久化处理
void SaveSettings();
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;
public class GodotSettingsManager : ISettingsManager { #region 显示设置
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) } };
private (int Width, int Height) GetResolutionSize(DisplayResolution resolution) { return _resolution.TryGetValue(resolution, out var size) ? size : _resolution[DisplayResolution.P720]; } 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); }
private DisplayMode _displayMode;
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;
_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(); } }
private DisplayResolution _displayResolution = DisplayResolution.P720;
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;
var (width, height) = GetResolutionSize(value); CheckResolutionSize(width, height); _displayResolution = value; SaveSettings(); } }
#endregion
public void Init() { Log.Information("Initializing godot settings"); LoadSettings(); }
public void Dispose() { Log.Information("Disposing godot settings"); SaveSettings(); }
public void SaveSettings() { Log.Information("Saving godot settings"); }
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
|
public partial class AutoLoad : Node {
private static void RegisterManagers() { if (ManagerRegistry.Initialized) return;
ManagerRegistry.Register<ISettingsManager>(() => new GodotSettingsManager()); } }
|
先别急着写功能, 点击 Rider 编译处理看看是否成功编译, 没问题才继续下一步处理
界面测试
这里先随便构建几个按钮功能, 用于触发之后查看是否修改生效, 这里界面先随便生成按钮处理下:

之后再 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;
namespace P21Game;
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;
private ISettingsManager _settings;
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; }
if (P720Button != null) { P720Button.Disabled = _settings.DisplayMode == DisplayMode.Fullscreen; P720Button.Pressed += () => { _settings.DisplayResolution = DisplayResolution.P720; }; }
if (P900Button != null) { P900Button.Pressed += () => { _settings.DisplayResolution = DisplayResolution.P900; }; }
if (P1080Button != null) { P1080Button.Pressed += () => { _settings.DisplayResolution = DisplayResolution.P1080; }; }
if (P1440Button != null) { P1440Button.Pressed += () => _settings.DisplayResolution = DisplayResolution.P1440; } }
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; } }
|
最后绑定完对应节点查看下效果即可, 具体效果如下:

注意: 这里启动首次点击 Fullscreen 默认是不生效的, 可以思考下为什么不生效
这里可以看到虽然强制设置指定比例, 但还是可以通过鼠标实现拉伸改变界面比例, 这里 Godot 修改不允许拉伸即可:
