GameTech/Godot 4.7 互动教程
目录示例游戏
第五部分 · 发布 · 第 18 章

导出与发布:Web、桌面与移动端

导出模板和资源包是怎么组合成游戏的;Web 导出的线程、响应头、体积、字体和浏览器限制;本站的网页参数桥是怎么实现的;桌面和移动端的发布要点。

前面各章的示例,你都是在浏览器里直接玩的。它们和你在编辑器里按 F5 运行的是同一份工程,只是多了一步"导出"。这一章讲导出到底做了什么,Web 版有哪些特殊限制,以及本站"拖滑块改游戏参数"是怎么做到的。最后介绍桌面端和移动端发布时需要注意的地方。

导出 = 引擎模板 + 资源包

Godot 编辑器本身就是一个"带编辑功能的引擎"。导出时,Godot 不会把你的游戏编译成新的程序,而是把两样东西放在一起:

  • 导出模板(export template):官方针对各个平台预先编译好的引擎运行时,不带编辑器。Windows 上是一个 .exe,Web 上是 .wasm 加上 .js 加载器,Android 上是 APK 的骨架。
  • 资源包(.pck):你的场景、脚本、图片、音频导入后的结果,打包成一个文件。

游戏启动时,引擎先找到这个 pck 并把它挂载为 res://,然后运行 project.godot 里设置的主场景。这也解释了为什么不同游戏导出的 wasm 完全相同,不同的只有 pck。本站的 6 个示例就是这样共用了同一份 37 MB 的引擎文件,每个游戏自己只有几百 KB 到几 MB 的 pck。

导出模板的版本必须和编辑器完全一致。4.7.2 的编辑器只能用 4.7.2.stable 的模板,4.7.1 的不行。在编辑器菜单「编辑器 → 管理导出模板」里可以在线下载,也可以手动安装 .tpz 文件。模板会放在 ~/Library/Application Support/Godot/export_templates/4.7.2.stable/(macOS)或 %APPDATA%\Godot\export_templates\(Windows)。

导出预设:export_presets.cfg

在「项目 → 导出」里添加的每个平台配置,都会保存在工程根目录的 export_presets.cfg 里。这个文件应该提交到版本库,这样别人或者 CI 也能用同样的配置导出。下面是本站示例用的 Web 预设:

export_presets.cfg第 1–22 行本站所有示例共用的 Web 导出预设在工程中查看 ↗
[preset.0]

name="Web"
platform="Web"
runnable=true
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter="*.json"
exclude_filter=""
export_path="export/web/index.html"

[preset.0.options]

variant/extensions_support=false
variant/thread_support=false
vram_texture_compression/for_desktop=true
vram_texture_compression/for_mobile=false
html/canvas_resize_policy=2
html/focus_canvas_on_start=true
progressive_web_app/enabled=false

里面有几项值得逐个说明:

选项本站取值说明
export_filterall_resources导出所有资源。另外还可以选"只导出选中的场景及其依赖",体积更小,但容易漏掉用 load() 动态加载的文件。
include_filter*.json非资源文件(json、txt、csv)默认不会被导出,要在这里额外列出来。
variant/thread_supportfalse不用多线程版引擎,服务器就不需要特殊的响应头(下一节详细讲)。
vram_texture_compression/for_desktoptrue贴图用 S3TC/BPTC 压缩格式,桌面浏览器的显存占用更小。如果要支持手机浏览器,就要同时打开 for_mobile(ETC2/ASTC),pck 会变大。
html/canvas_resize_policy2(自适应)画布跟随窗口大小变化。嵌在 iframe 里的游戏一定要选这一项。

本教程自己就踩过这个坑:参数桥脚本 tuning.gd 要读取 res://params.json。在编辑器里运行一切正常,但导出后的 Web 版读不到这个文件,所有参数都失效了。原因是 json 不是 Godot 资源,默认不会进 pck。在 include_filter 里加上 *.json 就解决了。如果你的游戏"编辑器里正常,导出后某个数据文件找不到",第一个要查的就是这里。

