Defold 的游戏逻辑全部由 Lua 脚本驱动。与「直接持有对象引用」的引擎不同,Defold 采用 消息传递(Message Passing) 模型:脚本之间、脚本与引擎系统之间通过 msg.post() 收发消息,这带来极强的解耦能力,也决定了它的代码组织方式。(Defold messaging docs)
本文是 Lua 脚本系统的深度篇,假设读者已了解 Defold 游戏引擎介绍 中的 Game Object/Collection/Component 模型。若你是新手,也可以先阅读 Lua 语言专题 打牢语言基础,再回到本文。
一、消息驱动模型:msg.post 的工作原理
1. URL 地址体系
Defold 中每个可通信对象都对应一个 URL,结构为 socket:path#fragment:
- socket:定位到某个 Collection 实例(命名空间),
#开头表示「当前 socket」 - path:Game Object 的 id(
#开头表示「当前 Game Object」) - fragment:Component 的 id(
#开头表示「当前组件自身」)
常用写法:
-- 给同对象上的 sprite 组件发消息
msg.post("#sprite", "play_animation", { id = hash("run") })
-- 给场景中名为 player 的对象发消息
msg.post("player", "activate")
-- 给另一集合实例 enemy_1 中的对象发消息
msg.post("enemy_1:/boss#script", "enrage", { factor = 2.0 })
URL 也可由 msg.url() 构造并在运行时保存,便于传递或缓存:
self.player_script = msg.url("player#script")
...
msg.post(self.player_script, "take_damage", { amount = 10 })
2. msg.post 是异步的
msg.post() 不会立即调用接收方的回调,而是把消息放入目标的消息队列,在本帧(或下一帧)由接收脚本的 on_message 统一处理。这意味着:
- 发送方不依赖接收方是否存在——发到不存在的地址会被静默丢弃或产生警告
- 消息在帧内「批量处理」,适合做事件解耦
- 严格时序需求(先 A 后 B)需要自己维护队列,参见 复杂逻辑与状态管理
3. 引擎内置消息
msg.post() 常用来调用引擎内置系统功能,这些消息行为是「组件级别」的:
-- 控制动画、物理与音效
msg.post("#sprite", "play_animation", { id = hash("jump"), playback = go.PLAYBACK_ONCE_FROM_START })
msg.post("#collisionobject", "apply_force", { force = vmath.vector3(0, 500, 0) })
msg.post("#sound", "play_sound", { gain = 0.8 })
-- 控制渲染与相机
msg.post("@render:", "set_clear_color", { color = vmath.vector4(0.1, 0.1, 0.2, 1) })
特殊 socket @render:、@system:、@input: 分别对应渲染、系统与输入接口。
二、组件生命周期
1. 脚本回调函数
一个 .script 文件通过以下回调与引擎交互:
init(self):对象创建后调用一次,用于初始化状态final(self):对象销毁前调用,清理资源update(self, dt):每帧调用,dt为帧间隔秒数on_message(self, message_id, message, sender):收到消息时调用on_input(self, action_id, action):接收输入时调用on_reload(self):编辑器热重载脚本时调用
function init(self)
self.hp = 100
self.speed = 200
end
function update(self, dt)
local pos = go.get_position()
pos.x = pos.x + self.speed * dt
go.set_position(pos)
end
function final(self)
print("player destroyed")
end
2. 生命周期时序
在同一帧中,各回调执行顺序有约定:先 update 后处理消息。更准确地说,引擎按组件注册顺序依次:先执行本帧消息队列(on_message),再执行 update。因此「在 update 里改状态、期望同帧消息生效」是常见误解。
对象销毁(go.delete())是延迟的:调用后对象在当前帧结束才真正移除,期间仍可收到消息,但 final 会在销毁时执行。
3. self 表与会话状态
每个脚本实例的 self 表是独立且持续的,跨帧保存状态。注意:
self不是全局共享,不同对象上的同一脚本各自持有一份- 大量状态放
self会影响 GC,高频临时数据尽量用局部变量 - 需要全局共享数据时,用 Lua 模块或专门的「服务对象」脚本
-- 用模块保存全局配置
local config = require "main.config" -- config.lua
config.difficulty = 2
三、工厂与 Collection 实例化
1. Factory 组件:动态创建单个对象
Factory 组件指向一个 .go 文件,运行时用 factory.create() 生成实例:
function fire(self)
local pos = go.get_position("muzzle")
local id = factory.create("/main/bullet/bullet.factory", pos)
-- 记录生成的 id,用于后续操作
self.last_bullet = id
end
factory.create() 还支持传入缩放与属性表:
local id = factory.create(url, pos, rotation, scale, { hp = 50, damage = 10 })
传入的属性表会以消息形式被实例脚本的 on_message 接收。
2. CollectionFactory:批量实例化复杂结构
当要生成的不是单个对象而是整套关卡结构(敌人 + 掉落 + 触发器)时,用 CollectionFactory:
function spawn_wave(self)
local id = collectionfactory.create("/main/waves/wave1.collectionc", vmath.vector3(100, 200, 0))
self.wave_id = id
end
生成的集合拥有独立 socket 命名空间,内部对象通过 wave_id:id#script 访问。
3. 实例化后的寻址
factory.create 返回的是 Game Object 的 id(hash)。要给它发消息:
local id = factory.create(url)
local obj_url = msg.url(nil, id, nil) -- socket 缺省 = 当前集合
msg.post(obj_url, "activate")
更常用的是对集合实例的 socket 寻址:
local wave_url = msg.url("wave_" .. tostring(id))
msg.post(wave_url, "play_intro")
4. 工厂的卸载与回收
动态创建的对象不会自动销毁,必须在 final 或事件中显式 go.delete(id)。频繁创建/销毁会引发 GC 抖动,推荐使用对象池复用——完整实现见 复杂逻辑与状态管理。
四、脚本间通信模式
1. 点对点:直接寻址
最直接的通信方式:接收方注册 on_message,发送方用精确 URL 发送。
-- 玩家脚本发送
msg.post("player#script", "take_damage", { amount = 20 })
-- 玩家脚本接收
function on_message(self, message_id, message, sender)
if message_id == hash("take_damage") then
self.hp = self.hp - message.amount
if self.hp <= 0 then die(self) end
end
end
适用场景:发送方明确知道目标是谁(如武器打到玩家)。
2. 广播:中央分发器
当消息接收方不确定、数量可变(UI、多个敌人、全局事件)时,引入中央「分发器」对象:
-- dispatcher.script
local subscribers = {}
function on_message(self, message_id, message, sender)
if message_id == hash("subscribe") then
subscribers[message.channel] = subscribers[message.channel] or {}
table.insert(subscribers[message.channel], sender)
elseif message_id == hash("broadcast") then
local list = subscribers[message.channel]
if list then
for _, url in ipairs(list) do
msg.post(url, message.event, message.payload)
end
end
end
end
发送方只需 msg.post("/dispatcher", "broadcast", {...}),订阅者动态加入,符合「发布-订阅」模式,也便于实现暂停、结算等全局事件。
3. 模块直调:纯 Lua 共享
对于不需要引擎消息语义的「数据与算法」,直接用 Lua 模块函数调用:
-- math_utils.lua
local M = {}
function M.clamp(x, min, max)
return math.max(min, math.min(max, x))
end
function M.lerp(a, b, t)
return a + (b - a) * t
end
return M
local math_utils = require "main.math_utils"
local clamped = math_utils.clamp(110, 0, 100)
模块方式无异步、无消息开销,适合数值工具、配置表、纯算法;但不适合「对象间触发行为」,因为模块不感知引擎对象生命周期。
4. 事件总线 vs 直接引用的取舍
| 场景 | 推荐 | 原因 |
|---|---|---|
| 确定目标、实时性要求高 | 点对点 msg.post | 清晰、低开销 |
| 一对多、订阅者可增删 | 广播/分发器 | 解耦、易扩展 |
| 纯算法、配置共享 | Lua 模块 | 无消息开销 |
| 跨系统全局事件(暂停/结算) | 广播 + 中央状态 | 避免层层转发 |
五、Lua 模块与代码组织
1. require 与路径约定
Defold 中 require "module.name" 把路径点(.)转为斜杠(/),并从工程根解析。约定:
- 模块文件放
main/下,命名小写下划线 - 模块返回表,表内是函数与常量
- 避免顶层副作用(模块加载时执行打印等),保持纯净
2. 工程代码结构建议
main/
├── modules/
│ ├── math_utils.lua
│ ├── event_bus.lua
│ └── config.lua
├── entities/
│ ├── player/
│ │ ├── player.go
│ │ ├── player.script
│ │ └── player_states.lua
│ └── enemy/
│ └── enemy.collection
├── systems/
│ ├── spawn_system.script
│ └── score_system.script
└── main.collection
模块层不依赖引擎 API,可独立单测;实体层按对象隔离;系统层负责跨对象协调。依赖方向保持单向:系统 → 实体 → 模块。
3. 避免常见坑
require的模块是全工程单例,模块内的状态是「全局」的,多对象共享时小心污染- 消息 id 用
hash()比较,hash("name") == hash("name")恒真,可直接比较 - Lua 的
table是引用语义,把表塞进消息时注意避免循环引用导致序列化异常
六、性能与调试
1. 消息与 GC
- 每帧大量
msg.post会创建临时表,移动端低端机尤其敏感;合并消息(一次携带多个字段)优于多次小消息 update中避免创建表;需要临时表时用缓存变量复用- 用
collectgarbage("count")监控 Lua 堆大小,长期稳定为佳
2. 性能剖析
编辑器内置 Profiler(Tools → Profiler)可观察:
- Lua 内存:脚本与表占用的堆
- Message:每秒消息量与队列积压
- Draw Call:渲染批次,过高时回图集优化(见 编辑器与资源管线)
3. 调试技巧
print()输出到 Console,pprint()(需扩展)可打印嵌套表- 脚本热重载(
Cmd/Ctrl + R)会调用on_reload,可在此刷新状态,迭代极快(详见 热更新与热重载) - 对不存在的 URL 发消息会在 Console 输出警告,善用日志定位寻址错误
七、典型脚本骨架
一个完整可用的玩家脚本骨架,整合了生命周期、输入与消息:
-- player.script
local vmath = require "vmath"
function init(self)
self.hp = 100
self.speed = 300
msg.post(".", "acquire_input") -- 请求输入
end
function update(self, dt)
local pos = go.get_position()
pos.x = pos.x + self.move_x * self.speed * dt
go.set_position(pos)
end
function on_input(self, action_id, action)
if action_id == hash("left") then
self.move_x = action.value or -1
elseif action_id == hash("right") then
self.move_x = action.value or 1
end
end
function on_message(self, message_id, message, sender)
if message_id == hash("take_damage") then
self.hp = self.hp - message.amount
if self.hp <= 0 then
msg.post("#", "play_animation", { id = hash("dead") })
go.delete()
end
end
end
八、总结
Defold 的脚本系统的核心是三条主线:消息驱动让对象之间只传事件不传引用;生命周期回调规范了状态的建立与清理;工厂与命名空间把动态创建和多实例隔离组织得井井有条。在此基础上,点对点、广播、模块三种通信方式分别服务不同耦合需求。
掌握了脚本系统,就可以顺畅衔接物理交互(Defold 物理引擎)与 UI 开发(Defold GUI/UI 开发),组合出完整的游戏玩法。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。