引言
Defold 的 Game Object + Component 模型开箱即用,但项目一大就容易乱:脚本互相引用、状态散落各处、加一个功能要改五个文件。ECS(Entity-Component-System)把「数据」和「行为」分开,它并不要求你抛弃 Defold 的组件系统,而是在 Lua 层建立一套清晰的约定。本文从 Defold 原生组件模型讲起,引出用 Lua 实现轻量 ECS 的方式,再覆盖模块划分、目录规范、消息驱动解耦,最后给出常见反模式与重构路径。
前置阅读:/defold-script-system-lua/(Lua 脚本与生命周期)、/defold-game-engine-introduction/(Game Object 与 Component 核心概念)。
目录
- 1. 为什么需要 ECS 架构
- 2. Defold 组件模型回顾
- 3. 用 Lua 实现轻量 ECS
- 4. Entity 与 Component 的设计
- 5. System 与更新循环
- 6. 模块划分与目录结构
- 7. 消息驱动与事件解耦
- 8. 常见架构反模式与重构
- 9. 速查表
- 相关阅读
- 延伸阅读
1. 为什么需要 ECS 架构
1. 继承树带来的问题
游戏对象天然是多维的:一个「会飞、会受伤、能拾取物品、还会放技能」的单位,用面向对象的继承去建模会迅速失控。要么把能力全部塞进基类,要么陷入多重继承的模拟,最终得到一棵谁也看不懂的继承树。
Defold 的做法是把能力拆成组件:sprite 管显示、collisionobject 管碰撞、script 管逻辑。组件本身就是一种「组合优于继承」的实现,只是 Defold 没有强制你按 ECS 组织 Lua 代码。
2. ECS 的三个概念
| 概念 | 含义 | Defold 对应 |
|---|---|---|
| Entity | 一个身份 ID,本身没有数据 | Game Object 的 id |
| Component | 纯数据,描述某种属性 | Lua table(不是引擎组件) |
| System | 处理某类组件集合的逻辑 | 脚本中的 update 函数 |
关键区别在于:Component 只存数据不存行为,System 只处理逻辑不存数据。这样做的好处是逻辑集中、易测试、易复用。
3. 为什么 Defold 适合轻量 ECS
- Defold 的
go.property、消息机制天然支持数据驱动; - Lua 的 table 可以低成本表达任意组件结构;
- Game Object 数量适中(通常几百到几千),不需要 SoA 级别的内存布局优化。
注意:不要把 ECS 当作教条。Defold 项目里更实用的是「组件化 + 消息驱动」的混合架构,ECS 只用于实体数量多、行为高度相似的场景(子弹、敌人、粒子)。
2. Defold 组件模型回顾
1. Game Object 是容器
Game Object 本身不渲染、不计算,它只是一个位置 + 子组件列表的容器。所有实际能力来自挂载的组件。
player (Game Object)
├── sprite → 显示角色贴图
├── collisionobject → 参与物理碰撞
├── script → 处理输入与状态
└── sound → 播放音效
2. 脚本组件的生命周期
Defold 的脚本组件有四个固定回调:
function init(self)
-- 组件创建时调用一次,适合初始化状态
self.hp = 100
end
function update(self, dt)
-- 每帧调用,dt 是距上一帧的秒数
self.hp = self.hp - dt * 0.1
end
function on_message(self, message_id, message, sender)
-- 收到消息时调用
if message_id == hash("damage") then
self.hp = self.hp - message.amount
end
end
function final(self)
-- 对象销毁时调用,适合清理
end
参数说明:
| 参数 | 类型 | 说明 |
|---|---|---|
| self | table | 脚本实例的状态表,跨回调共享 |
| dt | number | 帧间隔秒数,用于与帧率解耦 |
| message_id | hash | 消息标识,用 hash 字符串生成 |
| sender | url | 发送方的 URL |
3. URL 寻址
组件之间靠 URL 通信,格式为 /game_object#component:
-- 同 Collection 内可以直接用 # 简写
msg.post("#sprite", "play_animation", { id = hash("run") })
-- 跨 Collection 必须写全路径
msg.post("/enemies/enemy_1#script", "damage", { amount = 10 })
踩坑:URL 里的 id 是区分大小写的,且必须与编辑器中的命名完全一致。拼错不会报错,只会静默失败——调试时先
print(msg.url())确认自身 URL。
3. 用 Lua 实现轻量 ECS
1. Entity 就是 GO 的 id
不需要额外的实体表:go.get_id() 返回的 id 就是天然的唯一标识。
-- 每个实体脚本在 init 时向世界注册自己
function init(self)
self.eid = go.get_id()
world.register(self.eid)
end
2. Component 是纯数据表
组件不是引擎组件,而是挂在实体上的 Lua table:
-- components/health.lua
local M = {}
function M.new(hp, max_hp)
return {
hp = hp,
max_hp = max_hp or hp,
invuln = 0,
}
end
function M.damage(comp, amount)
if comp.invuln > 0 then return false end
comp.hp = comp.hp - amount
return comp.hp <= 0
end
return M
设计要点:M.damage 是操作组件的纯函数,输入输出明确,不依赖任何引擎状态,可以直接在单元测试里跑。
3. 世界注册表
-- world.lua
local M = {
entities = {}, -- eid -> { components }
systems = {}, -- 有序系统列表
}
function M.register(eid)
M.entities[eid] = M.entities[eid] or {}
end
function M.unregister(eid)
M.entities[eid] = nil
end
function M.add(eid, name, comp)
local e = M.entities[eid]
if not e then return end
e[name] = comp
end
function M.get(eid, name)
local e = M.entities[eid]
return e and e[name]
end
return M
记忆:
world.entities是eid -> { 组件名 -> 组件表 }的两层结构,System 通过遍历它来筛选关心的实体。
4. Entity 与 Component 的设计
1. 组件只放数据
反面示例——把行为也塞进组件,结果组件变成了「半个类」:
-- 不推荐:组件里带了引擎调用
local comp = {
hp = 100,
on_death = function(self)
go.delete() -- 组件依赖引擎,无法单元测试
end,
}
推荐做法:组件只描述状态,行为交给 System。
2. 组件的组合查询
-- 查询同时拥有 health 和 transform 的实体
local function query_entities(world, names)
local result = {}
for eid, comps in pairs(world.entities) do
local ok = true
for _, n in ipairs(names) do
if not comps[n] then ok = false break end
end
if ok then table.insert(result, eid) end
end
return result
end
3. 数据驱动的初始属性
用 go.property 把数值暴露到编辑器,策划不用改代码就能调参:
go.property("max_hp", 100)
go.property("move_speed", 200.0)
function init(self)
world.add(self.eid, "health", health.new(self.max_hp))
world.add(self.eid, "move", move.new(self.move_speed))
end
说明:go.property 声明的值可以在 .go 文件或 .script 的属性面板中覆盖,Factory 创建实例时也能通过 properties 参数传入。
5. System 与更新循环
1. System 是函数集合
-- systems/movement.lua
local world = require("world")
local M = {}
function M.update(dt)
for eid, comps in pairs(world.entities) do
local m = comps.move
if m then
local p = go.get_position(eid)
go.set_position(p + m.dir * m.speed * dt, eid)
end
end
end
return M
2. 更新顺序很重要
系统之间常有依赖:先算输入再算移动,先算伤害再算死亡。
-- main.script 中统一调度
local movement = require("systems.movement")
local combat = require("systems.combat")
local death = require("systems.death")
function update(self, dt)
movement.update(dt) -- 1. 位置先更新
combat.update(dt) -- 2. 再判定战斗
death.update(dt) -- 3. 最后处理死亡
end
踩坑:不要在 System 里直接
go.delete(),因为删除会让同一帧后续 System 拿到失效 id。推荐打标记,由末尾的清理 System 统一删除:
-- 标记删除,延后处理
function M.update(dt)
for eid, comps in pairs(world.entities) do
if comps.health and comps.health.hp <= 0 then
comps.dead = true
end
end
end
function M.cleanup()
for eid, comps in pairs(world.entities) do
if comps.dead then
go.delete(eid)
world.unregister(eid)
end
end
end
3. 系统开关与性能
不是所有系统每帧都要跑。低频系统可以按时间片调度:
function update(self, dt)
self.acc = (self.acc or 0) + dt
if self.acc >= 0.2 then -- 每 0.2 秒跑一次
self.acc = 0
ai.update(0.2)
end
movement.update(dt) -- 移动仍然每帧跑
end
6. 模块划分与目录结构
1. 按职责分层
/main
main.collection
/scripts
main.script ← 只负责调度
/core
world.lua ← 实体注册表
event_bus.lua ← 事件总线
/components
health.lua
move.lua
inventory.lua
/systems
movement.lua
combat.lua
spawn.lua
/data
config.lua ← 数值配置
2. 依赖方向必须单向
main.script → systems → components → core
规则:下层不能 require 上层。components/health.lua 不应该 require("systems.combat"),否则会形成循环依赖,Lua 的 require 会返回半初始化的 table,报错极其难查。
3. 命名规范
| 类型 | 规范 | 示例 |
|---|---|---|
| 模块文件 | 小写下划线 | event_bus.lua |
| 组件文件 | 名词单数 | health.lua |
| 系统文件 | 名词复数或动词 | movement.lua |
| 脚本组件 | 功能点命名 | player.script |
| 常量 | 全大写下划线 | MAX_ENEMIES |
踩坑:Defold 中
.script文件是组件,不能像普通 Lua 模块那样require。共享逻辑要放进.lua文件,.script只做引擎回调的胶水层。
7. 消息驱动与事件解耦
1. 直接调用 vs 消息
-- 直接调用:耦合强,A 必须知道 B 的存在
local audio = require("systems.audio")
audio.play("hit")
-- 消息:解耦,发送方不知道谁在听
msg.post("/audio#script", "play_sound", { id = hash("hit") })
2. 事件总线
跨模块的广播用事件总线,避免层层透传:
-- core/event_bus.lua
local M = { listeners = {} }
function M.on(event, fn)
M.listeners[event] = M.listeners[event] or {}
table.insert(M.listeners[event], fn)
end
function M.off(event, fn)
local list = M.listeners[event]
if not list then return end
for i = #list, 1, -1 do
if list[i] == fn then table.remove(list, i) end
end
end
function M.emit(event, payload)
local list = M.listeners[event]
if not list then return end
-- 复制一份,避免回调中修改列表导致遍历错乱
for _, fn in ipairs({ table.unpack(list) }) do
fn(payload)
end
end
return M
3. 与 Defold 消息机制配合
引擎级通信(组件之间)用 msg.post,游戏逻辑级通信(Lua 模块之间)用事件总线。两者不要混着用:
-- 组件收到引擎消息后,转发到事件总线
function on_message(self, message_id, message, sender)
if message_id == hash("collision_response") then
event_bus.emit("collision", { other = message.other_id, self = go.get_id() })
end
end
8. 常见架构反模式与重构
| 反模式 | 症状 | 重构方向 |
|---|---|---|
| 上帝脚本 | 单个 .script 超过 500 行 | 拆成 systems + 事件总线 |
| 全局状态 | 到处 _G.player_hp | 收进 world 注册表 |
| 循环依赖 | require 返回 nil 或半成品 | 抽出 core 层,单向依赖 |
| 硬编码数值 | 代码里写死 hp = 100 | 提到 data/config.lua |
| 消息滥用 | 每帧几十条消息 | 高频数据走共享表,消息只传事件 |
| 直接 delete | 遍历中删除实体 | 打标记,末尾统一清理 |
1. 逐步重构而非重写
不要一次性推翻现有代码。按下面的顺序渐进改造:
1. 先把散落的全局变量收进一个 state 模块
2. 再把纯逻辑抽成 .lua 模块(可单元测试)
3. 然后引入 world 注册表统一管理实体
4. 最后按 System 拆分 update 逻辑
2. 保持可测试性
任何不依赖 go.*、msg.*、vmath.* 的 Lua 模块都是可测的。把游戏规则写成纯函数,引擎调用留在薄薄的一层:
-- 纯函数:可单元测试
function M.apply_damage(health, amount, now)
if health.invuln > now then return false end
health.hp = health.hp - amount
return true
end
-- 胶水层:调用纯函数并把结果同步到引擎
function on_message(self, message_id, message, sender)
if message_id == hash("damage") then
if M.apply_damage(self.health, message.amount, socket.gettime()) then
update_health_bar(self)
end
end
end
9. 速查表
| 需求 | 做法 | 备注 |
|---|---|---|
| 实体标识 | go.get_id() | 天然唯一,无需额外表 |
| 注册实体 | world.register(eid) | init 时调用 |
| 添加组件 | world.add(eid, "health", comp) | 组件是纯数据表 |
| 查询实体 | 遍历 world.entities 筛选组件 | 实体量小无需索引 |
| 执行逻辑 | System 的 update 函数 | 注意顺序依赖 |
| 删除实体 | 打标记 + 末尾统一 cleanup | 避免遍历中删除 |
| 模块通信 | 引擎级 msg.post,逻辑级事件总线 | 两者不要混用 |
| 数据配置 | go.property 或 data/config.lua | 策划可调参 |
| 可测试性 | 规则写成纯函数 | 不依赖 go/msg/vmath |
一句话记忆:Entity 是 go.get_id(),Component 是纯数据 table,System 是无状态的处理函数;依赖方向保持单向(main → systems → components → core),删除实体走标记 + 统一清理,跨模块通信优先事件总线而不是直接 require。
相关阅读
- /defold-script-system-lua/ — Lua 脚本、消息路由与生命周期回调
- /defold-game-engine-complex-logic-state-management/ — 复杂逻辑与状态管理的工程实践
- /defold-game-engine-introduction/ — Game Object 与 Component 基础概念
- /defold-collections-factories/ — Collection 层级与 Factory 动态实例化
延伸阅读
- /defold-performance-optimization/ — 大量实体下的更新与渲染优化
- /defold-profiling-debugging/ — 定位架构瓶颈的调试手段
- /defold-save-serialization/ — 实体状态的序列化与恢复
- /defold-native-extensions/ — 用 C 扩展替换 Lua 热路径
- 游戏开发专题 — 游戏引擎架构与 ECS 原理
-- ======================================
-- 完整示例:极简 ECS 世界(world.lua)
-- 放在 /core/world.lua,由 main.script 调度
-- ======================================
local M = {
entities = {},
systems = {},
}
function M.register(eid)
M.entities[eid] = M.entities[eid] or {}
return M.entities[eid]
end
function M.unregister(eid)
M.entities[eid] = nil
end
function M.add(eid, name, comp)
local e = M.entities[eid]
if not e then
e = M.register(eid)
end
e[name] = comp
return comp
end
function M.get(eid, name)
local e = M.entities[eid]
return e and e[name]
end
function M.add_system(name, sys)
table.insert(M.systems, { name = name, fn = sys })
end
function M.update(dt)
for _, s in ipairs(M.systems) do
local ok, err = pcall(s.fn, dt)
if not ok then
print(string.format("[ECS] system %s error: %s", s.name, tostring(err)))
end
end
end
return M
-- ======================================
-- 完整示例:main.script 调度入口
-- ======================================
local world = require("core.world")
go.property("enemy_count", 20)
local function movement_system(dt)
for _, eid in ipairs(world.query({ "move", "health" })) do
local m = world.get(eid, "move")
local p = go.get_position(eid)
go.set_position(p + m.dir * m.speed * dt, eid)
end
end
local function cleanup_system()
for _, eid in ipairs(world.query({ "dead" })) do
go.delete(eid)
world.unregister(eid)
end
end
function init(self)
world.add_system("movement", movement_system)
world.add_system("cleanup", cleanup_system)
print("[init] ECS 世界已启动,预置敌人数量:", self.enemy_count)
end
function update(self, dt)
world.update(dt)
end
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。