导出也可以完全在命令行里完成,不用打开编辑器。这样方便写成脚本或者放进 CI:

godot --headless --path 工程目录 --import          # 首次导出前先导入资源
godot --headless --path 工程目录 --export-release "Web" build/web/index.html
#       ↑ 引号里是预设的名字;还有 --export-debug 和只导出资源包的 --export-pack

Web 导出的特殊之处

1. 只能用 Compatibility 渲染器

浏览器里能用的图形 API 是 WebGL 2.0,对应 Godot 的 Compatibility 渲染器(OpenGL ES 3.0 级别)。Forward+ 和 Mobile 基于 Vulkan、D3D12、Metal,在浏览器里跑不了。所以如果确定要发布 Web 版,从建工程的第一天起就选 Compatibility,不要等做完了才发现 SDFGI、体积雾这些效果在 Web 上都没有。第 15 章有一张表列出了 Compatibility 下 3D 功能的实际可用情况。

2. 单线程还是多线程

多线程版引擎依赖浏览器的 SharedArrayBuffer。出于安全原因(Spectre 漏洞),浏览器只在页面处于跨源隔离状态时才开放这个功能,这要求服务器在响应里加两个头:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

这两个头会带来一连串连锁反应:页面上引用的所有跨域资源(图片、脚本、广告、统计代码)都必须明确允许被嵌入,否则会被拦截。很多托管平台也不让你自定义响应头。从 Godot 4.3 起可以导出单线程版(导出预设里「线程支持」不勾选),它不需要这些响应头,放到任何静态服务器上都能运行,对大多数游戏来说这是更省心的选择。代价是音频混音也在主线程上做,主线程很忙时声音可能卡顿。

3. 体积和压缩

未压缩的 wasm 有 37 MB,但它的压缩率很高:gzip 后大约 9.6 MB,brotli 后大约 6.6 MB。所以服务器一定要开压缩,并且要给 wasm 设置正确的 MIME 类型。否则浏览器无法边下载边编译(streaming compile),加载会明显变慢。nginx 的配置示例:

types { application/wasm wasm; }        # 较新版本的 nginx 已经自带
gzip on;
gzip_types application/wasm application/javascript application/octet-stream;
location ~* \.(wasm|pck)$ { add_header Cache-Control "public, max-age=604800"; }

wasm 只要下载一次,之后会走浏览器缓存,所以多个游戏共用一份引擎是很划算的做法。

4. 中文字体

在桌面上运行时,Godot 找不到字形会自动去用系统字体,所以中文能正常显示。浏览器里的 Godot 访问不到系统字体,没有打包字体的话,所有中文都会显示成方块。解决办法是在「项目设置 → GUI → 主题 → 自定义字体」里指定一个中文字体文件。完整的中文字体有 10 到 20 MB,太大了。本站的构建脚本会扫描每个工程里实际用到的字符,用 fontTools 把思源黑体(Noto Sans SC,开源许可)裁剪成只含这些字的子集,一般只有 100 到 300 KB。如果你的游戏文字是固定的,这个方法很合适;如果玩家可以输入任意文字(比如聊天),就需要打包常用字表(GB2312 一级字库大约 3500 字,约 1 MB)。

5. 浏览器带来的限制

限制表现处理方法
音频需要用户手势页面打开后,在用户点击或按键之前,浏览器不允许播放声音做一个"点击开始"的界面;Godot 会在第一次输入后自动恢复音频
鼠标锁定MOUSE_MODE_CAPTURED 只能在点击事件里生效,按 Esc 时浏览器会强制释放FPS 游戏要有"点击画面继续"的提示层(见第 16 章)
键盘焦点游戏嵌在 iframe 里时,用户点了页面上的其他地方,游戏就收不到按键了本站在你松开滑块时会自动把焦点还给游戏
user:// 存档映射到浏览器的 IndexedDB,清除站点数据后会丢失重要数据要提示玩家,或者同步到服务器
文件系统和进程不能访问本地文件,OS.execute 等功能不可用用 OS.has_feature("web") 判断平台,走不同的分支

