引言
Defold 项目想上 CI,难点不在写 YAML,而在两件事:一是代码可测性——逻辑和引擎耦合在一起就测不了;二是构建可重复——编辑器点一下能出包,命令行也要能出同样的包。本文从测试策略讲起,覆盖纯 Lua 逻辑解耦、busted 单元测试、headless 集成测试、bob.jar 命令行构建、多平台构建矩阵、自动化版本号与发布,最后给出静态检查与规范落地方案。
前置阅读:资源管线与项目结构、Lua 模块与脚本生命周期。
目录
- 1. 测试策略与可测性设计
- 2. 纯 Lua 逻辑与引擎解耦
- 3. 单元测试框架与用例编写
- 4. 集成测试与无头运行
- 5. 用 bob.jar 做命令行构建
- 6. 多平台构建矩阵
- 7. 自动化版本号与发布
- 8. 静态检查与代码规范
- 9. 速查表
- 相关阅读
- 延伸阅读
1. 测试策略与可测性设计
1. 测试金字塔
| 层级 | 范围 | 工具 | 占比 |
|---|---|---|---|
| 单元测试 | 纯函数、算法、状态机 | busted | 70% |
| 集成测试 | 系统协作、消息流 | headless 引擎 | 20% |
| 端到端 | 真机点击流程 | 手动 + 自动化脚本 | 10% |
Defold 项目最容易做的是第一层:把所有不依赖引擎的逻辑抽成纯 Lua 模块,这样就能在普通 Lua 解释器里跑测试,不需要启动引擎。
2. 什么该测
值得测:
- 伤害计算公式、掉落概率表
- 背包增删改查与容量规则
- 存档序列化与版本迁移
- 状态机流转(idle → run → jump)
- 数值配置的合法性(如概率之和为 1)
不值得测:
- 引擎 API 本身(go.set_position 等)
- 纯展示逻辑(UI 摆放)
- 一次性工具脚本
3. 可测性设计的三个原则
1. 依赖注入:模块不 require 引擎全局,需要什么从参数传进来
2. 无副作用:纯函数输入输出明确,不读写全局状态
3. 时间可注入:把 os.time() 当作参数传入,方便测试时间相关逻辑
踩坑:Defold 的
go、msg、vmath、sys是引擎注入的全局,在普通 Lua 解释器里不存在。模块只要引用了它们,测试就会报attempt to index a nil value。这就是必须解耦的原因。
2. 纯 Lua 逻辑与引擎解耦
1. 反例与正例
-- 不推荐:逻辑与引擎混在一起,无法单元测试
function on_message(self, message_id, message, sender)
if message_id == hash("damage") then
self.hp = self.hp - message.amount
if self.hp <= 0 then
go.delete() -- 依赖引擎
msg.post("/score", "add", { n = 100 }) -- 依赖引擎
end
end
end
-- modules/combat.lua 纯 Lua,无引擎依赖
local M = {}
-- 返回:新血量、是否死亡、是否触发击退
function M.apply_damage(hp, amount, armor)
local real = math.max(1, amount - (armor or 0))
local new_hp = hp - real
return new_hp, new_hp <= 0, real >= 10
end
function M.apply_heal(hp, max_hp, amount)
return math.min(max_hp, hp + amount)
end
return M
-- 脚本里只做胶水层
local combat = require("modules.combat")
function on_message(self, message_id, message, sender)
if message_id == hash("damage") then
local hp, dead, knockback = combat.apply_damage(self.hp, message.amount, self.armor)
self.hp = hp
if knockback then
go.set_position(go.get_position() + vmath.vector3(0, 10, 0))
end
if dead then
go.delete()
msg.post("/score", "add", { n = 100 })
end
end
end
收益:combat.lua 现在可以在任何 Lua 解释器里测试,包括 CI 机器。
2. 用适配层注入引擎能力
如果逻辑确实需要读时间、读配置,用适配层注入:
-- modules/session.lua
local M = {}
-- clock 默认用 os.time,测试时可传入假时钟
function M.new(clock)
return { clock = clock or os.time, start = nil }
end
function M.begin(s)
s.start = s.clock()
end
function M.elapsed(s)
if not s.start then return 0 end
return s.clock() - s.start
end
return M
-- 测试时传一个可控时钟
local fake_time = 1000
local session = session_mod.new(function() return fake_time end)
session_mod.begin(session)
fake_time = 1042
assert(session_mod.elapsed(session) == 42)
3. 单元测试框架与用例编写
1. 安装 busted 与目录结构
luarocks install busted
modules/
combat.lua
inventory.lua
tests/
spec/
combat_spec.lua
.busted ← 配置:告诉 busted 到哪里找模块
-- tests/.busted
return {
default = {
ROOT = { "tests/spec" },
lpath = "./modules/?.lua;./?.lua",
pattern = "_spec",
},
}
2. 编写用例
-- tests/spec/combat_spec.lua
local combat = require("combat")
describe("combat.apply_damage", function()
it("普通伤害扣血", function()
local hp, dead = combat.apply_damage(100, 20, 0)
assert.are.equal(80, hp)
assert.is_false(dead)
end)
it("护甲减伤至少扣 1 点", function()
local hp = combat.apply_damage(100, 5, 999)
assert.are.equal(99, hp)
end)
it("血量归零判定死亡", function()
local hp, dead = combat.apply_damage(10, 50, 0)
assert.is_true(dead)
assert.is_true(hp <= 0)
end)
it("高伤害触发击退", function()
local _, _, knockback = combat.apply_damage(100, 30, 0)
assert.is_true(knockback)
end)
end)
describe("combat.apply_heal", function()
it("治疗不超过上限", function()
assert.are.equal(100, combat.apply_heal(80, 100, 50))
end)
end)
3. 运行与覆盖率
busted tests/spec/combat_spec.lua
# ●●●●●●
# 5 successes / 0 failures / 0 errors / 0 pending
luarocks install luacov
busted --coverage
luacov
# modules/combat.lua 100.00%
# modules/inventory.lua 87.50%
踩坑:busted 与 Defold 的 Lua 版本(LuaJIT 2.1 / Lua 5.1)可能有语法差异。如果模块里用了
goto、整数除法//等 5.2+ 语法,busted 用 5.4 能跑但引擎会报错。建议用lua5.1或luajit作为 busted 的运行时。
4. 集成测试与无头运行
1. 用 bob 构建 headless 变体
Defold 提供无头(headless)构建:引擎不带图形后端,只跑逻辑与消息系统,适合在 CI 里验证「游戏能启动、脚本不报错、关键流程能走通」。
java -jar bob.jar \
--root . \
--archive \
--platform x86_64-linux \
--variant headless \
--bundle-output build/headless \
resolve build
说明:--variant headless 让引擎跳过渲染初始化,因此可以在没有 GPU 的 CI 容器里运行。
2. 运行时注入测试入口
通过 --config 覆盖启动集合,让引擎跑测试场景而不是主场景:
./build/headless/dmengine \
--config=bootstrap.main_collection=/tests/test_main.collection \
--config=display.width=320 \
--config=display.height=240
3. 测试脚本自动退出
-- tests/test_runner.script
local passed, failed = 0, 0
local function check(name, cond)
if cond then
passed = passed + 1
print("[PASS]", name)
else
failed = failed + 1
print("[FAIL]", name)
end
end
function init(self)
check("world 初始化", world.init() == true)
check("玩家生成", world.spawn_player() ~= nil)
self.frames = 0
end
function update(self, dt)
self.frames = self.frames + 1
world.step(dt)
if self.frames >= 120 then
check("玩家未越界", world.player_in_bounds())
print(string.format("[RESULT] passed=%d failed=%d", passed, failed))
-- 用退出码反馈给 CI
sys.exit(failed == 0 and 0 or 1)
end
end
关键:
sys.exit(code)会把退出码传给 shell,CI 据此判断成败。没有这一步,CI 永远是绿的。另外记得用timeout 120 ./dmengine ...兜底,退出码 124 表示超时。
5. 用 bob.jar 做命令行构建
1. 下载与最简命令
# 官方构建服务(推荐,跟随稳定版)
curl -O https://d.defold.com/stable/bob/bob.jar
java -jar bob.jar \
--root . \
--archive \
--platform x86_64-macos \
--variant release \
--bundle-output build/macos \
resolve build bundle
bob 的三个阶段:
| 阶段 | 作用 |
|---|---|
| resolve | 下载并解析项目依赖(库、扩展) |
| build | 编译资源与代码,产出归档 |
| bundle | 打包成目标平台可执行产物 |
2. 常用参数
| 参数 | 说明 | 示例 |
|---|---|---|
| –root | 项目根目录 | . |
| –archive | 产出归档文件 | 无值 |
| –platform | 目标平台 | x86_64-win32 |
| –variant | debug 或 release | release |
| –bundle-output | 产物输出目录 | build/win |
| –bundle-format | 产物格式 | zip、apk、html5 |
| –settings | 覆盖 game.project | ci.settings |
| –defines | 应用清单 | manifest.txt |
| –build-server | 构建服务器 | 默认官方 |
| –texture-compression | 纹理压缩开关 | 无值 |
3. 用 settings 文件覆盖配置
CI 里常常要改版本号、包名,不必手改 game.project:
# ci.settings
[project]
version = 1.4.2
[bootstrap]
main_collection = /main/main.collection
java -jar bob.jar --settings ci.settings --archive --platform js-web --variant release resolve build bundle
踩坑:bob 会在
build/下缓存已编译资源。如果资源依赖变了但缓存没失效,会出现「改了没生效」。CI 上每次都是干净环境所以没问题,本地调试时记得java -jar bob.jar --root . clean。
6. 多平台构建矩阵
1. 目标平台标识
| 平台 | 标识 | 产物格式 |
|---|---|---|
| macOS Intel | x86_64-macos | zip |
| macOS Apple Silicon | arm64-macos | zip |
| Windows | x86_64-win32 | zip |
| Linux | x86_64-linux | zip |
| Android | armv7-android / arm64-android | apk |
| iOS | arm64-ios | ipa |
| HTML5 | js-web / wasm-web | html5 |
2. CI 矩阵配置
name: Build
on:
push:
tags: ["v*"]
jobs:
build:
strategy:
matrix:
include:
- platform: x86_64-win32
format: zip
- platform: x86_64-linux
format: zip
- platform: js-web
format: html5
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Download bob
run: curl -fO https://d.defold.com/stable/bob/bob.jar
- name: Build
run: |
java -jar bob.jar \
--root . \
--archive \
--platform ${{ matrix.platform }} \
--variant release \
--bundle-format ${{ matrix.format }} \
--bundle-output build/${{ matrix.platform }} \
resolve build bundle
- uses: actions/upload-artifact@v4
with:
name: build-${{ matrix.platform }}
path: build/${{ matrix.platform }}
移动端额外参数:Android 与 iOS 构建需要签名证书与密钥库,不要把密钥写进仓库,用 CI 的 Secrets 注入。
java -jar bob.jar --archive --platform arm64-android --variant release \
--bundle-format apk --bundle-output build/android \
--keystore release.keystore --keystore-pass "$KEYSTORE_PASS" \
--keystore-alias release --keystore-alias-pass "$ALIAS_PASS" \
resolve build bundle
7. 自动化版本号与发布
1. 版本号注入
用 git tag 或 CI 变量生成版本号,写进 settings:
VERSION="${GITHUB_REF_NAME#v}" # v1.4.2 → 1.4.2
cat > ci.settings <<EOF
[project]
version = $VERSION
EOF
语义化版本:MAJOR.MINOR.PATCH
1.4.2
│ │ └── 修复(兼容)
│ └──── 新功能(兼容)
└────── 不兼容变更
预发布:1.5.0-rc.1
2. 发布流水线与产物校验
- name: Package
run: |
cd build/x86_64-win32
zip -r game-${{ github.ref_name }}.zip .
- name: Release
uses: softprops/action-gh-release@v2
with:
files: build/x86_64-win32/game-${{ github.ref_name }}.zip
generate_release_notes: true
发布前至少校验三件事:
1. 包内不含调试符号与测试资源
2. 版本号与 git tag 一致
3. 关键资源存在(如 main.collection、启动脚本)
# 检查产物中的版本号
unzip -p build/game.zip game.project | grep -A1 "\[project\]"
8. 静态检查与代码规范
1. luacheck 与 selene
luarocks install luacheck
luacheck modules/ --globals go msg vmath sys gui factory --no-max-line-length
说明:--globals 声明 Defold 注入的全局,否则 luacheck 会把它们报成未定义变量。selene 速度更快,配置更清晰:
# selene.toml
std = "lua51"
[lints]
undefined_variable = "warn"
shadowing = "warn"
2. 代码规范
| 项目 | 规范 |
|---|---|
| 缩进 | 4 空格,不用 Tab |
| 命名 | 模块与变量 snake_case,常量 UPPER_CASE |
| 行宽 | 不超过 120 字符 |
| 函数长度 | 不超过 50 行 |
| 文件长度 | 不超过 500 行 |
| require 顺序 | 标准库 → 第三方 → 项目内 |
| 禁止 | 全局变量、os.execute、深层嵌套 |
3. 提交前钩子与格式统一
# .git/hooks/pre-commit
#!/bin/sh
set -e
echo "== luacheck =="
luacheck modules/ --globals go msg vmath sys gui factory
echo "== busted =="
busted
echo "== 检查通过 =="
# 用 stylua 统一格式,消除不同编辑器配置带来的 diff 噪音
stylua --indent-type Spaces --indent-width 4 modules/
踩坑:CI 里跑
luacheck要固定版本(如luacheck==1.2.0),否则规则更新会让历史代码突然报错,把发布流程卡死。
9. 速查表
| 需求 | 命令或做法 | 备注 |
|---|---|---|
| 安装测试框架 | luarocks install busted | 建议用 lua5.1/luajit 运行时 |
| 跑单元测试 | busted | 配置放 tests/.busted |
| 覆盖率 | busted --coverage && luacov | 输出各文件覆盖百分比 |
| 命令行构建 | java -jar bob.jar --archive --platform P --variant V resolve build bundle | 三阶段 |
| 覆盖配置 | --settings ci.settings | 改版本号、包名 |
| 无头测试 | --variant headless + dmengine --config=... | 无需 GPU |
| 退出码 | sys.exit(code) | 否则 CI 永远绿 |
| 超时保护 | timeout 120 ./dmengine ... | 退出码 124 表示超时 |
| 静态检查 | luacheck modules/ --globals go msg vmath sys gui factory | 声明引擎全局 |
| 格式统一 | stylua --indent-width 4 | 消除 diff 噪音 |
| 产物校验 | unzip -p pkg.zip game.project | 核对版本号 |
一句话记忆:把不依赖引擎的逻辑抽成纯 Lua 模块用 busted 测;集成测试用 bob 构建 headless 变体并在测试脚本末尾 sys.exit(失败数) 把结果传给 CI;发布用 bob.jar --settings ci.settings resolve build bundle 三阶段命令配合平台矩阵;提交前跑 luacheck + busted 两道关。
相关阅读
延伸阅读
- 原生扩展的构建与依赖解析
- 热更新包的制作与校验
- 测试场景的集合与动态加载
- 从零搭建项目的学习路线
- 游戏开发专题 — 游戏工程化与 CI 实践
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。