GodotC# 的热更新

目前 Godot 内部其实已经自带了动态模块功能, 可以依靠这部分功能来实现内部基础的的更新机制

最好理解以上官方内容, 都是实现 Godot 热更新的核心机制, 剩下就是要把场景打包成 Export PCK/ZIP 即可

对于 C# 项目必需先构建DLL放在项目目录中, 然后在加载资源包之前需要通过 Assembly.LoadFile("mod.dll")

注意: 这也说明主场景启动的 tscn 文件不要编写任何游戏业务逻辑, 而是要只负责检测验证下载远程热更新包到本地

游戏业务最好按照 通用资源/场景资源 分包, 一些共享的图片/字体资源最好放在通用资源包

包加载

这里创建以下场景来做示例

  • Main(主场景): 进入游戏的主界面场景, 只负责验证本地包是否存在

  • Game(业务场景): 主要游戏业务场景, 相当于进入之后的游戏界面(多个场景创建多个 TSCN)

对于 Main 场景实际上只需要挂脚本然后加 Cover(背景封面:TextureRect)LoadingBar(进度条:ProgressBar),
具体搭建如下:

elf

这里图片是 AI 生成 1920x1080(16:9) 图片, 关键词: 图片风格为 「复古动漫」,比例 16:9, 奇幻/梦幻 , 精灵之森林, 日系风格

这里的图片如下, 可以直接下载处理下使用:

elf

之后编写测试脚本, 用于查看百分比进度条递增的效果, 脚本内容如下

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
using System.Threading.Tasks;
using Godot;

namespace HotFix.Scene;

/// <summary>
/// 启动主场景
/// </summary>
public partial class Main : Control
{
/// <summary>
/// 封面节点
/// </summary>
[Export] public TextureRect Cover;

/// <summary>
/// 加载进度条
/// </summary>
[Export] public ProgressBar LoadingBar;


/// <summary>
/// 定时器是否启动
/// </summary>
private bool _timerRunning = true;

/// <summary>
/// 初始化
/// </summary>
public override void _Ready()
{
// 设置是否运行帧更新
ProcessMode = LoadingBar?.ProcessMode ?? ProcessModeEnum.Disabled;


// 启动定时器: 测试使用
_ = StartProgressBar();
}

/// <summary>
/// 启动定时程序
/// </summary>
private async Task StartProgressBar()
{
while (_timerRunning)
{
// 每秒执行一次
await ToSignal(GetTree().CreateTimer(1.0f), "timeout");
GD.Print("tick");
if (LoadingBar.Value < LoadingBar.MaxValue)
{
LoadingBar.Value++;
}
}
}

/// <summary>
/// 游戏帧递增
/// </summary>
public override void _Process(double ignore)
{
}
}

这里绑定好节点之后可以看到进度不断递增, 这就是用于测试效果进度递增是否正确, 确认没问题就考虑做 Game 场景并打包成资源

资源打包

现在就是另外创建新的场景用来测试我们热更新功能, 这里随便搭建个场景文件(注意: 这些热更新场景不要打包到主场景Main之中)

这里新建场景就比较简单点, 只需要放置背景带颜色图片和一段文字演示, 不过需要说明的是热更新目录我都是放在 /Packages 文件下

game

后面这个功能是要直接打包导出的, 这里直接打包处理: 点击 项目 - 导出 按钮和选项, 在导出选项之中选择平台

export

注意这里会提示没有导出模板内容, 需要去官网下载具体的导出模板(下拉到最后): https://godotengine.org/download/windows/

这里采用 C# 语言开发, 需要采用具体的 dotnet 导出模板下载

下载模板完成 编辑器 - 管理导出模板 - 从文件安装 之后选中刚刚下载好的 Godot_{版本号}-stable_mono_export_templates.tpz

安装完成就可以继续下面的热更包构建, 这里添加 Windows Desktop 的输出, 按照以下方面配置:

  • 热更新包不需要可执行, 所以将 可执行 设置关闭

  • 在子选项 选项 之中如果真是出包最好将 调试 - 导出控制台封装 设置为 No

  • 在子选项 选项 之中因为我们是热更新资源包打包, 所以 应用 - 修改资源 可以直接关闭

  • 在子选项 资源 选择相关的业务资源 导出选中的资源(包括依赖项) 勾选 Packages 所有内容

pac

最后点击 导出 PCK/ZIP 就可以导出我们的热更新资源包, 可以创建个资源目录专门放这部分热更新包

我这边放置在 /Assets 之中并且在 Git 加入排除规则避免同步上传

最后得到打出资源包就代表成功

assets

之后就是准备在 Main 场景之中加载 Game.pck 文件是否成功

