下载 App
10/163 浏览案例分享
【案例分享】Lua 脚本编写指南:入口只有 2 个函数,API 有 4 种调用形态,12 组写法对照我全踩过
投稿方向:星火编辑器论坛「案例分享」专区 / 生态贡献度
类型:图文案例
素材来源:《别停下》开发实录 —— engine-docs/lua-scripting-guide.md、api/globals.md、api/index.md,对照项目 scripts/main.lua(979 行)与 templates/scaffold-2d.lua
一句话结论
这套引擎的 Lua 层没有框架,只有 2 个入口函数、14 个全局对象表,和一批 tolua++ 绑定的 C++ 对象。 你写 function Update() 它不会调;你写 eventData.X 拿到的是 nil;你 node.position.x = 10 改不动任何东西。这三件事,一件都不报错。
一、生命周期:能被引擎找到的只有 2 个函数
场景脚本
scripts/main.lua 里,引擎会主动调用的全局函数就 Start() 和 Stop() 两个。
函数:时机 里面放什么
Start():脚本加载后一次 建 Scene、订事件、初始化状态
Stop():脚本卸载 / 退出 注销订阅、nvgDelete 释放 context
function Start()
graphics.windowTitle = "别停下"
vg_ = nvgCreate(1)
assert(vg_, "NanoVG context unavailable")
SubscribeToEvent(vg_, "NanoVGRender", "HandleNanoVGRender")
SubscribeToEvent("Update", "HandleUpdate")
print("[DontStop] ready")
end
function Stop()
if vg_ then nvgDelete(vg_); vg_ = nil end
end
function Update() 不是回调。这个名字你随便写,引擎不会去找它。帧循环得自己订:
SubscribeToEvent("Update", "HandleUpdate")
第二个参数是全局函数名的字符串,不是函数引用。名字打错的话,订阅照样成功,只是永远不触发——一个不报错的哑火。
脚本组件
挂成 LuaScriptInstance 的脚本是另一副样子。没有全局函数,改用 self:
function Start()
self.speed = 5.0
self.body = self.node:GetComponent("RigidBody2D") -- 缓存,别每帧 GetComponent
self:SubscribeToEvent("Update", "HandleUpdate")
end
function Stop()
self:UnsubscribeFromAllEvents()
end
self.node 是组件挂载的那个节点。self:SubscribeToEvent 的订阅跟着组件走,组件销毁时自动清掉;场景脚本的订阅得自己在 Stop() 里收。
订哪个事件,决定你的代码跑在这一帧的哪一段
事件:位置 适合放什么
Update:主循环,场景步进之后 渲染、UI、非物理逻辑
SceneUpdate:同帧物理步进之前 施力、MoveCharacter、载具输入
PhysicsPreStep / PhysicsPostStep:物理步前后 固定步长的玩法逻辑
NanoVGRender:NanoVG 绘制阶段 矢量绘制,nvgBeginFrame 必须在这里
Update 里改物理意图会晚一帧生效,因为场景已经步进完了。要当帧生效就订 SceneUpdate。
二、引擎 API 的 4 种调用形态
形态:长相 例子
全局对象表:名字.属性 / 名字:方法() graphics:GetWidth()
裸全局函数:函数名(参数) SendEvent("BombExploded", data)
tolua++ 对象:对象["字段"]:Get类型() eventData["TimeStep"]:GetFloat()
属性式赋值:对象.属性 = 值 node.position = Vector3(0, 0.5, 0)
全局对象表:14 个
不用声明,直接写名字就能用:
audio cache console context engine fileSystem graphics
input localization log network renderer time ui
常用的长这样:
input.mouseMode = MM_RELATIVE -- 属性
local w, h = graphics:GetWidth(), graphics:GetHeight() -- 方法
local dpr = graphics:GetDPR()
log:Write(LOG_ERROR, "[procedural] " .. tostring(err))
engine:Exit()
同一个对象上属性和方法是混着用的。graphics.windowTitle 是赋值,graphics:GetWidth() 是调用——没有 SetWidth() 这种东西。
裸全局函数
SendEvent / SubscribeToEvent / GetCache / IsServerMode 这些不属于任何表。SendEvent 的第二参可以省:
SendEvent("BombExploded") -- 无 payload
local data = VariantMap()
data["Damage"] = 10
SendEvent("BombExploded", data) -- handler 里 eventData:GetInt("Damage")
scene:SendEvent("BombExploded") -- 以 scene 为 sender
第二参只吃 VariantMap。传一个 Lua table 会报 tolua 类型错误,引擎不会帮你转。
tolua++ 对象:事件负载
eventData 是 VariantMap,取值有等价的两条路:
local dt = eventData["TimeStep"]:GetFloat() -- 经典:先索引出 Variant,再转类型
local dt = eventData:GetFloat("TimeStep") -- 便捷:引擎直接绑定,少一次中间对象
类型后缀就那几个:GetInt / GetFloat / GetBool / GetString / GetPtr("Node")。事件字段定义在 .emmylua/Events.d.lua,写回调前翻一眼比猜快。
几条不会给你商量的限制
限制:具体数字 / 表现
入口函数:2 个:Start() / Stop(),多写不认
全局对象:14 个,见上表,名字拼错就是 nil
事件名:字符串匹配,写错订阅成功但永不触发
回调名:也是字符串,同样静默
帧循环:必须自己订 Update,没有默认循环
SendEvent 第二参:只收 VariantMap,Lua table 直接报类型错
graphics:SetMode():已禁用,改分辨率只能读 GetWidth/GetHeight/GetDPR
Vector2/3:Normalize():已废弃,只能 v = v:Normalized()
Vector2(IntVector2):已废弃,要显式抄 x/y/z
Lua 版本:5.4,goto、位运算、// 都能用
SetMode() 被禁这条最反直觉——你手上明明有个同名方法,调了没反应。屏幕尺寸只能读,不能设,适配靠 dpr 算逻辑分辨率。
三、踩过的坑:12 组错误写法 vs 正确写法
坑 1:eventData 用点语法
-- ❌ tolua++ 对象不支持点访问字段
local dt = eventData.TimeStep
local x = eventData.X:GetInt()
-- ✅
local dt = eventData["TimeStep"]:GetFloat()
local x = eventData:GetInt("X")
点语法拿到 nil,再往下 :GetFloat() 就是 attempt to index a nil value。
坑 2:数组从 0 开始
-- ❌ items[0] 是 nil,循环第一轮就炸
for i = 0, #items - 1 do
print(items[i])
end
-- ✅
for i = 1, #items do
print(items[i])
end
算出来的索引记得兜一下:local i = math.max(1, i)。
坑 3:table.unpack 不在末位
-- ❌ 只展开第一个元素,结果是 {1, "extra"}
local t = { table.unpack(items), "extra" }
-- ✅ 放末位
local t = { "header", table.unpack(items) } -- {"header", 1, 2, 3}
这跟 JS 的 [...arr, x] 不一样,别按那个直觉写。
坑 4:\uXXXX
-- ❌ Lua 5.4 的转义要花括号,报 missing '{' near '"\u2'
local star = "\u2605"
-- ✅
local star = "\u{2605}"
local heart = "\u{2764}"
更省事的做法是直接把字符写进字符串:local star = "★",文件存成 UTF-8。
坑 5:自赋值丢类型
-- ❌ pos 的类型在自赋值里丢了,后面 pos.x 报 undefined-field
local pos = node.position
pos = pos + velocity * dt
node.position = pos
-- ✅ 运算结果落新变量
local newPos = node.position + velocity * dt
node.position = newPos
-- ✅ 或者直接写回属性
node.position = node.position + velocity * dt
i = i - 1、i = i * 2 同样中招:integer 退化成 number,再拿去索引 table<integer, V> 就报错。i = i + 1 不受影响。
坑 6:直接改 node.position.x
-- ❌ node.position 返回的是副本,改完就丢
node.position.x = 10
-- ✅ 取出、改、整体写回
local position = node.position
position.x = 10
node.position = position
Vector2 / Vector3 是 Lua 原生值类型,赋值和传参都复制。跟同族的其他 tolua userdata 不是一套语义,别混着记。
坑 7:资源路径带 assets/ 前缀
-- ❌ scripts/ 和 assets/ 都是资源根,加了前缀找不到
local tex = cache:GetResource("Texture2D", "assets/Textures/player.png")
-- ✅ 从下一级开始写
local tex = cache:GetResource("Texture2D", "Textures/player.png")
坑 8:SetEnabled(false) 藏不住子节点
-- ❌ 子节点上的 StaticModel / AnimatedModel 照渲染
node:SetEnabled(false)
-- ✅ 递归禁用整棵子树
node:SetDeepEnabled(false)
根因是 Component:IsEnabledEffective() 只看直接父节点,不往上递归。
坑 9:局部 Scene 被 GC
-- ❌ audioScene 是 local,函数一返回就没人持有
local function setupAudio()
local audioScene = Scene()
sfx.node = audioScene:CreateChild("SFX")
end
-- ✅ 模块级变量持有
local _audioScene
local function setupAudio()
_audioScene = Scene()
sfx.node = _audioScene:CreateChild("SFX")
end
赋给 connection.scene / viewport.scene 也不算数,那些属性是弱引用,不加 Lua 引用计数。崩溃不在出错那一帧,在几秒到几分钟后引擎内部的某次帧更新里,位置随机。子节点和子组件由父对象强引用,不用单独持有。
坑 10:每帧建字体
-- ❌ 每帧调 nvgCreateFont,显存一路泄漏
function HandleNanoVGRender(eventType, eventData)
nvgCreateFont(vg_, "sans", "Fonts/NotoSansSC.ttf")
-- 绘制……
end
-- ✅ Start() 里建一次,句柄复用
local ok, h = pcall(nvgCreateFont, vg_, "sans", "Fonts/NotoSansSC.ttf")
fontReady_ = ok and type(h) == "number" and h >= 0
nvgFontFace / nvgFontSize 每帧调没问题,建字体不行。
坑 11:FindById 的返回值直接调子类方法
-- ❌ FindById 返回 Widget|nil,LSP 报 undefined-field,运行时也可能 nil
local label = uiRoot_:FindById("scoreLabel")
label:SetText("Score: " .. tostring(score))
-- ✅ 行尾收窄类型 + nil 检查
local label = uiRoot_:FindById("scoreLabel") --[[@as Label?]]
if label then
label:SetText("Score: " .. tostring(score))
end
坑 12:assert 住一个 DWP 媒体资源
-- ❌ 字体是媒体资源,启动瞬间必不在,assert 直接打断启动
assert(nvgCreateFont(vg_, "sans", "Fonts/NotoSansSC.ttf") >= 0)
-- ✅ 不 assert,记下状态,逐帧重试
local ok, h = pcall(nvgCreateFont, vg_, "sans", "Fonts/NotoSansSC.ttf")
fontReady_ = ok and type(h) == "number" and h >= 0
.ttf / .png / .ogg 这类媒体资源,引擎先给占位再后台下载。.lua / .json / .mdl / .shader 这些是 render-blocking,启动前已经就位,可以放心 cache:GetResource()。分界线就在这。
四、一份能直接抄的骨架
-- scripts/main.lua
local FIXED_DT = 1 / 60
local accumulator = 0.0
local vg_ = nil
local fontReady_ = false
local fontRetry_ = 0
-- 固定步长的玩法推进,自己实现
local function Step(dt)
-- 移动、碰撞、计分……
end
function Start()
graphics.windowTitle = "别停下"
vg_ = nvgCreate(1)
assert(vg_, "NanoVG context unavailable")
-- .ttf 属于 DWP 媒体资源,启动瞬间拿不到是正常的,别 assert
local ok, h = pcall(nvgCreateFont, vg_, "sans", "Fonts/NotoSansSC.ttf")
fontReady_ = ok and type(h) == "number" and h >= 0
input.mouseMode = MM_ABSOLUTE
SubscribeToEvent(vg_, "NanoVGRender", "HandleNanoVGRender")
SubscribeToEvent("Update", "HandleUpdate")
SubscribeToEvent("KeyDown", "HandleKeyDown")
end
function Stop()
if vg_ then nvgDelete(vg_); vg_ = nil end
end
---@param eventType string
---@param eventData UpdateEventData
function HandleUpdate(eventType, eventData)
local dt = eventData:GetFloat("TimeStep")
-- 字体未就绪:每 30 帧重试一次,下载完自然会成功
if not fontReady_ then
fontRetry_ = fontRetry_ + 1
if fontRetry_ % 30 == 0 then
local ok, h = pcall(nvgCreateFont, vg_, "sans", "Fonts/NotoSansSC.ttf")
if ok and type(h) == "number" and h >= 0 then fontReady_ = true end
end
end
-- 切后台回来 dt 会很大,先清累加器,否则一帧补算几十步
if dt > 0.25 then accumulator = 0; return end
accumulator = accumulator + dt
local steps = 0
while accumulator >= FIXED_DT and steps < 8 do
Step(FIXED_DT)
accumulator = accumulator - FIXED_DT
steps = steps + 1
end
if steps >= 8 then accumulator = 0 end -- 追不上就丢掉,别让它越积越多
end
FIXED_DT 配累加器这套在《别停下》里跑的是 60 Hz 固定步,steps < 8 是上限——真机卡一下之后,玩家看到的是"慢一帧",不是"瞬移一段"。
适用边界
这套写法覆盖的: 用 scripts/main.lua 起手的场景脚本,以及挂在节点上的 LuaScriptInstance 脚本组件;Lua 5.4 语法(含位运算符 & | ~ << >>);基于 Urho3D 1.8 的那套核心 API。
不适用或要另外处理的:
情况:原因
用 urhox-libs/UI 起手的项目:UI.Init({autoEvents = true}) 已经代管 input / update / render,不用自己订 NanoVGRender
纯逻辑的离线批处理脚本:入口仍是 Start(),但收尾必须 engine:Exit(),不调就卡住主循环
服务端脚本:.mdl 在服务端也被算进 render-blocking,DWP 媒体资源在服务端只占位不下载
大量 GetComponent 的帧内调用:属性式访问不便宜,该缓存的还是得在 Start() 里缓存
坑 5 和坑 8 是静态分析器和 GC 的行为,不是引擎运行时契约。升级工具链之后值得回头复测一遍——其中一部分已经在修复队列里。
可迁移的经验
1. 先确定"谁在调我",再写逻辑。 引擎只认 2 个全局函数名和字符串形式的回调名。名字错了不报错,这类哑火靠读代码是查不出来的,只能靠"这里应该有输出但没有"的直觉去定位。
2. 同一份 API 面上混着多种调用形态,是绑定层的常态。 属性、方法、GetXxx("字段")、对象["字段"]:GetXxx() 四套并存。写之前先确认手上这个是哪种,比写完再调试便宜得多。
3. 不报错的错误最贵。 点语法拿 nil、数组从 0 起、自赋值丢类型——三个都不抛异常,只是行为不对。开发期把 print 打在关键入口和出口上,比事后加日志省事。
4. 生命周期归属决定你写不写 Stop()。 组件形态的订阅跟着组件走;场景脚本的订阅得自己收。同一份代码从一种形态挪到另一种,漏收订阅的问题会跟着一起挪过去。
(完)
星火编辑器-新手教程-关于多游戏模式的配置和触发的获取截图
星火编辑器-新手教程-关于多游戏模式的配置和触发的获取
HELLO 大家好,我是阿木木牧。最近第一版的内部小范围(几人)第一个图的测试已经结束了。 整体游戏以装备数值的崩坏而告终~ (虽然大家都很欢快。。。) 现在准备开始开发下个阶段的内容啦,就是将游戏模式拆分出来。分成单人模式,多人组队模式(合作PVE),多人匹配模式(对抗模式)。那么,怎么配置呢?说来也很简单哈~ 1,在游戏编辑器中,数据编辑器,找到地图设置。 2,编辑地图设置。比如我希望最大数量
14 赞
3 回复
不支持当前设备截图
不支持当前设备
荣耀400pro
2 赞
3 回复
00:18
修复一下这个挂起过。有概率用不了的bug吧截图
修复一下这个挂起过。有概率用不了的bug吧
太绝望了[表情_豹哭][表情_豹哭][表情_豹哭][表情_豹哭]
1 赞
1 回复
05:19
1截图
1
1.完成后,获得信用点的增益依旧有效,可以卡无限信用点。理论上 2.部分天赋和在一起可以实现永续,那些天赋,我下一个发,因为只能传一个视频。 3.卡,人多点就卡,知道卡死,但是手机不会卡就游戏卡
2 赞
无须申请,星火编辑器现已公测开放下载截图
无须申请,星火编辑器现已公测开放下载
—————————————————— 各位关注星火编辑器的小伙伴: 感谢大家一直以来对编辑器支持! 破晓已去,曙光降临! 星火编辑器现已开启公测! · 关于下载和资格申请 现已无需申请,请去下面的官方首页直接下载! 缘起测试是星火编辑器自诞生以来第一次对外测试! 从2022年八月开始到十一月,历时三个月。 缘起之后缘聚测试,历时又七个月! 缘聚之后,破晓降临,历时又十二个月! 曾经微弱的星火现已渐
官方
331 赞
375 回复
00:11
人物有小概率会卡着不往前走截图
人物有小概率会卡着不往前走
3 赞
1 回复