实践:本站的网页参数桥

你在前面各章里拖动的每一个滑块,都要穿过"网页 → iframe → wasm → GDScript"这几层,最后改掉某个节点的 @export 变量。实现它只用到一个类:JavaScriptBridge。它只在 Web 平台上有效,可以执行 JS 代码、获取 JS 对象、把 GDScript 函数包装成 JS 回调。

第一步,游戏启动时开始监听网页发来的消息。浏览器里跨 iframe 通信的标准方法是 postMessage。我们拿到 JS 的 window 对象,把一个 GDScript 函数注册为 message 事件的监听器:

tuning.gd第 151–155 行在工程中查看 ↗
func _setup_web() -> void:
	var window := JavaScriptBridge.get_interface("window")
	_js_callback = JavaScriptBridge.create_callback(_on_js_message)
	window.addEventListener("message", _js_callback)
	_post({"type": "ready"})

create_callback 返回的 JS 对象必须保存在成员变量里。如果只存在局部变量中,函数返回后它就会被回收,JS 那边再调用这个回调时就什么也不会发生,而且不会有任何报错。

第二步,收到消息后解析 JSON 并分发。回调函数收到的参数是一个 Array,里面是 JS 传过来的参数,这里只有一个 event 对象。读取 args[0].data 就能拿到网页发来的字符串:

tuning.gd第 160–193 行在工程中查看 ↗
func _on_js_message(args: Array) -> void:
	var raw: Variant = args[0].data
	if typeof(raw) != TYPE_STRING:
		return
	var msg: Variant = JSON.parse_string(raw)
	if typeof(msg) != TYPE_DICTIONARY or msg.get("ns") != "godot-tut":
		return
	match msg.get("type"):
		"set":
			set_value(msg["key"], msg["value"])
		"action":
			action_triggered.emit(msg["name"])
		"hello":
			_post({"type": "ready"})
		"input":   # 虚拟按键 / 摇杆:当成真实的输入动作注入,游戏代码不用区分来源
			_set_touch_mode(true)
			var action_name: String = msg["action"]
			if InputMap.has_action(action_name):
				var ev := InputEventAction.new()
				ev.action = action_name
				ev.strength = clampf(float(msg.get("strength", 1.0)), 0.0, 1.0)
				ev.pressed = ev.strength > 0.0
				Input.parse_input_event(ev)
		"look":
			_set_touch_mode(true)
			touch_look.emit(Vector2(float(msg["dx"]), float(msg["dy"])))
		"touch":
			_set_touch_mode(bool(msg["on"]))


func _set_touch_mode(on: bool) -> void:
	if touch_mode != on:
		touch_mode = on
		touch_mode_changed.emit(on)

第三步,把值写进绑定的属性。每个游戏脚本在 _ready 里声明"我的哪些属性可以被网页调",参数桥用 Object.set() 按名字写入:

tuning.gd第 42–51 行在工程中查看 ↗
func bind(obj: Object, prefix: String, props: Array) -> void:
	for prop: String in props:
		var key := "%s.%s" % [prefix, prop]
		_bindings.append([weakref(obj), key, prop])
		if values.has(key):
			obj.set(prop, values[key])
		elif OS.is_debug_build() and defs.has(key) and defs[key].has("default"):
			var expected: Variant = _coerce(defs[key], defs[key]["default"])
			if str(expected) != str(obj.get(prop)):
				push_warning("Tuning: %s 在 params.json 的 default=%s,但脚本里是 %s" % [key, expected, obj.get(prop)])

反方向:游戏把实时数据发给网页。通过 JavaScriptBridge.eval 调用父页面的 postMessage。示例下方"实时数据"栏里的速度、分数等,都是这样传出来的,大约每秒 8 次:

tuning.gd第 198–201 行在工程中查看 ↗
func _post(msg: Dictionary) -> void:
	msg["ns"] = "godot-tut"
	var js_str := JSON.stringify(JSON.stringify(msg))
	JavaScriptBridge.eval("window.parent.postMessage(%s, '*')" % js_str)

运行上面的示例,一边拖「最高速度」一边让角色跑动,观察"实时数据"里的 速度 x。然后点「下载工程」:下载下来的 params.json 里,你改过的参数会多出一个 "value" 字段。用 Godot 打开这个工程运行,tuning.gd 启动时会读到它,你在网页上调好的手感就带到了本地。

这个模式也适合你自己的项目:把游戏的 Web 版嵌进开发者后台,策划在网页上调数值,调好后一键导出成配置文件。在游戏里,同一套 bind 代码在桌面上什么也不做,在 Web 上才连接网页。

多个游戏共用一份引擎

Godot 导出的 index.html 里有一段 GODOT_CONFIG,executable 指定引擎文件的位置(不带扩展名),mainPack 指定 pck 的位置。本站的构建脚本把每个示例的这两项改成:

{ "executable": "../../engine/godot",   // 所有示例共用 /engine/godot.wasm
  "mainPack":   "index.pck",            // 每个示例自己的资源包
  "ensureCrossOriginIsolationHeaders": false, ... }

这样,读者看过第一个示例后,后面的示例都只需要下载很小的 pck。

手机上怎么玩:虚拟按键也走参数桥

平台跳跃、RPG、太空射击和 FPS 都是用键盘操作的,手机上没有键盘,FPS 还需要鼠标锁定,手机浏览器也不支持。本站的做法是在网页上给这几个示例叠一层触屏虚拟按键:左下是摇杆,右下是按键,FPS 还有右半屏"滑动转视角"。在手机上打开时会自动显示,电脑上可以点示例顶部的「触屏按键」打开试试。

按键本身是网页里的 HTML 元素,按下时通过同一个 postMessage 通道发给游戏,例如 {"type": "input", "action": "jump", "strength": 1}。游戏收到后构造一个 InputEventAction,交给 Input.parse_input_event(),效果和真的按下键盘一样:Input.is_action_pressed()、get_axis()、_unhandled_input 都能收到,所以平台跳跃、RPG 和太空射击的玩法代码一行都不用改。摇杆发送的 strength 在 0 到 1 之间,get_axis() 读到的就是模拟量,推一半,角色就以一半的速度走。实现就在上面 _on_js_message 的 "input" 和 "look" 两个分支里。

虚拟按键层只有控件本身接收触摸,其他区域的点击会直接落到游戏画面上。所以太空射击的菜单按钮、三消的棋盘照常能点。Godot 默认会把触摸转换成鼠标事件(项目设置 emulate_mouse_from_touch),只用点击和拖动的游戏不需要任何虚拟按键。

FPS 是唯一需要改游戏代码的:在触屏模式下,游戏不再等待鼠标锁定就直接开始,视角改由 Tuning.touch_look 信号驱动(和鼠标移动共用同一个 _look() 函数)。另外,iPhone 上的 Safari 不支持把普通元素切到全屏,所以「全屏」按钮在那里会改成让示例铺满整个浏览器窗口。

桌面端:Windows、macOS、Linux

桌面导出最简单:选好平台,点导出,就会得到一个可执行文件加一个同名的 .pck。打开「嵌入 PCK」(binary_format/embed_pck)可以把资源包合进可执行文件,只发一个文件。各平台要注意的地方:

  • Windows:可以设置图标、版本号、公司名。没有代码签名的 exe 第一次运行时会被 SmartScreen 拦一下,独立游戏一般可以接受;要去掉这个提示,需要购买代码签名证书。
  • macOS:导出的是 .app(可以打包成 zip 或 dmg),默认是同时支持 Intel 和 Apple 芯片的 universal 版本。要正常分发,必须用 Apple 开发者账号做代码签名和公证(notarization)。Godot 的导出选项里可以直接填证书和公证账号。没有签名的 app,玩家需要右键选择"打开"才能运行。
  • Linux:导出 x86_64 的可执行文件即可。Steam 上一般配合 Steam Runtime 使用。

