引言
游戏里最容易被写烂的东西不是渲染,也不是物理,而是「对象之间怎么说话」。新手常把一个全局单例塞满所有状态,player.hp 被 UI、敌人 AI、存档系统同时读写,改一处炸一片。Defold 给的答案很克制:一切跨对象通信都走消息——msg.post 发、on_message 收、URL 寻址。这套机制看起来只是「函数调用的另一种写法」,但它的真正价值在于强制解耦:发送方不需要知道接收方是谁、是否存在、在哪个集合里。
本文把 Defold 消息传递讲成一个可落地的架构:从 msg.post 与 URL 寻址讲起,覆盖 on_message 的回调与分发顺序,接着是父子层级、集合(Collection)与代理(Proxy)中的消息穿透,然后是事件总线(Event Bus)与集中式分发两种解耦模式,再对比消息与直接调用的性能取舍,最后给出请求-响应与一次性订阅的实战模板。
前置:脚本生命周期与 msg 、ECS 与组件解耦 。消息队列的通用理论见 消息队列设计 、Actor 模型详解 。
1. 消息传递的核心:msg.post 与 URL 寻址
Defold 里没有「对象引用」,只有 URL。每个游戏对象(Game Object)、组件、集合都有一个地址(URL),消息就是「寄到这个地址的信」。
URL 结构: [socket:]<path>[#fragment]
socket 集合代理(Proxy)的 socket 名,跨集合时用
path 游戏对象 id,"." 表示「自己」
fragment 组件 id,如 "#sprite"、"#script"
四种常用寻址写法:
| 写法 | 含义 | 场景 |
|---|---|---|
"." | 当前游戏对象 | 给自己发(延迟一帧执行) |
"#sprite" | 当前对象上的 sprite 组件 | 控制自身组件 |
"/enemy" | 同一集合里的 enemy 对象 | 同集合通信 |
"main:/ui#gui" | 代理 main 里的 ui 对象 | 跨集合通信 |
发送与接收的最小闭环:
-- 发送方:给玩家对象发一个「受伤」消息
msg.post("/player", "apply_damage", { amount = 25, source = "trap" })
-- 接收方(player.script):在 on_message 里消费
function on_message(self, message_id, message, sender)
if message_id == hash("apply_damage") then
self.hp = self.hp - message.amount
-- sender 是发送方的 URL,可用于回信
msg.post(sender, "damage_applied", { hp = self.hp })
end
end
三个必须记住的事实:
1. message_id 是 hash(整数),比较用 == 而非字符串
2. 消息体(message)是 Lua table,按值传递引用,不要塞大数组
3. sender 参数是 URL,请求-响应模式靠它回信
消息体的设计纪律:消息体是「值语义」的事件描述,不是「引用语义」的共享状态:
-- 好:只带事件必需的数据,接收方自行决定怎么用
msg.post("/player", "apply_damage", { amount = 25, source = "trap" })
-- 坏:把整个对象状态塞进去,接收方和发送方再次耦合
msg.post("/ui", "refresh", { player = self.player_obj, world = self.world })
消息体三条纪律:
✓ 只放「标量 + 小表」:数字、字符串、hash、短数组
✗ 不放 userdata(如 go.get 拿到的 vector 可以,但别放引用)
✗ 不放「以后会变」的对象引用——那是共享状态,不是消息
心智:URL 是地址、msg.post 是信封、on_message 是收件箱——发送方只认地址,不认对象;消息体只装「事件」,不装「状态」。
2. on_message 与消息分发机制
消息不是「立刻执行」的——msg.post 把消息推进队列,引擎在当前帧的脚本更新循环结束后统一派发。这带来两个直接后果:
① 同帧内多次 post 同一消息 → 全部按序派发(不是覆盖)
② 给自己 post(".")→ 下一帧才收到,可用来「延迟一帧」解循环依赖
分发顺序:
帧内顺序:
1. 所有脚本的 update(self, dt) 执行
2. 收集本帧产生的消息
3. 按「post 顺序」逐条派发到 on_message
常用内置消息(由引擎发出,你不必手动 post):
| message_id | 何时收到 | 典型用途 |
|---|---|---|
init | 脚本初始化 | 取组件引用、加载数据 |
final | 对象销毁前 | 清理、保存状态 |
enable / disable | 组件启停 | 暂停逻辑 |
update | 每帧 | 同 update 回调 |
animation_done | 动画播完 | 状态机回稳态 |
collision_response | 物理碰撞 | 碰撞处理 |
function on_message(self, message_id, message, sender)
-- 用 if/elseif 链分发;消息多时改用 table 映射
if message_id == hash("enable") then
self.active = true
elseif message_id == hash("disable") then
self.active = false
elseif message_id == hash("animation_done") then
self:on_anim_done(message.animation_id)
end
end
消息太多时的表驱动分发:
-- 把 handler 存进 table,避免超长 if 链
local handlers = {}
handlers[hash("enable")] = function(self, msg) self.active = true end
handlers[hash("disable")] = function(self, msg) self.active = false end
handlers[hash("respawn")] = function(self, msg) self:respawn(msg.pos) end
function on_message(self, message_id, message, sender)
local h = handlers[message_id]
if h then h(self, message) end
end
心智:消息走队列、帧末派发、按 post 顺序——「给自己 post」是延迟一帧的标准手法,表驱动分发治超长 if 链。
3. 父子层级与集合代理
Defold 的层级是「集合嵌套集合」,消息可以沿层级向上/向下穿透:
main.collection
├── player.go
├── level.collection ← 子集合
│ ├── enemy.go
│ └── tilemap.go
└── ui.collection(作为 Proxy 加载)
向上:消息冒泡到父级。子对象的 URL 若只写相对路径,找不到时引擎会沿父级查找:
-- 在 enemy.script 里,向「同集合的 player」发消息
msg.post("/player", "spotted") -- 先找本集合,找不到再向上冒泡
跨集合:必须用 Proxy(代理)。把 ui.collection 以 Proxy 组件加载进主集合,才能用 socket 寻址:
-- 主集合里的 loader.script 加载 UI 代理
msg.post("#ui_proxy", "load") -- 异步加载 ui.collection
-- 加载完成后,跨集合给 UI 发消息
msg.post("ui:/hud#gui", "set_hp", { hp = 80 })
-- socket ↑ ↑对象 ↑组件
Proxy 的三个关键点:
1. 跨集合通信必须经 Proxy,直接写 "/ui/hud" 是错的
2. load 是异步的:加载完成前 post 会被丢弃,需等 proxy_loaded 消息
3. 卸载用 msg.post("#ui_proxy", "unload"),卸载后地址失效
-- 等代理加载完成再通信
function on_message(self, message_id, message, sender)
if message_id == hash("proxy_loaded") then
msg.post(sender, "enable") -- 启用代理
msg.post("ui:/hud#gui", "set_hp", { hp = 100 })
end
end
Proxy 生命周期的完整时序:
load → proxy_loaded → enable → [正常通信] → disable → unload → proxy_unloaded
↑ 此后地址才可用 ↑ 期间消息可达
常见坑:
1. 在 init 里直接 post 跨集合消息 → 代理还没加载,消息被静默丢弃
2. 卸载代理后继续 post → 地址失效,同样是静默丢弃
3. 同一集合被多个 Proxy 引用 → 每个实例独立,socket 名不能重
心智:同集合用相对路径、跨集合必须过 Proxy;Proxy 加载是异步的,先收 proxy_loaded 再发消息——「静默丢弃」是消息模型最容易踩的坑。
4. 解耦模式:事件总线与集中分发
直接 post 到具体对象,发送方仍要知道「谁该收」。真正解耦要引入中间层,两种主流模式:
4.1 事件总线(Event Bus)
一个「总线对象」订阅所有事件,其他对象只往总线发,谁关心谁去总线订阅:
-- bus.script:全局事件总线
function init(self)
self.listeners = {} -- event_id -> { url1, url2, ... }
end
function on_message(self, message_id, message, sender)
if message_id == hash("subscribe") then
local list = self.listeners[message.topic] or {}
table.insert(list, sender)
self.listeners[message.topic] = list
elseif message_id == hash("publish") then
local list = self.listeners[message.topic]
if list then
for _, url in ipairs(list) do
msg.post(url, message.topic, message.payload)
end
end
end
end
-- 订阅方:只发 subscribe,不知道谁在发
msg.post("/bus", "subscribe", { topic = hash("player_died") })
-- 发布方:只发 publish,不知道谁在收
msg.post("/bus", "publish", { topic = hash("player_died"), payload = { id = 1 } })
4.2 集中式分发(单入口路由)
一个「游戏管理器」持有所有系统引用,接收统一事件后按游戏状态转发:
-- manager.script:集中路由
function on_message(self, message_id, message, sender)
if message_id == hash("game_event") then
if message.type == "level_complete" then
self:on_level_complete(message.level)
elseif message.type == "player_died" then
self:on_player_died()
end
end
end
两种模式取舍:
| 维度 | 事件总线 | 集中式分发 |
|---|---|---|
| 解耦度 | 高(收发互不知) | 中(都认识管理器) |
| 调试难度 | 较难(链路隐式) | 易(单点断点) |
| 适合 | 系统多、事件杂 | 状态机清晰的中小项目 |
| 风险 | 订阅泄漏、循环触发 | 管理器变上帝对象 |
心智:总线管「谁关心就订阅」,管理器管「按状态转发」——解耦度与可调试性此消彼长,按项目规模选。
5. 消息 vs 直接调用:性能与取舍
消息不是免费的。每次 post 涉及 URL 解析、队列插入、派发查找:
msg.post 的隐含成本:
1. URL 字符串 → 哈希查找
2. 消息入队(分配)
3. 帧末派发时按 URL 定位脚本
性能量级参考(同一帧内大量通信时才有意义):
| 通信方式 | 相对开销 | 适用频率 |
|---|---|---|
| 直接函数调用(同一脚本内) | 1x | 每帧每对象 |
组件属性 go.set / go.get | ~2x | 每帧少量 |
msg.post 跨对象 | 事件驱动、非每帧 | |
| 跨集合 Proxy 消息 | 更高 | 低频(UI 更新等) |
决策规则:
用消息: 跨对象、跨集合、事件通知、需要解耦、生命周期不确定
用直调: 同一脚本内部、每帧热路径(如更新 1000 个粒子的位置)
用 go.set:同对象组件属性同步(位置/缩放/染色)
-- 反例:每帧给 500 个子弹对象各 post 一次「更新位置」
-- 正例:子弹自己在 update 里读共享数据,只在「命中」时 post 一次事件
function update(self, dt)
self.pos = self.pos + self.vel * dt
go.set_position(self.pos) -- 直接设置,不走消息
-- 只有命中这种「离散事件」才 post
end
心智:连续状态用直调/go.set,离散事件用消息——把消息留给「事件」,别把它当每帧的遥控器。
6. 实战:请求-响应与一次性订阅
请求-响应(Request-Response):发送方需要回执时,用 sender 回信并携带一个「请求 id」防止串台:
-- 请求方
self.req_id = (self.req_id or 0) + 1
msg.post("/inventory", "query_item", { id = "potion", req_id = self.req_id })
function on_message(self, message_id, message, sender)
if message_id == hash("query_result") then
if message.req_id == self.req_id then -- 只认最新请求
self.potion_count = message.count
end
end
end
一次性订阅(One-shot Listener):订阅后收到第一条就取消,避免重复触发:
msg.post("/bus", "subscribe_once", { topic = hash("level_loaded") })
function on_message(self, message_id, message, sender)
if message_id == hash("level_loaded") then
-- 处理一次后不再关心
msg.post("/bus", "unsubscribe", { topic = hash("level_loaded") })
self:start_gameplay()
end
end
防循环触发:A 收到消息后 post 给 B,B 又 post 回 A,会形成消息风暴。工程上的三道闸:
1. 事件里带 source,收到自己的事件直接丢弃
2. 加「正在处理」标志位,处理期间不再发布同类事件
3. 总线侧做去重:同一 tick 内相同 topic+payload 只转发一次
-- 丢弃自产事件,防循环
function on_message(self, message_id, message, sender)
if sender == msg.url() then return end -- 自己发的不处理
-- ...
end
超时兜底:请求-响应模式下,接收方可能已经被销毁,请求方必须设超时,否则状态永远卡在「等待中」:
function init(self)
self.pending = {} -- req_id -> { deadline = t }
end
function update(self, dt)
local now = os.time()
for req_id, p in pairs(self.pending) do
if now > p.deadline then
self.pending[req_id] = nil
self:on_request_timeout(req_id) -- 走降级分支
end
end
end
心智:请求-响应靠 sender + req_id,一次性订阅靠「收到即退订」,防循环靠 source 过滤与去重,可靠性靠超时兜底。
速查表
| 需求 | 做法 |
|---|---|
| 给自己发消息(延迟一帧) | msg.post(".", "topic", {...}) |
| 控制自身组件 | msg.post("#sprite", "play_animation", {...}) |
| 同集合跨对象 | msg.post("/enemy", "spotted") |
| 跨集合通信 | 先 Proxy load,再用 socket:/obj#comp |
| 收消息 | on_message(self, message_id, message, sender) |
| 回信 | msg.post(sender, "reply", {...}) |
| 解耦 | 事件总线(subscribe/publish)或集中式管理器 |
| 热路径 | 直调 / go.set,别用消息 |
| 防循环 | if sender == msg.url() then return end |
| 等异步资源 | 监听 proxy_loaded 后再发消息 |
一句话记忆:Defold 的通信模型是「URL 寻址 + 队列派发 + 回调消费」——同集合用相对路径、跨集合过 Proxy;解耦靠事件总线或集中管理器,热路径回退到直调与 go.set;请求-响应用 sender 回信加 req_id 去串台,防循环靠 source 过滤——把消息留给离散事件,架构自然就松了。
小结
消息传递是 Defold 架构的「骨架」。它用一点性能换来了三样东西:发送方无需知道接收方(可替换、可缺席)、生命周期安全(对象销毁后消息自然丢弃,不会悬空指针)、天然的帧级同步点(帧末派发,避免一帧内半更新状态)。落地时的三条纪律:一是同集合用相对 URL、跨集合必须走 Proxy 并等 proxy_loaded;二是连续状态用 go.set,离散事件才用 msg.post;三是引入总线或管理器作为唯一事件入口,避免对象之间网状直连。做到这三点,你的 Defold 项目就能在规模变大后依然改得动。
延伸阅读
- 脚本生命周期与 msg
- ECS 与组件解耦
- 消息队列设计 — 队列、投递与背压的通用理论
- Actor 模型详解 — 「只靠消息通信」的架构范式
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。