GodotC# 的显示设置

游戏当中不可获取的就是调整系统设置, 而其中最最容易被人忽略的显示设置, 这里列举下常见的显示设置

如果是简单的显示设置, 就是如下这种方式设置:

basic-conf

这种就是标准的将所有配置简略合并成单个配置修改, 适用于简单游戏类型(无3D交互和不做手柄操作)

而相对比较复杂的显示设置, 如下就示:

adv-conf

这种就是相对比较复杂的, 但是一般都是会把游戏显卡游戏效果单独移动到游戏设置(Game Settings)

游戏设置: 双显卡切换和特效切换(高/中/低), 还有伽马值/阴影/环境光/抗锯齿/帧率上限等高级选项

这里回过头来说下应该实现的 显示设置(Display Settings), 日常最底层用到的系统配置并且都是一次实现后续复用的功能

以最简单的设置来说, 哪怕最简单的游戏都需要实现以下显示设置功能

  • 桌面模式(Display Mode): 设置游戏渲染窗口, 可以按照以下选项来切换(游戏桌面一律不运行鼠标点击拖动调整分辨率)

    • 全屏(Fullscreen): 将界面直接填充独占当前主显示器, 全屏的分辨率直接读取当前显示器最大分辨率值
    • 窗口化(Windowed): 将窗口默认调整 1024×768~3840×2160 分辨率多选配置, 下面有对应标准分辨率列表
    • 无边框窗口(Borderless): 将窗口的状态栏去除的界面模式, 也就是有没有游戏界面上面最小化/关闭窗口那一栏, 其他和窗口化一致
  • 显示分辨率(Display Resolution): 游戏窗口的强制分辨率, 一般都有标准化的对应分辨率配表, 下面有对应标准分辨率列表

  • 垂直同步(VSync): 让游戏的渲染帧率与显示器的刷新率保持同步避免出现画面撕裂, 但是坏处就是输入带有延迟, 这配置由用户自己决定开关

  • 运行帧率(FPS): 用于设置游戏运行的最大帧, 如果不是大型游戏的话保持默认默认帧率选择即可, 甚至没必要暴露该配置给玩家设置

    • 30: 省电模式下的运行帧率
    • 60: 常规模式下的运行帧率
    • 120: 高刷新模式下运行帧率
    • 144: 电竞级别的运行帧率
    • 240: 高端旗舰显示器带支持运行帧率
    • 无限制: 这个由 Godot 决定内部支持最大帧率设置, 尽可能能跑多少帧率就到多少帧率
    • Godot 运行帧率依靠 Godot 内置的 Engine.SetMaxFps(fps) 方法修改, 设置 0 就是解放无限制运行帧率
    • 请注意: VSync开启(Enabled/Adaptive)时, MaxFps会失效并将帧率上限强制为显示器刷新率
    • 开启 VSync + G-Sync/FreeSync 可以将帧率上限设为刷新率 -2~-3(如 144Hz 屏设 141 帧) 来避免输入延迟和画面撕裂

垂直同步和调整运行帧率不是必要的, 一般只有涉及到动作类会影响(视角切换导致延迟), 其他游戏默认开启不用额外配置

后续说明还是按照之前初始化说明的 P21Game 项目来做展开, 结合 Manager 全局管理器来做全局显示控制

分辨率相关知识点

实际上切换显示在游戏引擎当中可以归类成以下行为

  • 全屏: 填充目前显示器窗口, 然后获取当前显示器最大分辨率来设置(注意:多屏的时候需要查找主显示器窗口)

  • 窗口化: 默认切换到最低 1080P(1920×1080) 尺寸的小窗口模式, 然后支持手动调整不同档位的分辨率

  • 无边框窗口化: 类似于窗口化实现, 但是将窗口时候的状态栏去除(也就是最小化/最大化/关闭那个栏关闭)

全屏模式下不允许调整窗口分辨率, 而是计算得出支持的最高分辨率来切换

显示分辨率的选项网上很少有人提及, 这部分也是后面自己摸索起来的, 也算是给需要的人做参考, 可以直接通用以下表格

标准命名 分辨率数值 行业代号和屏幕比例 画质等级 适配场景(游戏开发)
XGA 1024×768 无专属P数(4:3) 标清 极低配入门/复古游戏
HD 1280×720 720P(16:9) 高清 低配PC/核显玩家核心适配
HD+ 1366×768 768P(4:3) 标清 笔记本低分辨率适配
HD+ 1600×900 900P(16:9) 高清 中低配PC/主流笔记本适配
FHD 1920×1080 1080P(16:9) 全高清 桌面端核心适配(主推)
QHD 2560×1440 2K/1440P(16:9) 2K高清 中高配PC/电竞显示器适配
WQHD 2560×1600 2.5K(16:10) 2K高清 高端笔记本/27寸显示器适配
UHD 3840×2160 4K/超高清(16:9) 4K超高清 高配PC/32寸以上大屏适配