打包加载

这里先写死加载项目路径下的 Assets 目录, 后面再扩展出加载 user:// 专属目录资源下载并验证

这里先简单点处理, 一般会去 user://Game.pck 判断文件是否存在, 不存在或者版本不匹配则是服务端下载最新的包覆盖本地

其实需要区分包类型, 如果是在线网络游戏下载的包是比较大的, 一般不推荐放置在 user:// 空间之中, 但是如果是小型 mod 还是放置其中

一般资源材质包动不动都要按 GB 来计算, 放置 user:// 的用户文件夹大部分是默认基于C盘空间, 不如直接基于启动时候的路径配置:

1
2
3
4
// 在代码之中按照以下方面配置包路径目录
// 这里是直接锁定启动 exe 执行文件的根目录下附加 Game.pck
// 大型热更新包最好基于启动的目录做资源配置
ProjectSettings.LoadResourcePack(OS.GetExecutablePath().GetBaseDir().PathJoin("Game.pck"));

在 Main 脚本之中设置字符串暴露变量用于设置热更新包的路径地址, 这里填写 res://Assets/Game.pck

然后就是具体的更新包加载功能编写:

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.Threading.Tasks;
using Godot;

namespace HotFix.Scene;

/// <summary>
/// 启动主场景
/// </summary>
public partial class Main : Control
{
/// <summary>
/// 封面节点
/// </summary>
[Export] public TextureRect Cover;

/// <summary>
/// 加载进度条
/// </summary>
[Export] public ProgressBar LoadingBar;


/// <summary>
/// 热更包的路径地址
/// </summary>
[Export] public string PckFilename;

/// <summary>
/// 加载的热更新包资源
/// </summary>
private PackedScene _imported = null;


/// <summary>
/// 定时器是否启动
/// </summary>
private bool _timerRunning = true;

/// <summary>
/// 初始化
/// </summary>
public override void _Ready()
{
// 设置是否运行帧更新
ProcessMode = LoadingBar?.ProcessMode ?? ProcessModeEnum.Disabled;


// 测试加载资源
if (!ProjectSettings.LoadResourcePack(PckFilename))
{
GD.PrintErr("Resource pack not found");
}
else
{
// 加载成功就可以直接获取场景
GD.Print("Resource pack loaded");

// 注意加载的资源路径必须要和打包的时候一致
_imported = GD.Load<PackedScene>("res://Packages/Game/Game.tscn");
GD.Print(_imported);

// 这里应该可以打印类似 <PackedScene#-9223371998837602871>
// 这就代表资源包加载完成可以将其挂载到场景
// 后面定时器每秒+10进度完成之后挂载该场景
}

// 启动定时器: 测试使用
_ = StartProgressBar();
}

/// <summary>
/// 启动定时程序
/// </summary>
private async Task StartProgressBar()
{
while (_timerRunning && _imported != null)
{
// 每秒执行一次
await ToSignal(GetTree().CreateTimer(1.0f), "timeout");
GD.Print("tick");
if (LoadingBar.Value < LoadingBar.MaxValue)
{
LoadingBar.Value += 10;
}
else
{
_timerRunning = false;
GD.Print("loading bar finished");

// 读条完成直接资源附加
var instance = _imported.Instantiate();
GetTree().Root.AddChild(instance);

// 隐藏|销毁启动界面
this.Hide();
this.QueueFree();
}
}
}
}

最后配置热更新包路径然后运行等待10s读条完成看看是否会加载到包内部场景, 如果成功跳转就代表没有错误

这里仅仅是作为简单的热更新加载包功能编写, 实际上内部还有很多复杂的问题要处理, 还有网络 CDN 分发和 MD5 哈希验证这些种种问题还没处理完成

资源列表

从上面就可以知道热更新包的资源需要以下信息(有些信息是我这边扩展):

  • id: 对应的资源唯一标识, 这个用于提供给客户端按照 KEY 提取 i18n 翻译说明目前进度, 比如 "正在更新战斗场景..."

  • path: 打包的时候写入的资源路径, 也就是上面说得类似 user://{包名}.tscn 场景路径, 用于挂载到 Godot 场景之中

  • url: 远程资源包的下载地址, 正式上线需要配置 CDN 资源地址, 比如 https://cdn.example.com/assets/Game.pck

  • hash: 远程资源包的哈希值, 用于比对本地的资源包哈希是否一致, 不一致就直接视为异常的资源包重新下载覆盖

  • size: 资源包的预估大小, 以字节为单位提供给下载进度做进度递增的 UI 表现效果, 让进度条识别大小之后填充

  • version: 资源包发布的版本号, 本地最开始验证都需要验证本地资源文件的版本