上 Steam 时,常见做法是用 GodotSteam 这类社区插件接入成就、云存档等功能。

移动端:Android 和 iOS

  • Android:需要先安装 Java JDK(Godot 4 要求 JDK 17)和 Android SDK,然后在「编辑器设置 → 导出 → Android」里填好路径。调试用的 keystore 编辑器可以自动生成;正式发布要自己生成 release keystore,并且一定要备份好,丢了就再也不能更新同一个应用。Google Play 要求上传 AAB 格式,所以要勾选"使用 Gradle 构建",导出格式选 AAB。另外要注意 Google Play 对 targetSdk 版本的要求,每年都会提高。
  • iOS:必须在 Mac 上操作。Godot 导出的是一个 Xcode 工程,然后用 Xcode 签名、打包、上传到 App Store Connect。需要 Apple 开发者账号的 Team ID 和描述文件。
  • 移动端的渲染器可以用 Mobile(Vulkan/Metal),也可以用 Compatibility(兼容旧设备更好)。触屏操作要提前规划:Godot 默认会把触摸转换成鼠标事件(项目设置里的 emulate_mouse_from_touch),三消这类只用点击和拖动的游戏不改代码就能在手机上玩。本站的三消示例在手机浏览器里也能正常玩。

发布前检查清单

你用编辑器右上角的「远程调试 → 在浏览器中运行」测试 Web 版,一切正常;但导出后传到公司的 CDN 上,页面提示缺少 SharedArrayBuffer。最可能的原因是什么?

  1. CDN 没有开启 gzip
  2. wasm 的 MIME 类型不对
  3. 导出时选了多线程版本,而 CDN 没有返回 COOP/COEP 响应头
  4. 没有打包中文字体

只有多线程版本才需要 SharedArrayBuffer,而它要求页面处于跨源隔离状态。编辑器内置的测试服务器会自动加上 COOP/COEP 响应头,所以本机正常;CDN 默认不会加。解决办法是让 CDN 返回这两个头,或者更省事地改成单线程导出(thread_support=false)。

游戏在编辑器里能读到 res://data/levels.csv,导出后读不到,最可能的原因是什么?

  1. 导出后 res:// 变成只读了
  2. csv 不是 Godot 资源,没有被打进 pck,要在 include_filter 里加上
  3. Web 平台不支持 FileAccess

res:// 在导出后确实是只读的,但读取不受影响。真正的原因是非资源文件默认不会被导出。在导出预设的「资源 → 导出非资源文件」(include_filter)里加上 *.csv 就可以了。另外,Godot 默认会把 csv 当作翻译文件导入,如果只是普通数据,要在导入面板里把导入方式改成"保留文件"(Keep File)。

用 JavaScriptBridge.create_callback 注册了一个 JS 回调,结果网页调用它时什么都没发生,也没有报错。最可能是哪里写错了?

  1. 回调对象只存在局部变量里,函数结束后就被回收了
  2. 回调函数的参数类型没有写成 Array
  3. 应该用 JavaScriptBridge.eval 而不是 create_callback

create_callback 返回的 JavaScriptObject 要一直被 GDScript 引用着,回调才有效。所以本站的 tuning.gd 把它存在成员变量 _js_callback 里。

结语

到这里,你已经从节点和信号开始,做过了平台跳跃、俯视角 RPG、三消、太空射击和 FPS 五种游戏,也知道了怎么把它们发布出去。接下来最好的学习方法,是挑一个示例工程下载下来,改出你自己的版本:给 RPG 加一个新区域,给三消加一种新的特殊宝石,或者把 FPS 的敌人换成你自己设计的。遇到问题时,Godot 的官方文档(docs.godotengine.org)和编辑器内置的帮助(按 F1 搜索类名)是最可靠的参考资料。