这就是日常游戏用到分辨率, 从 13~14寸笔记本(1024×768) 到超高清设备 4K 分辨率, 一般游戏通用这个分辨率模板即可

这部分的建议定义在 Utility(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
namespace P21Utility.Definitions.Settings.Display;

/// <summary>
/// 通用显示分辨率
/// </summary>
public enum Resolution
{
/// <summary>
/// 1024×768 是比较特殊的 XGA 规则, 按照 4:3 比例标清
/// 早期 13~14寸低配缩配的笔记本电脑都是采用这类尺寸, 是较为古老的比例并且无法归类到 xxxP 之类
/// </summary>
PXga = 1,

/// <summary>
/// 1280×720 是比较通用的高清标准规则, 采用 16:9 比例, 归类到 720P
/// </summary>
P720 = 2,

/// <summary>
/// 1366×768 后续 XGA 移动设备提升的视频尺寸, 依旧还是基于 4:3 比例标清
/// </summary>
P768 = 3,

/// <summary>
/// 1600×900 移动设备从 4:3 比例过渡到 16:9 高清的尺寸
/// 这里是后续移动设备同步到桌面设备分辨率统一的分界点
/// </summary>
P900 = 4,

/// <summary>
/// 1920×1080 移动设备全面过渡到桌面设备的全高清分辨率(16:9)
/// 目前市面上大部分都是支持这部分分辨率, 一般优选该比例作为默认分辨率开发
/// </summary>
P1080 = 5,

/// <summary>
/// 2560×1440 这部分开始就是 2K 分辨率, 目前官方比较认可的2K指代这部分尺寸
/// 这个 2K 尺寸需要和下面另外的 2K 尺寸区分, 这个尺寸采用的是 16:9
/// </summary>
P1440 = 6,

/// <summary>
/// 2560×1600 这是很特殊的 2K 分辨率, 采用 16:10 这种特殊比例
/// 这部分是设计师/办公用的小众宽屏比例, 考虑到可能有这方面游戏需求所以建议支持
/// </summary>
P1600 = 7,

/// <summary>
/// 3840×2160 目前高端设备的 4K 分辨率, 采用 16:9 比例
/// 4K 高清游戏现在也是游戏玩家主要追求
/// </summary>
P2160 = 8,

}

扩展 DisplayResolution 枚举, 利用 C# 的扩展特性给这些枚举追加 GetSize(分辨率宽高尺寸) 和 GetRatio(分辨率宽高比例) 方法:

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
namespace P21Utility.Definitions.Settings.Display;

/// <summary>
/// 显示分辨率扩展功能
/// 采用 C# 的扩展特性来扩展 Resolution 功能
/// </summary>
public static class ResolutionExtensions
{
/// <summary>
/// 获取分辨率对应的宽高尺寸
/// </summary>
/// <param name="resolution">显示分辨率类型</param>
/// <returns>(宽,高)之类尺寸</returns>
public static (int, int) GetSize(this Resolution resolution)
{
return resolution switch
{
Resolution.PXga => (1024, 768),
Resolution.P720 => (1280, 720),
Resolution.P768 => (1366, 768),
Resolution.P900 => (1600, 900),
Resolution.P1080 => (1920, 1080),
Resolution.P1440 => (2560, 1440),
Resolution.P1600 => (2560, 1600),
Resolution.P2160 => (3840, 2160),
// 默认建议默认切换会1080P分辨率, 市面大部分显示设备都支持的分辨率
_ => (1920, 1080)
};
}


/// <summary>
/// 获取分辨率对应的宽高比例
/// </summary>
/// <param name="resolution">显示分辨率类型</param>
/// <returns>(16,9)比例的元组对象</returns>
public static (int, int) GetRatio(this Resolution resolution)
{
var (w, h) = resolution.GetSize();
var gcd = GetRatioDivisor(w, h);
return (w / gcd, h / gcd);
}


/// <summary>
/// 计算两个数的最大公约数, 用于得出约分比例
/// </summary>
/// <param name="a">宽度</param>
/// <param name="b">高度</param>
/// <returns>得出的公约数</returns>
private static int GetRatioDivisor(int a, int b)
{
while (b != 0)
{
var temp = b;
b = a % b;
a = temp;
}

return a;
}



/// <summary>
/// 检查当前的显示器支持的分辨率列表
/// </summary>
/// <param name="width">最大宽度</param>
/// <param name="height">最大高度</param>
/// <returns>支持的比例列表</returns>
public static Resolution[] GetResolutions(int width, int height)
{
return Enum
.GetValues<Resolution>()
.Where(resolution =>
{
var (w, h) = resolution.GetSize();
return w <= width && h <= height;
}).ToArray();
}
}

而另外方面还有对应显示模式枚举, 这个枚举没什么好讲直接按照下面即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
namespace P21Utility.Definitions.Settings.Display;

/// <summary>
/// 显示模式
/// </summary>
public enum Mode
{
/// <summary>
/// 全屏显示
/// </summary>
Fullscreen = 1,

/// <summary>
/// 窗口化
/// </summary>
Windowed = 2,


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

这就是通用的分辨率枚举扩展功能, 这个工具枚举无论是 Godot/Unity3d/UE 都可以使用, 涵盖目前主流的显示器分辨率

Godot 分辨率设置

说完通用部分就要结合 P21 项目来继续开发游戏工程, 之前简单声明 ISettingsManager 封装并实现动态修改显示设置

后面仔细考虑感觉写得太过粗糙, 我的想法是尽可能将知识点和通用规范融入项目之内, 尽可能不会让人照抄代码失去自己理解

最终达到的效果是让人理解游戏开发之中 "为什么要这么做""应该怎么去做" 的问题点和解决方案

包括上面的分辨率比例扩展出来的市面通用显示器分辨率知识, 就是为了让人知道为什么选择这些尺寸并选择不同分辨率尺寸

这里 Godot 关于显示设置常用的 API 如下所示, 具体可以参照官方文档说明:

功能需求 对应的Godot C# API 作用
获取主显示器ID DisplayServer.GetPrimaryScreen() 在多个屏幕当中的主显示屏幕
获取当前窗口所在主显示器ID DisplayServer.WindowGetCurrentScreen() 在多个屏幕当中的主显示屏幕
获取显示器原生分辨率 DisplayServer.ScreenGetSize(screenId) 获取指定主显示器当中的屏幕分辨率
设置窗口大小 DisplayServer.WindowSetSize(Vector2I) 将目前游戏窗口大小按指定尺寸调整
设置窗口最小尺寸 DisplayServer.WindowSetMinSize(Vector2I) 将目前游戏窗口大小按最小尺寸调整
设置窗口最大尺寸 DisplayServer.WindowSetMaxSize(Vector2I) 将目前游戏窗口大小按最大尺寸调整
设置窗口的位置 DisplayServer.WindowSetPosition(Vector2I) 调整目前游戏窗口位置
设置窗口模式(全屏/窗口) DisplayServer.WindowSetMode(WindowMode) 设置窗口目前显示模式
窗口扩展配置 DisplayServer.WindowSetFlag(DisplayServer.WindowFlags.Borderless, true/false) 隐藏状态栏等功能
设置VSync DisplayServer.WindowSetVsyncMode(VSyncMode) 设置垂直同步功能
禁止窗口拖调 DisplayServer.WindowSetResizable(true/false) 不允许窗口被拖动调整分辨率

需要注意很多游戏开发者都忽略多显示器这种情况(多显示器的调整也是很复杂的情况), 一般会遇到的情况如下:

  • 多屏幕调整坐标位置, 大部分调整主显示窗口位置(实现居中显示)的情况, 哪怕移动到副屏坐标都是从主显示器坐标系的位置计算

  • 目前虽然有分辨率标准(720/900/1080P之类), 但是部分玩家是支持将屏幕旋转90度成纵向显示屏, 这时候为了游戏正常运行就需要需要调整上下大黑边

  • 多显示器当中有比较小众的多屏合一情况(四个显示器扩展设置单块屏幕显示), 这种情况算是很小众的问题点, 不需要过多的关注处理

所以这里在 Game 层用到 C# 扩展功能来为所有 Display.Resolution 枚举扩展出 SetSize 等方法:

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
using Godot;
using P21Utility.Definitions.Settings.Display;

// 专门放置 {Game层}.Manager.Settings.Extensions 之中
namespace P21Game.Manager.Settings.Extensions;

/// <summary>
/// 静态类, 用于扩展 Display.Resolution 枚举
/// </summary>
public static class GodotDisplayResolutionExtensions
{
/// <summary>
/// 将 Display.Resolution 映射成 GodotAPI 的 DisplayServer.WindowSetSize 操作
/// </summary>
/// <param name="resolution">显示分辨率枚举</param>
/// <param name="centered"></param>
/// <returns>是否切换成功</returns>
public static void SetSize(this Resolution resolution,bool centered = false)
{
// 获取切换显示宽高
var (width, height) = resolution.GetSize();

// 获取当前屏幕可用尺寸, 确认显示器是否支持该分辨率
// 如果不做这样的显示, 会导致 1080P 分辨率的显示器被设置成 4K 分辨率
var screenId = DisplayServer.WindowGetCurrentScreen();
var screenSize = DisplayServer.ScreenGetSize(screenId);
if (width > screenSize.X || height > screenSize.Y) return;

// 切换具体尺寸
DisplayServer.WindowSetSize(new Vector2I(width, height));
DisplayServer.WindowSetMinSize(new Vector2I(width, height)); // 设置最小尺寸, 不要设置最大尺寸

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


/// <summary>
/// 检查当前的显示器支持的最大分辨率
/// </summary>
/// <returns></returns>
public static Resolution[] GetResolutions()
{
// 获取目前使用显示器的最大尺寸
var screenId = DisplayServer.WindowGetCurrentScreen();
var screenSize = DisplayServer.ScreenGetSize(screenId);
return ResolutionExtensions.GetResolutions(screenSize.X, screenSize.Y);
}

}

最后随便演示下添加布局查看效果, 这里随便设置游戏的操作界面:

ui-layout

脚本内容如下:

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
using System;
using Godot;
using P21Game.Manager.Settings;
using P21Game.Manager.Settings.Extensions;
using P21Utility.Definitions.Settings;


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

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

/// <summary>
/// 下拉选择分辨率
/// </summary>
[Export] public OptionButton ResolutionSelect;

/// <summary>
/// 保存分辨率设置
/// </summary>
[Export] public Button UpdateButton;



/// <summary>
/// 初始化完成
/// </summary>
public override void _Ready()
{

// 获取目前支持的分辨率加入其中
if (ResolutionSelect != null)
{
ResolutionSelect.Clear();// 清空

// 遍历目前支持的分辨率
foreach (var resolution in GodotDisplayResolutionExtensions.GetResolutions())
{
var resId = (int)resolution; // 转化为下拉选择 ID
var (w, h) = resolution.GetSize(); // 获取具体尺寸
ResolutionSelect.AddItem($"{w} x {h}({resolution.ToString()})", resId);
}
}

// 触发切换显示分辨率事件
if (UpdateButton != null)
{
UpdateButton.Pressed += UpdateDisplayResolution;
}

}


/// <summary>
/// 更新显示分辨率
/// </summary>
private void UpdateDisplayResolution()
{
// 确认选中并切换成对应尺寸
var id = ResolutionSelect.GetSelectedId();
var resolution = (DisplayResolution)id;
resolution.SetSize(); // 使用的自定义扩展方法, 将 Godot 内部切换指定分辨率
}

}

在编辑器之中绑定按钮就可以手动切换不同分辨率样式, 我目前主显示器为 1080P 下拉列表如下:

select-resolution

这样默认最高分辨率只能到 1080P 渲染, 剔除掉不支持的 2K/4K 分辨率达到显示分辨率的最优解

Godot 显示模式

目前的功能已经支持调整分辨率处理, 接下来就是用到的是 DisplayServer.WindowSetMode 的功能来切换显示模式, 这里需要扩展额外知识点

对于桌面模式的全屏其实是有两种全屏模式: FullscreenExclusiveFullscreen, 这两种虽然是全屏但是各自处理方式不同

  • Fullscreen: 覆盖画面全屏幕, 其实也就是类似于让游戏画面窗口处于顶级渲染, 同屏下程序渲染任务在后台进行(只是挡住不可见)

  • ExclusiveFullscreen: 独占屏幕全屏化, 这个模式下会停止同屏幕下其他程序渲染任务, 将渲染任务集中在当前游戏程序之中

这里的知识在其他游戏引擎通用, 是关于桌面渲染方面的知识点

ExclusiveFullscreen 因为是完全独占窗口渲染从而性能上好很多, 在大型游戏上面的可以最大限度压榨电脑性能

虽然看起来很美好, 但是实际上在日常使用当中也有坑点的, 主要问题还是 ExclusiveFullscreen 在部分直播时候使用采集卡游戏会出现布局混乱

ExclusiveFullscreen 在全屏切换模式下会导致某些二次开发的直播工具/利用桌面分栏布局的工具/直播设备采集卡等依赖窗口钩子的元素挤占到第二屏幕

所以更推荐采用更加温和的 Fullscreen 方式做全屏实现, 当然如果大型游戏直接用独占全屏以获得最高性能也可以, 这部分按照自身项目出发即可

而 Godot 方便实现也比较简单, 还是和上面一样扩展 Display.Mode 功能即可:

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
using System;
using System.Linq;
using Godot;
using P21Utility.Definitions.Settings.Display;


// 专门放置 {Game层}.Manager.Settings.Extensions 之中
namespace P21Game.Manager.Settings.Extensions;

/// <summary>
/// 静态类, 用于扩展 Display.Mode 枚举
/// </summary>
public static class GodotDisplayModeExtensions
{
/// <summary>
/// 获取目前支持的显示模式列表
/// </summary>
/// <returns>支持显示模式枚举</returns>
public static Mode[] GetModes()
{
// 最好集中定义在内部方法, 后续如果游戏上其他主机相关平台可能不支持切换显示模式这里就返回空
// 外部识别返回空就默认隐藏该配置
return Enum.GetValues<Mode>();
}


/// <summary>
/// 扩展支持显示模式切换
/// </summary>
/// <param name="mode">显示模式枚举</param>
public static void SetMode(this Mode mode)
{
switch (mode)
{
case Mode.Windowed:

// 设置窗口化, 追加边框
DisplayServer.WindowSetMode(DisplayServer.WindowMode.Windowed);
DisplayServer.WindowSetFlag(DisplayServer.WindowFlags.Borderless, false);
break;
case Mode.Borderless:
// 先设为窗口化,再去除边框
DisplayServer.WindowSetMode(DisplayServer.WindowMode.Windowed);
DisplayServer.WindowSetFlag(DisplayServer.WindowFlags.Borderless, true);
break;
default:
case Mode.Fullscreen:
// 设置全屏需要优选自身目前最大的分辨率
// 比如你显示器默认支持到 1080P 分辨率, 不要去强制拉伸分辨率
// 在正式环境当中发现过有的玩家主屏幕采用竖屏方式运行, 而如果采用全屏拉伸的处理方法会导致游戏UI全部错位
// 所以需要优选出当前显示器的最高分辨率设置
var resolution = GetMaxScreenResolution();
var (width, height) = resolution.GetSize();
var resolutionVec2 = new Vector2I(width, height);

// 设置全屏之后拉伸主显示器尺寸
DisplayServer.WindowSetMode(DisplayServer.WindowMode.Fullscreen);
DisplayServer.WindowSetSize(resolutionVec2);
DisplayServer.WindowSetMinSize(resolutionVec2); // 只需要调整最小尺寸, 不需要调整最大
break;
}
}


/// <summary>
/// 获取目前屏幕支持的最大分辨率
/// </summary>
/// <returns>命中的最大分辨率, 如果都没有命中默认返回1080P的分辨率</returns>
private static Resolution GetMaxScreenResolution()
{
var screenId = DisplayServer.WindowGetCurrentScreen(); // 获取目前显示器ID
var screenSize = DisplayServer.ScreenGetSize(screenId); // 获取目前显示器的尺寸

// 对默认分辨率做倒序
var sorted = Enum.GetValues<Resolution>()
.OrderByDescending(res =>
{
var (w, h) = res.GetSize(); // 复用你已有的枚举GetSize拓展方法
return w * h; // 按像素总数排序,高分辨率优先
});

// 遍历获取最大分辨率
foreach (var res in sorted)
{
var (w, h) = res.GetSize();
if (w <= screenSize.X && h <= screenSize.Y)
{
return res; // 找到最大支持分辨率,直接返回
}
}

// 没有找到的分辨率返回默认 1080P
return Resolution.P1080;
}


/// <summary>
/// 调整分辨率和设置显示模式
/// </summary>
/// <param name="mode">显示模式</param>
/// <param name="resolution">分辨率</param>
public static void SetModeAndResolution(this Mode mode, Resolution resolution)
{
resolution.SetSize();
mode.SetMode();
}

}

最后就是在脚本之中实现对应功能, 这里追加 OptionButton 节点来作为下拉选择桌面模式:

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
using System;
using Godot;
using P21Game.Manager.Settings;
using P21Game.Manager.Settings.Extensions;
using P21Utility.Definitions.Settings.Display;


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

/// <summary>
/// 挂载游戏入口节点的 C# 脚本
/// </summary>
public partial class Main : Node
{
/// <summary>
/// 下拉选择显示模式
/// </summary>
[Export] public OptionButton ModeSelect;

/// <summary>
/// 下拉选择分辨率
/// </summary>
[Export] public OptionButton ResolutionSelect;

/// <summary>
/// 保存分辨率设置
/// </summary>
[Export] public Button UpdateButton;


/// <summary>
/// 确认是否窗户化
/// </summary>
/// <returns></returns>
private bool IsWindowed { get; set; }


/// <summary>
/// 初始化完成
/// </summary>
public override void _Ready()
{
// 获取目前支持的显示模式加入其中
if (ModeSelect != null)
{
ModeSelect.Clear();
foreach (var mode in GodotDisplayModeExtensions.GetModes())
{
var modeId = (int)mode;
ModeSelect.AddItem($"{mode} - {modeId}", modeId);
}
}


// 获取目前支持的分辨率加入其中
if (ResolutionSelect != null)
{
ResolutionSelect.Clear(); // 清空
foreach (var resolution in GodotDisplayResolutionExtensions.GetResolutions())
{
// 附加所有尺寸
var (w, h) = resolution.GetSize();
ResolutionSelect.AddItem($"{w} x {h}({resolution.ToString()})", (int)resolution);
}
}

// 触发切换显示分辨率事件
if (UpdateButton != null)
{
UpdateButton.Pressed += UpdateDisplayResolution;
}

// 设置窗口化初始化值
IsWindowed = DisplayServer.WindowGetMode() != DisplayServer.WindowMode.Fullscreen;
}


/// <summary>
/// 更新显示分辨率和显示模式
/// </summary>
private void UpdateDisplayResolution()
{
// 获取显示模式
var modeId = ModeSelect.GetSelectedId();
var mode = (Mode)modeId;
IsWindowed = mode != Mode.Fullscreen;

// 确认是否窗口化, 如果设置为窗口化就就该显示分辨率
if (IsWindowed)
{
var id = ResolutionSelect.GetSelectedId();
var resolution = (Resolution)id;
resolution.SetSize(); // 重置分辨率
}

// 最后统一设置显示模式
mode.SetMode();
}


/// <summary>
/// 游戏帧更新
/// </summary>
/// <param name="delta"></param>
public override void _Process(double delta)
{
// 如果全屏直接禁用调整分辨率, 全屏只允许运行在主显示器最大分辨率
ResolutionSelect.Disabled = !IsWindowed;
}
}

这里追加节点绑定功能即可, 最后在 Godot 编辑器设置下具体节点功能即可:

select-mode

后续就是准备提升为全局视频管理器, 也就是将其 Manager 化处理

Manager 整合管理

全局管理器定义也比较简单, 但是需要涉及到落地保存本地和读取加载配置的情况, 一般游戏的系统配置都是直接写入到本地

而写入本地的系统配置一般都是以 JSON 形式保存, 这部分就需要用到 JSON 序列化功能, 所以这部分需要连接 C# 的 JSON 扩展

默认 C# 内部已经支持 JSON(System.Text.Json), 不要用 Godot 的 JSON 序列化库(功能欠缺严重, 而且不是 C# 原生)

这里 Manager 定义如下, 抽象成专门管理的 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
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
using System;
using P21Utility.CDI;
using P21Utility.Definitions.Settings;

namespace P21Game.Manager.Settings;

/// <summary>
/// 配置类功能抽象
/// 可以先放在 Game 层, 后续如果该接口可以通用就可以提升到 Utility 层
/// </summary>
public interface ISettingsManager : IManager
{
/// <summary>
/// 系统级的功能, 需要优先加载
/// </summary>
int IManager.Priority => -1;


/// <summary>
/// 加载本地配置到游戏
/// </summary>
public void LoadSettings();

/// <summary>
/// 保存配置到本地
/// </summary>
public void SaveSettings();


#region 显示设置

/// <summary>
/// 修改显示设置的回调事件, 用于外部回调去唤起更新
/// 注意: 该事件可能会被唤醒多次, 因为显示修改很多配置都是关联的, 也就是修改一次会带动多个配置变动
/// </summary>
public event Action OnDisplayChanged;


/// <summary>
/// 是否处于全屏模式
/// </summary>
public bool IsFullscreenMode { set; get; }


/// <summary>
/// 修改显示模式
/// </summary>
public DisplayMode DisplayMode { set; get; }


/// <summary>
/// 修改显示分辨率
/// </summary>
public DisplayResolution DisplayResolution { set; get; }

/// <summary>
/// 修改是否启用垂直同步
/// </summary>
public bool DisplayVSync { set; get; }

#endregion


#region 音频设置

// todo: 等待追加音频设置功能

#endregion

#region 游戏设置

// todo: 等待追加游戏设置功能

#endregion

#region 操作设置

// todo: 等待追加操作设置功能

#endregion
}

这里目前只有显示设置这类功能已经支持, 后续会自动扩展不同设置, 这里需要定义数据类来装载具体的配置(也可以理解为数据容器):

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
using P21Utility.Definitions.Settings;

// 放置在 {Game层}.Manager.Settings.Data 的目录之中
// 只用于序列化 JSON 和挂载到 Manager 做数据读写操作
namespace P21Game.Manager.Settings.Data;

/// <summary>
/// Godot 显示设置的数据类
/// 用于 JSON 序列化保存本地
/// </summary>
public class GodotDisplaySettings
{
/// <summary>
/// 显示模式: 默认为全屏
/// </summary>
public DisplayMode Mode { get; set; } = DisplayMode.Fullscreen;


/// <summary>
/// 显示分辨率: 默认为 1080P
/// </summary>
public DisplayResolution Resolution { get; set; } = DisplayResolution.P1080;

/// <summary>
/// 是否启用垂直同步: 默认启用
/// </summary>
public bool VSync { get; set; } = true;
}

为什么需要数据类? 因为这些配置需要直接将类对象序列化到本地保存, 如果不采用数据类就要专门定义 Map/Dict 对象写入和提取

用 Map/Dict 对象读写很麻烦, 需要对每个字段做验证类型处理, 不如直接将类序列化/反序列化处理容易

这里就构建成新全局管理器对象:

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
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.Json;
using Godot;
using P21Game.Manager.Settings.Data;
using P21Game.Manager.Settings.Extensions;
using P21Utility.Definitions.Settings;
using Serilog;

namespace P21Game.Manager.Settings.Impl;

/// <summary>
/// Godot 系统配置管理器
/// </summary>
/// <param name="filename">保存的本地文件: 由外部传入</param>
public class GodotSettingsManager(string filename) : ISettingsManager
{
/// <summary>
/// 启动的时候默认加载本地配置
/// </summary>
public void Init()
{
Log.Information("GodotSettings File: {Filename}", filename);
try
{
// 加载本地配置
LoadSettings();
}
catch (Exception exception)
{
Log.Error(exception, "Failed to load Godot Settings");
InitSettings(); // 生成原始配置对象
}
}

/// <summary>
/// 不要退出的时候再写入一遍配置, 这种行为没有意义
/// 而是要每次修改之后同步写入到本地
/// </summary>
public void Dispose() => Log.Information("Disposing Godot Settings, File: {Filename}", filename);


/// <summary>
/// 读取本地的配置文件
/// </summary>
public void LoadSettings()
{
// 加载本地文件是否存在配置, 不存在直接初始化简单
if (!File.Exists(filename))
{
InitSettings();
return;
}

// 加载内部的配置
var json = File.ReadAllText(filename, new UTF8Encoding(false));

// 解构成 Dict 形式
var settings = JsonSerializer.Deserialize<Dictionary<string, JsonDocument>>(json);

// 读取显示配置
try
{
LoadDisplaySettings(settings[nameof(GodotDisplaySettings)].Deserialize<GodotDisplaySettings>());
Log.Information("Loaded Godot Display Settings( {DisplaySettings} )", _displaySettings.ToString());
}
catch (Exception exception)
{
Log.Error(exception, "Failed to load Godot Display Settings");
LoadDisplaySettings(new GodotDisplaySettings());
}
}


/// <summary>
/// 加载显示设置
/// </summary>
/// <param name="displaySettings">更新的显示设置</param>
private void LoadDisplaySettings(GodotDisplaySettings displaySettings)
{
_displaySettings = displaySettings;
_displaySettings.Mode.SetMode();
_displaySettings.Resolution.SetSize();
DisplayServer.WindowSetVsyncMode(_displaySettings.VSync ? DisplayServer.VSyncMode.Enabled : DisplayServer.VSyncMode.Disabled);
}

/// <summary>
/// 生成原始配置
/// </summary>
private void InitSettings()
{
// 初始化视频设置
LoadDisplaySettings(new GodotDisplaySettings());


// todo: 其他配置

SaveSettings(); //将配置重新保存本地
}



/// <summary>
/// 保存配置文件到本地
/// </summary>
public void SaveSettings()
{
try
{
// 将对应类型保存在 KEY-VALUE 结构
Dictionary<string, object> settings = new()
{
[nameof(GodotDisplaySettings)] = _displaySettings ?? new GodotDisplaySettings(),
// todo: 其他音频/游戏/操作设置
};

// 将类对象序列化成 JSON
var jsonSettings = JsonSerializer.Serialize(settings);
Log.Information("Saving Godot Settings: {JsonSettings}", jsonSettings);

// 确认目录存在
var dirname = Path.GetDirectoryName(filename);
if (!string.IsNullOrEmpty(dirname) && !Directory.Exists(dirname))
{
Directory.CreateDirectory(dirname); // 创建目录
}

// 以 UTF8-NO-BOM 形式写入文件
File.WriteAllText(filename, jsonSettings, new UTF8Encoding(false));
}
catch (Exception exception)
{
Log.Error(exception, "Failed to save Godot Settings");
}
}


#region 显示设置

/// <summary>
/// 显示设置
/// </summary>
private GodotDisplaySettings _displaySettings;


/// <summary>
/// 修改显示设置的回调处理
/// </summary>
public event Action OnDisplayChanged;

/// <summary>
/// 确认目前是否为全屏模式
/// </summary>
public bool IsFullscreenMode
{
get => _displaySettings.Mode == DisplayMode.Fullscreen;
set => throw new NotSupportedException(); // 不支持修改
}


/// <summary>
/// 切换显示模式
/// </summary>
public DisplayMode DisplayMode
{
get => _displaySettings.Mode;
set
{
if (value == _displaySettings.Mode) return;

// 同步修改 Godot 配置, 不是全屏模式就要修改成目前选中的配置
_displaySettings.Mode = value;
value.SetMode();
// 不是全屏需要回滚原来的分辨率
if (!IsFullscreenMode) _displaySettings.Resolution.SetSize();

// 确认正在加载中并唤起回调保存本地
OnDisplayChanged?.Invoke();
SaveSettings();
}
}

/// <summary>
/// 切换显示分辨率
/// </summary>
public DisplayResolution DisplayResolution
{
get => _displaySettings.Resolution;
set
{
// 跳过重复
if (value == _displaySettings.Resolution) return;
if (IsFullscreenMode) return; // 全屏不允许修改
_displaySettings.Resolution = value;
value.SetSize();
OnDisplayChanged?.Invoke(); // 唤起回调
SaveSettings(); // 保存本地
}
}


/// <summary>
/// 切换是否启用垂直同步
/// </summary>
public bool DisplayVSync
{
get => _displaySettings.VSync;
set
{
if (value == _displaySettings.VSync) return;
_displaySettings.VSync = value;
DisplayServer.WindowSetVsyncMode(value
? DisplayServer.VSyncMode.Enabled
: DisplayServer.VSyncMode.Disabled);
OnDisplayChanged?.Invoke(); // 唤起回调
SaveSettings(); // 保存本地
}
}

#endregion
}

ISettingsManager 看起来功能都是很通用组件, 如果在 Game 层内部被验证可以通用的话, 确认没问题就可以提升到 Utility 通用工具层来管理

注意: 一定要加上 if (value == DisplaySettings.* ) return 重复跳过, 因为 Godot C# 内部事件会让属性是会被多次唤起调用的

之后就是将该全局管理器在 AutoLoad 之中注册到全局:

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
using System;
using Godot;
using P21Game.Manager.Settings;
using P21Game.Manager.Settings.Impl;
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)
{
// 初始化日志配置
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;


// 注册配置管理器, 写入的文件由外部传入, 实际上应该有个全局系统变量加载, 这里暂时写死
var settingsFilename = ProjectSettings.GlobalizePath("user://settings.json");
ManagerRegistry.Register<ISettingsManager>(()=>new GodotSettingsManager(settingsFilename));
}


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

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

最后就是通用实现的 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
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
using Godot;
using P21Game.Manager.Settings;
using P21Game.Manager.Settings.Extensions;
using P21Utility.CDI;
using P21Utility.Definitions.Settings;
using Serilog;


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

/// <summary>
/// 挂载游戏入口节点的 C# 脚本
/// </summary>
public partial class Main : Node
{
/// <summary>
/// 下拉选择显示模式
/// </summary>
[Export] public OptionButton ModeSelect;

/// <summary>
/// 下拉选择分辨率
/// </summary>
[Export] public OptionButton ResolutionSelect;

/// <summary>
/// 保存分辨率设置
/// </summary>
[Export] public Button UpdateButton;



/// <summary>
/// 配置管理器
/// </summary>
private ISettingsManager _settingsManager;



/// <summary>
/// 初始化完成
/// </summary>
public override void _Ready()
{
// 获取全局管理器
_settingsManager = ManagerRegistry.Get<ISettingsManager>();

// 获取目前支持的显示模式加入其中
if (ModeSelect != null)
{
ModeSelect.Clear();
var active = -1;
foreach (var mode in GodotDisplayModeExtensions.GetModes())
{
var modeId = (int)mode;
ModeSelect.AddItem($"{mode} - {modeId}", modeId);

// 确认目前是否选中该值
if (_settingsManager.DisplayMode.Equals(mode)) active = modeId;
}

if (active > -1) ModeSelect.Selected = ModeSelect.GetItemIndex(active);
Log.Information("Selected Mode: {active}", active);
}


// 获取目前支持的分辨率加入其中
if (ResolutionSelect != null)
{
ResolutionSelect.Clear(); // 清空

// 遍历目前支持的分辨率
var active = -1;
foreach (var resolution in GodotDisplayResolutionExtensions.GetResolutions())
{
var resId = (int)resolution; // 转化为下拉选择 ID
var (w, h) = resolution.GetSize(); // 获取具体尺寸
ResolutionSelect.AddItem($"{w} x {h}({resolution.ToString()})", resId);

// 确认目前是否选中该值
if (_settingsManager.DisplayResolution.Equals(resolution)) active = resId;
}
if (active > -1) ResolutionSelect.Selected = ResolutionSelect.GetItemIndex(active);
Log.Information("Selected Resolution: {active}", active);

}

// 触发切换显示分辨率事件
if (UpdateButton != null)
{
UpdateButton.Pressed += UpdateDisplayResolution;
}


// 监听显示设置更新功能
_settingsManager.OnDisplayChanged += () =>
{
Log.Information("Display Settings Updated");
};
}


/// <summary>
/// 更新显示分辨率
/// </summary>
private void UpdateDisplayResolution()
{
// 获取显示模式
var modeId = ModeSelect.GetSelectedId();
var mode = (DisplayMode)modeId;
_settingsManager.DisplayMode = mode;
if (_settingsManager.IsFullscreenMode) return;


// 切换管理器的显示尺寸
var id = ResolutionSelect.GetSelectedId();
var resolution = (DisplayResolution)id;
_settingsManager.DisplayResolution = resolution;
}


/// <summary>
/// 游戏帧更新
/// </summary>
/// <param name="delta"></param>
public override void _Process(double delta)
{
// 如果全屏直接禁用调整分辨率, 全屏只允许运行在主显示器最大分辨率
ResolutionSelect.Disabled = _settingsManager.IsFullscreenMode;
}
}

这样就实现切换游戏分辨率和显示模式的基础功能, 最后的效果如下:

display-changed