Defold ECS 与代码架构

从 Defold 原生组件模型出发,讲清 ECS 思想在 Lua 项目中的落地方式:Entity 与 Component 的职责划分、System 与更新循环、脚本模块划分与目录规范、消息驱动的事件解耦,以及常见架构反模式与重构路径,帮助中型项目长期保持可维护性。

引言

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 架构

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

参数说明:

参数类型说明
selftable脚本实例的状态表,跨回调共享
dtnumber帧间隔秒数,用于与帧率解耦
message_idhash消息标识,用 hash 字符串生成
senderurl发送方的 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

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「defold」更多文章

  1. Defold 测试与持续集成
  2. Defold 本地化与多语言
  3. Defold 团队协作与版本控制