这里展示 JSON 资源文件示例用来提供参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[
{
"id": "GAME_SCENE",
"path": "res://Packages/Game/Game.tscn",
"url": "https://cdn.example.com/assets/Game.pck",
"hash": "63d72051e901c069f8aa1b32aa0c43bb",
"size": 4192,
"version": "1.0.0"
},
{
"id": "BATTLE_SCENE",
"path": "res://Packages/Battle/Battle.tscn",
"url": "https://cdn.example.com/assets/Battle.pck",
"hash": "747d99f92ee9c080ba26108ac5d26488",
"size": 8192,
"version": "1.0.0"
}
]

这是份网络游戏的热更包列表, 一般都是负责游戏周边工具链的服务端(比如PHP/Java/NodeJS)部署的动态暴露接口验证本地客户端资源是否最新

而在 GodotMain 界面, 只需要实现以下功能:

  • HTTP 请求库功能

  • JSON 序列化功能

  • HTTP 下载功能

  • MD5 文件哈希校验功能

这部分都是需要在基础 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
using System.Collections;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace HotFix.Common;

/// <summary>
/// 包数据字段
/// JSON 序列化模式采用 Record 类型定义
/// </summary>
/// <param name="Id">对应的资源唯一标识</param>
/// <param name="Path">打包的时候写入的资源路径</param>
/// <param name="Url">远程资源包的下载地址</param>
/// <param name="Hash">远程资源包的哈希值</param>
/// <param name="Size">资源包的预估大小</param>
/// <param name="Version">资源包发布的版本号</param>
public record PackageDataRecord(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("path")] string Path,
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("hash")] string Hash,
[property: JsonPropertyName("size")] long Size,
[property: JsonPropertyName("version")] string Version
)
{

/// <summary>
/// JSON数据转化
/// </summary>
/// <param name="packages">更新包列表数组对象</param>
/// <param name="options">JSON序列化配置</param>
/// <returns>数据包列表JSON</returns>
public static string ToJson(List<PackageDataRecord> packages,JsonSerializerOptions options = null)
{
return JsonSerializer.Serialize(packages,options);
}

/// <summary>
/// 将JSON数据转化成 PackageDataRecord 数组对象
/// </summary>
/// <param name="json">JSON字符串</param>
/// <param name="options">JSON反序列化配置</param>
/// <returns>PackageDataRecord列表数据</returns>
public static List<PackageDataRecord> FromJson(string json, JsonSerializerOptions options = null)
{
return JsonSerializer.Deserialize<List<PackageDataRecord>>(json, options);
}



/// <summary>
/// 生成测试数据包列表
/// </summary>
/// <returns>序列化JSON数据</returns>
public static string TestData()
{
// 游戏场景数据
var gameScene = new PackageDataRecord(
"GAME_SCENE",
"res://Packages/Game/Game.tscn",
"https://cdn.example.com/assets/Game.pck",
"63d72051e901c069f8aa1b32aa0c43bb",
4192,
"1.0.0");

// 战斗场景数据
var battleScene = new PackageDataRecord(
"BATTLE_SCENE",
"res://Packages/Battle/Battle.tscn",
"https://cdn.example.com/assets/Battle.pck",
"747d99f92ee9c080ba26108ac5d26488",
8192,
"1.0.0"
);

// 打包成数据列表
var packages = new List<PackageDataRecord>
{
Capacity = 2
};
packages.Add(gameScene);
packages.Add(battleScene);

// 序列化成 JSON
return ToJson(packages);
}
};

这里就是和其他游戏后端需要对接的数据结构, 在 Godot 之中运行下测试代码就可以看到最后的数据打印

1
2
3
4
5
6
7
8
9
// 查看测试数据时候正确, 这里没有处理 try-catch 相关, 正式功能需要做拦截处理
var json = PackageDataRecord.TestData();
var form = PackageDataRecord.FromJson(json);
GD.Print(json);
GD.Print($"Size = {form.Count}");

// 输出内容如下
// [{"id":"GAME_SCENE","path":"res://Packages/Game/Game.tscn","url":"https://cdn.example.com/assets/Game.pck","hash":"63d72051e901c069f8aa1b32aa0c43bb","size":4192,"version":"1.0.0"},{"id":"BATTLE_SCENE","path":"res://Packages/Battle/Battle.tscn","url":"https://cdn.example.com/assets/Battle.pck","hash":"747d99f92ee9c080ba26108ac5d26488","size":8192,"version":"1.0.0"}]
// Size = 2

一般来说是配置文件放在 CDN 站点提供拉取资源, 这边写死本地的 http://127.0.0.1:8080 来处理, 我这边自己本地架设 HTTP 服务