下载 App
10/16案例分享
【案例分享】用引擎当命令行工具:程序化/离线 Lua 跑批处理
一句话结论
引擎不只能跑游戏,还能当命令行工具用 —— 起一个脚本,算完东西、写盘、退出。
批量改 Prefab 字段、程序化生成贴图、预生成关卡数据,这些活不用手点编辑器,写段 Lua 跑一次就完事。但有三个不变量必须遵守,否则脚本会卡死到超时。
它和游戏脚本的根本区别
入口:游戏脚本是 Start() 起、主循环里持续 Update;程序化脚本是 Start() 起、做完一次性工作就 engine:Exit()。
渲染:游戏脚本真渲染看效果;程序化脚本不产出渲染像素,只做计算和落盘。
落盘:游戏脚本用相对路径(写到游戏隔离 savedata);程序化脚本用绝对路径(写到 /workspace/assets/ 直接交付项目)。
失败处理:游戏脚本弹错、recover、继续跑;程序化脚本立即 fail-loud(写 log、非零退出码)。
最后一行是关键差别:批处理脚本没有「重试」的概念,错就是错,必须让人看见。
脚本模板(必须遵守)
function Start()
local ok, err = pcall(function()
-- 你的工作写在这里
end)
if not ok then
log:Write(LOG_ERROR, "[procedural] " .. tostring(err))
end
engine:Exit() -- 必须!不调就卡主循环,命中调用方超时
end
三个不变量:
1. pcall 包住业务逻辑 —— 别让 Lua 报错把整个进程崩掉、把日志方向带偏
2. 失败用 log:Write(LOG_ERROR, ...) 记 —— 上游能从 stdout 读到准确错因
3. engine:Exit() 必须在 pcall 之外 —— 无论成功失败都要退
第 3 条最容易写错。写成 pcall 里面、或者只在成功分支调用,结果就是失败时脚本不退出、卡在主循环里、直到超时。那时候你看到的报错是「超时」,而不是真正的错因,排查方向直接跑偏。
能做什么、不能做什么
这个模式不产出渲染像素。判断口诀:
接口名带 GPU / Render / Shader / Texture upload 的多半出不来结果;带 Image / Scene / File / Resource 的多半能用。
能做的:
Image:SetPixel / SetSize / Resize / SavePNG / SaveJPG / SaveTGA / Clear 等 CPU 像素操作
Scene:LoadXML / SaveXML、Node:CreateChild、组件序列化
Lua 全套,require 项目的 urhox-libs/
cache:GetFile / cache:GetResource 读项目 assets
RigidBody / CollisionShape 物理模拟 step
NavigationMesh:Build()
FreeType 字体光栅到 Image
不能做的:
任何需要看到屏幕渲染结果的能力
RenderToTexture、shader 后处理、compute、skybox 卷积
UI 真正渲染像素(创建 UIElement 不报错,但看不到结果)
Audio 播放
任何对 Renderer 输出做截图
注意倒数第二行:物理模拟 step 可以跑(CPU 计算),但不能截图看结果 —— 所以物理预解算能算数据、不能出画面。
实例一:程序化噪声贴图
function Start()
local ok, err = pcall(function()
local img = Image()
local W, H = 256, 256
img:SetSize(W, H, 4)
math.randomseed(42) -- 固定种子,保证可复现
for y = 0, H - 1 do
for x = 0, W - 1 do
local n = (math.sin(x * 0.05) + math.cos(y * 0.05)) * 0.5 + 0.5
img:SetPixel(x, y, Color(n, n * 0.6, 1.0 - n, 1.0))
end
end
assert(img:SavePNG("/workspace/assets/Textures/noise.png"), "SavePNG failed")
print("[procedural] wrote /workspace/assets/Textures/noise.png")
end)
if not ok then log:Write(LOG_ERROR, "[procedural] " .. tostring(err)) end
engine:Exit()
end
两个细节值得注意:
math.randomseed(42) —— 程序化生成一定要固定种子,否则每次跑出来的贴图都不一样,没法进版本管理
assert(img:SavePNG(...), "SavePNG failed") —— 写盘失败要显式抛错,别让脚本「跑完了但文件没生成」
实例二:批量迁移 Prefab 字段
function Start()
local ok, err = pcall(function()
local files = cache:GetFileSystem():ScanDir(
"/workspace/assets/Prefabs/", "*.xml", SCAN_FILES, false)
for _, name in ipairs(files) do
local fullPath = "/workspace/assets/Prefabs/" .. name
local scene = Scene()
scene:LoadXML(File(scene:GetContext(), fullPath, FILE_READ))
-- 在这里改字段,例如把所有旧 "Speed" 属性 *= 2
for _, node in ipairs(scene:GetChildren(true)) do
local body = node:GetComponent("RigidBody")
if body then body:SetMass(body:GetMass() * 2) end
end
assert(scene:SaveXML(File(scene:GetContext(), fullPath, FILE_WRITE)))
print("[procedural] migrated " .. name)
end
end)
if not ok then log:Write(LOG_ERROR, "[procedural] " .. tostring(err)) end
engine:Exit()
end
这是批处理最实用的形态:几十上百个 Prefab,手点编辑器要一下午,脚本跑一遍几秒钟。而且可复现 —— 同样的脚本再跑一次结果一致。
改字段前先把原文件备份一份。这类脚本没有「撤销」。
实例三:跑项目自己的业务逻辑
local LevelGen = require("urhox-libs/MyProject/LevelGen")
function Start()
local ok, err = pcall(function()
local levels = LevelGen.generate(20, { seed = 12345, difficulty = "hard" })
local f = File(GetContext(), "/workspace/assets/Data/levels.json", FILE_WRITE)
f:WriteString(cjson.encode(levels))
f:Close()
end)
if not ok then log:Write(LOG_ERROR, tostring(err)) end
engine:Exit()
end
关键点:离线脚本能 require 项目自己的模块。这意味着关卡生成器不用写成两套 —— 游戏里怎么生成,离线就怎么生成。
落盘约定
正式产物:/workspace/assets/...(项目正常资源位置,游戏脚本能 cache:GetResource() 直接用)
临时 / 调试:/tmp/... 或 /workspace/.tmp/...
不要写:/workspace/scripts/(脚本目录混入数据文件会污染)、系统目录、引擎目录
日志与错误诊断(最容易踩的坑)
没有文件日志,全部走 stdout。默认日志级别 = WARNING,INFO 级被过滤。
print(...) —— 总是出现(走 LOG_RAW,不受 level 过滤)
log:Write(LOG_WARNING, ...) / log:Write(LOG_ERROR, ...) —— 出现
log:Write(LOG_INFO, ...) —— 被默认级别吞掉
引擎自身的 WARNING / ERROR —— 出现
引擎自身的 INFO(init chatter)—— 被吞掉(这正是我们要的,日志干净)
两条实战结论:
1. 做进度提示用 print,不要用 log:Write(LOG_INFO, ...) —— 后者你看不见
2. Lua 抛 error 但被 pcall 接住 → err 字符串完整、行号准确,一定要 log:Write(LOG_ERROR, ...) 打出来,否则 stdout 上什么都看不到
还有一种情况:没被 pcall 接住 → 进程未必崩,但 Start() 提前结束、engine:Exit() 不会被调,最后表现为超时。
所以「脚本超时」这个现象,根因可能是业务逻辑报错,而不是「算得太慢」。看到超时先查有没有漏掉 pcall 或 Exit。
可迁移的经验
1. 一次性任务要有明确的结束信号(这里是 engine:Exit())。没有结束信号的批处理,失败时的表现是「卡住」,会掩盖真正的错因。
2. 固定随机种子。任何程序化生成,只要产物要进版本管理,就必须可复现。
3. 写盘要 assert。「跑完了但文件没生成」是最坑的失败模式 —— 它看起来成功了。
4. 批处理前先备份。没有撤销。
5. 日志级别要确认。默认被过滤掉的级别,写再多也等于没写 —— 先用 print 确认链路通了,再换正式日志接口。