Flecs Module 系统:构建可复用的游戏功能模块库 From Glue Code to Publishable Modules
模块化不是为了代码好看,是为了让你第二个项目不必从第一个项目的残骸里翻作业
为什么模块系统是「第二个项目」才会痛的那块基石
凌晨两点,你在新项目的目录里翻上一个项目的旧代码,找到一个叫 physics_stuff_final_v3 的文件,一千四百行。你不知道里面哪几段还有效,哪几段是你三个月前为了赶版本临时打的补丁,哪几段在物理步长改过之后就已经失效了。你咬了咬牙,全选、复制、粘贴,然后对自己说:这次一定会整理。
不会的。我干过三次,三次都没整理。
模块系统要解决的从来不是「代码整洁」这种听起来很体面的问题,它解决的是一个很朴素的问题:你愿不愿意在第三个项目里,再把同一坨东西复制一遍。人只有在第二次要用同一个东西的时候,才会想起模块化;而那一刻,通常已经来不及了,因为你手上的这坨东西已经和多处游戏逻辑纠缠在一起,剪哪一刀都出血。
Flecs 有趣的地方在于,它把「模块」做成了世界里的一等公民,而已经不仅仅是一种代码组织习惯。在 Flecs 里,模块就是一个实体(entity),挂着一堆组件声明、系统、初始化逻辑,导入它,世界就多出一整套能力。听着很轻,但这一层轻,恰好让它能同时服务三件事:给玩家稳定的手感、给策划能调的参数、给未来的你一份可读的说明书。
先说说玩家能看见什么。玩家看不见模块,但玩家能看见「跳跃手感在第 5 关突然变了」。这种事故十有八九是因为第 5 关走了另一条场景加载路径,重力值漏设了,常量在两个地方各写了一份,改了一个忘了另一个。模块化不能让你变聪明,但它能让这类 bug 在结构上无法发生,因为重力只有一个地方能改。
上一篇(第 19 篇)我们聊了怎么用 CMake、vcpkg、Conan 把 Flecs 拉进项目,那是「请人进门」。这一篇要聊的是反过来的事:怎么把你自己写的东西,变成一个别人(包括三个月后的你)能直接导入的模块。下一篇会讲 WebAssembly 部署,那时候你会感谢自己现在把模块切成了一块一块的,因为要裁剪包体的时候,你不需要在一千四百行里找哪段能删。
下面四层,从「模块到底是个什么东西」一路讲到「怎么把它发布出去还不被人骂」。中间会插两张真实案例的深度拆解:一个是 Flecs 官方模块家族自己怎么分层,另一个是 Bevy 生态里那个被版本追着跑的物理插件。
第一层:Module 是世界的装配说明书,并非文件夹
先把最容易搞混的概念拆开。很多人一说模块就想到目录、想到命名空间、想到 CMake 里的一个静态库目标。这三样东西和 Flecs 的 Module 不在一个层面上,混着用一定出事。
命名空间和库目标活在编译期,它们决定「哪些符号能看见、哪些代码链进来」。Flecs 的 Module 活在运行期,它决定「这个世界里现在有哪些组件类型、哪些系统、哪些查询」。一个是装货,一个是开箱。你得两个都做,但它们不能互相代替。
Flecs 里模块的实体形态非常朴素:一个带名字的实体,上面挂着这个模块要注册的全部内容,另外还挂着一个「只跑一次」的初始化动作。导入模块 = 在 World 上把这个实体实例化,把里面的类型和系统一次性倒进世界。这个设计妙在它没有给世界引入任何新层级,因为世界本来就是平的,模块也只是平地上的一块砖。查询跑起来的时候,模块实体身上没有业务组件,自然被过滤掉,不会污染任何一次遍历。
说句实话,我第一次看到「模块是一个实体」的时候是有点不屑的,觉得这不就是换了个说法的命名空间吗。后来我把它用在多 World 场景上(服务器一个、客户端一个)才明白差别在哪:命名空间是编译期的一张纸,同一个进程里所有 World 共享;模块实体是每个 World 各自的一份现场,A 世界导入过的模块,B 世界不导入就没有。这个差别在做网络同步时是救命的,第 15 篇讲预测回滚会再遇到它。
那怎么切模块?我见过太多团队按「文件名」或者「感觉像一类东西」来切,切完三个月就成一团。真正管用的判据只有三条:
| 判据 | 问自己的问题 | 该独立成模块 | 不该独立 |
|---|---|---|---|
| 数据所有权 | 这批组件主要由谁写、由谁读? | 读写方高度内聚,外人只读 | 两个系统各写一半,互相踩 |
| 更新节奏 | 它是每帧跑,还是每 5 帧、还是只在初始化跑? | 节奏独立,能单挂一个 Phase | 节奏和主循环死死绑定 |
| 依赖方向 | 它需要知道别的模块的存在吗? | 只依赖更底层的通用能力 | 反过来要别人知道它 |
拿 2D 物理模块举例。它拥有位置、速度、碰撞体、质量这几类组件,它每帧在固定的时间步里跑,它只依赖数学和空间索引,不知道玩家、不知道敌人、不知道 UI。三条全中,这就是一个标准模块。
反过来,比如「玩家控制」这东西,它知道输入、知道物理、知道动画、知道音效,它天生就是一个依赖大户。它该做成模块吗?该,但它必须站在依赖链的最上层,而且不许有任何模块反过来依赖它。这条纪律你要是守不住,整个项目会变成一锅粥,而且是那种你不敢碰的粥。
还有个很实际的好处,很多人没意识到:模块化直接影响调参手感。物理参数一旦收进模块的配置里,它就是一份,改一次全关卡生效。策划在编辑器里把重力从 -9.8 拧到 -14,所有场景同步变化,他能立刻判断「这个跳跃是不是太飘」。如果参数分散在五个场景脚本里,他永远调不出手感,因为每次改完只生效了五分之一。
第二层:一个模块的四张脸,以及命名空间带来的麻烦
一个能用的模块,从外面看有四张脸。这四张脸决定了别人怎么用它,也决定了你以后怎么改它。
第一张脸是类型面板。模块往世界里注册了哪些组件、哪些 Tag、哪些关系。这是模块最公开的部分,也是最难改的部分。组件加一个字段,编译期不报错,但老存档读进来就是一场灾难(第 17 篇专门讲这个)。
第二张脸是执行面板。模块往调度里挂了哪些系统、挂在哪个 Phase、和谁有先后依赖。这块是新手最容易忽略的,因为系统执行顺序写错了不会报错,只会让游戏变得「有点怪」。物理积分跑在碰撞检测后面,角色就会偶尔穿墙一帧,测试同学报上来是「偶发穿墙」,你查三天。
第三张脸是配置面板。重力多大、步长多细、迭代几次、休眠阈值多少。我的强烈建议是:这些值一律做成世界里的单例组件,不要用编译期常量。做成组件以后,它自动获得三个超能力:能被序列化进存档、能在运行时被调试工具改、能做多套预设(正常关卡一套、水下关卡一套、月球关卡一套)。用常量的话,改一次重编译一次,策划会恨你。
第四张脸是交互面板。模块对外提供什么事件、暴露什么关系、允许外部用什么方式查询它。这张脸最容易被做肥,做着做着就变成「什么都暴露」,然后你就再也升不动这个模块了。
接下来说命名空间,这是一笔血债。Flecs 的组件是按名字寻址的,重名会撞。小项目两个人写不会撞,五个人以上必撞,而且撞的方式极其阴险,因为编译器一句话都不会说。
我自己翻过的车:项目里有两个地方都定义了「速度」。一个是物理积分用的真实速度,一个是 AI 寻路算出来的期望速度。名字一模一样,谁先注册谁占坑,另一个被悄悄改成了别的路径。结果就是 AI 系统读到的是上一帧物理积分之后的值,又积了一次,角色走起来像在冰面上打滑。这个 bug 花了我两个晚上,最后是在 Flecs Explorer 里翻组件列表才看出来的。从那以后我定了个死规矩:模块名 + 子域 + 组件名,三段式,谁都不许省。
这里正好请出第一个案例:Flecs 官方模块家族怎么给自己划脸。
Flecs 仓库里有一批自带模块,比较有名的几个是:负责反射与类型信息的 meta、负责脚本化场景描述的 script、负责运行时统计的 stats、负责对外暴露 REST 接口的 rest、负责单位换算的 units。你把它们当成一个真实项目的模块库来看,会发现几条很硬的设计纪律。
第一条,分层极其克制。script 依赖 meta,因为脚本要按类型名去描述组件,必须知道类型的元信息。但 meta 完全不知道 script 的存在,它只提供「把类型登记进来、按名字查回去」这件事。units 谁都不依赖,自己站着。这就是干净的单向依赖链,你可以把 script 整个裁掉,meta 毫发无伤。
第二条,开发期模块和运行期模块严格分开。stats 和 rest 是典型的开发期模块,你在出正式版的时候会把它们整个关掉,一个字节都不留。它们从设计之初就假定自己「可能不存在」,所以其它模块不许依赖它们。这条纪律在独立游戏里价值巨大,因为你的包体预算就那么点,尤其在 WebAssembly 环境下(第 21 篇会详细算这笔账)。
第三条,模块不假设世界内容。meta 不知道用户会定义哪些类型,所以它采取「你登记我才知道」的姿态,绝不主动扫描全世界。这个姿态很值得学。我见过太多自研模块,一进世界就横扫所有实体,找它认为该有的东西,结果换个项目立刻水土不服。
代价也很真实:分层克制意味着你得写更多样板,导入顺序错了要自己查,文档得一行行写清楚谁依赖谁。收益是你能在任何时候干净地切掉一整块,去做一个只有 3MB 的 Web 版本。这笔买卖,我认为值。
第三层:依赖、导入顺序与可裁剪,模块之间的政治
模块一多,问题就从「怎么写」变成「谁听谁的」。这一层聊三件事:依赖方向、导入的幂等、以及编译期与运行期两种裁剪的取舍。
依赖方向必须是单向的,而且这一条比什么架构原则都重要。玩法模块可以依赖物理模块,物理模块可以依赖数学与空间索引,反过来一概不许。一旦物理模块开始知道「玩家按了什么键」,它就不再是物理模块,它是「这个游戏的物理」,第二个项目就再也用不上它了。
导入幂等也很关键。同一个模块被两个地方各导入一次,会发生什么?好的实现是第二次直接返回已有的那份,什么都不做;坏的结果是系统被挂了两份,物理每帧算两次,角色跑起来是两倍速,而且你根本看不出哪来的。我的建议是模块自己内部就处理掉这件事,别指望调用方守规矩。
然后是裁剪,这里有个很多人没想清楚的岔路口:编译期裁剪和运行期裁剪不是二选一,它们服务的目标完全不同。
| 维度 | 编译期裁剪(构建时关掉) | 运行期裁剪(导入时决定) |
|---|---|---|
| 省的是什么 | 包体大小、链接时间、内存里的静态区 | 世界里的实体与系统数量、初始化耗时 |
| 典型对象 | 统计面板、REST 调试接口、脚本引擎 | 某个关卡不需要的玩法模块、不同模式的功能 |
| 什么时候必须用 | WebAssembly、移动端、有硬包体上限时 | 多模式游戏、编辑器与运行时共用一套代码 |
| 代价 | 构建配置翻倍,得维护多套预设 | 代码还在包里,只是没跑 |
再往下是最痛的一环:模块对宿主的接口面,决定了它未来的升级痛感。一个模块如果对外暴露两百个类型、三十个事件、一堆内部实体句柄,那它永远升不动,因为每改一处都要通知所有使用者。我的经验值是把对外暴露的类型控制在「一个人一天能读完」的量级,大概二三十个,事件控制在十个以内。超了就该拆模块了。
现在请出第二个案例,也是这一层最生动的教训:Bevy 生态里的 bevy_rapier 物理插件。
先交代背景。Bevy 是 Rust 阵营的 ECS 引擎,它的模块化抽象叫 Plugin:一个插件往 App 里塞资源、塞系统、塞类型,思想跟 Flecs 的 Module 是一路人。而 bevy_rapier 是社区里最主流的 2D/3D 物理插件,把 Rapier 物理引擎包成了一套 Bevy 组件和系统。这几乎是「第三方模块」的标准范本:它定义了刚体、碰撞体、速度、关节这一整套组件,挂了物理步进和碰撞事件转换的系统,对外提供查询和射线检测的入口。使用者只需要导入这一个插件,就获得完整物理能力。
收益非常明显:Bevy 生态在早期就是靠这种插件模式滚起来的,物理、音频、UI、输入各有一批插件,谁都不用等官方。设计意图很漂亮:核心保持小,能力靠拼。
但代价在两年后集中爆发了。Bevy 走的是高频破坏性版本节奏,大约每三个月一个大版本,API 说改就改。插件作者得一路追。于是社区里出现了一种非常尴尬的局面:你升到最新 Bevy,物理插件还停在两个版本之前,编译不过;你想用物理,就得把 Bevy 钉在老版本,其它插件又要求新 Bevy。有人形容那段时间「升级 Bevy 是一场需要排期的项目」。这不是谁不努力,这是模块接口面太宽的必然结果:物理插件要接触 Transform、要接触时间步、要接触调度、要接触事件,它和宿主的接触面几乎覆盖了宿主的一半,宿主一动,它就得跟着动。
这个教训对我们的自研模块同样成立。我的对策是三条:第一,只接触宿主最稳定的那部分能力,别为了省几行代码去用最新的花活;第二,把和宿主的交互集中在一个薄薄的适配置层里,将来宿主变了只改这一层;第三,版本兼容表必须写在文档第一屏,别让使用者自己去猜。
顺手提一句旁证:Godot 4.1 改 GDExtension 接口的时候,社区里一批插件集体重编译,同一个剧本。凡是「以插件/模块形式接入宿主」的架构,都要为这一课交学费,早交比晚交便宜。
第四层:从自用到可发布,把私人手艺变成公共契约
前三层讲的是「怎么用得对」,这一层讲「怎么给别人用」。分水岭特别清晰:有没有第二个人,能在不问你一句的情况下把它跑起来。做不到,它就是自用工具;做得到,它才叫模块。
先选形态。三种主流发布方式,各有各的适用场景:
| 形态 | 怎么做 | 适合 | 坑 |
|---|---|---|---|
| 源码内嵌 | 几个文件直接放进项目目录 | 单人项目、原型期、模块还在剧烈变化 | 升级靠手动比对,改过的文件会冲突 |
| 独立仓库 + 包管理 | 单独一个仓库,通过 CMake 或包管理器拉取 | 多项目共用、团队协作 | 一开始就得把构建脚本写干净 |
| 单文件夹可拷贝 | 一个目录自包含,拖进去就能编 | 给别人试用、发 jam、开源小模块 | 依赖第三方库时容易漏 |
我的建议很直白:先在项目内做源码内嵌版,等它连续三个月没改过接口,再抽出去独立成仓库。上来就搞独立仓库的,八成会在前两个月反复推翻设计,最后仓库里全是废弃分支,看的人一脸问号。
然后是文档。我不要求你写论文,但六样东西必须有,少一样别人就会来问你:怎么导入(三步以内)、配置项一张表、组件一张表(名字、含义、谁写谁读)、事件一张表(什么时候触发、带什么信息)、已知限制与坑、版本兼容(我用的是哪个 Flecs 大版本、哪个编译器)。就这六样,能省下你未来 80% 的回答时间。
接下来是我认为整个模块化里最被低估的一件事:语义化版本的判定标准。在 ECS 模块里,「破坏性变更」的定义比普通库要宽得多,因为有些改动编译器根本不管,但使用者一定会出事。给你一张判定表:
| 改动 | 编译报错吗 | 算破坏性变更吗 | 为什么 |
|---|---|---|---|
| 组件新增字段 | 不报 | 算 | 老存档读进来该字段是空的,行为变了 |
| 组件删除字段 | 会报 | 算 | 使用者代码要改 |
| 系统执行顺序调整 | 不报 | 算(最阴的一类) | 行为悄悄变了,测试都不一定抓得住 |
| 新增一个可选配置项 | 不报 | 不算 | 默认值保持旧行为就行 |
| 重命名组件 | 会报 | 算,而且要配迁移说明 | 存档里的名字对不上 |
| 内部实现优化 | 不报 | 不算 | 只要对外行为一致 |
说到这我要泼一盆冷水:大部分自研模块死在「没写迁移说明」上。你自己改完顺手就提交了,三个月后第二个项目拉下来发现存档读不了,你得把两年前的提交翻出来比对。我的做法是每次破坏性改动都在文档里加一行「从 X 到 Y 要做什么」,哪怕只有一句「把旧的阻尼字段拆成线性和角两个,都填原来那个值」。这一行值千金。
最后给个成熟度分级,用来判断「我现在能不能对外说这是个模块」:
| 等级 | 标准 | 典型状态 |
|---|---|---|
| L0 自用脚本 | 只有你能跑,参数写死,无文档 | 原型期,别急着发布 |
| L1 可复用 | 配置收进组件,有一个最小示例场景 | 可以给队友用 |
| L2 可交付 | 六样文档齐全,版本兼容表在,有已知坑清单 | 可以给外部试用 |
| L3 可发布 | 语义化版本 + 迁移说明 + 自动化测试或基准 | 可以挂在社区里让人用 |
绝大多数独立开发者的模块卡在 L1 到 L2 之间,不是因为技术难,是因为写文档这件事没有即时反馈,玩家不会因为你写了好文档而多买一份。但你第二个项目会。
参数化设计工具:三张表把模块钉在纸面上
光讲原则没用,落到表上才能执行。三张表,拿走就能用。
表一:2D 物理模块的配置组件字段(建议区间 + 调错症状)。这张表是我自己项目里用的,数值以「1 米 = 32 像素」为单位制,你按自己的换算改。
| 字段 | 作用 | 建议区间 | 调错了会怎样 |
|---|---|---|---|
| 固定步长 | 物理每次推进的时间 | 1/60 到 1/120 秒 | 太大高速物体穿墙;太小 CPU 明显吃紧、低端机掉帧 |
| 每帧子步数 | 一帧内跑几次物理 | 1 到 4 | 太少则高速碰撞漏检;太多则帧时间尖刺,手感发黏 |
| 重力加速度 | 每秒每秒的垂直加速 | 约 -9.8 到 -30(按单位制) | 直接影响跳跃滞空时长,这是手感第一旋钮 |
| 速度迭代次数 | 约束求解的迭代轮数 | 6 到 10 | 太低则堆叠物体互相渗透、抖动;太高则物理耗时翻倍 |
| 位置迭代次数 | 穿透修正的轮数 | 2 到 4 | 太低则物体卡进墙里出不来 |
| 线性休眠阈值 | 多慢算静止 | 按单位制标定,先测再定 | 太灵敏则慢速移动的平台提前睡死不动 |
| 碰撞层数量 | 分类过滤用的层数 | 8 到 16 | 超过 16 层要换用位图方案,别硬撑整数掩码 |
| 碰撞事件队列容量 | 一帧最多缓存多少碰撞事件 | 256 到 2048 | 太小则爆炸场景丢事件,音效播不出来 |
| 像素每米 | 单位换算系数 | 全项目唯一,写进模块配置 | 两个模块混用不同系数,整套物理数值全废 |
表里我最想强调的是最后一行。单位换算不是一个「顺手写个宏」的事,它是全项目最容易翻车的地方。我见过一个项目,角色模块用 1 米 = 32 像素,道具模块用 1 米 = 100 像素,两边都对,合起来所有碰撞箱都小了三倍。查了两天。
表二:模块健康度自评(每季度跑一次,低于及格线就重构)
| 指标 | 健康值 | 警戒线 | 说明 |
|---|---|---|---|
| 对外暴露类型数 | 10 到 25 | 超过 40 | 一个人一天读不完就该拆 |
| 对外事件数 | 3 到 8 | 超过 12 | 事件太多说明模块边界没找对 |
| 直接依赖的其它模块数 | 0 到 2 | 超过 4 | 依赖越多越难单独测试 |
| 配置项数量 | 5 到 15 | 超过 25 | 太多说明该拆成两个模块了 |
| 最近一次破坏性变更距今 | 3 个月以上 | 每月都在改 | 说明设计还没稳定,别急着发布 |
| 最小示例能否独立跑 | 能 | 不能 | 不能跑就别叫模块,叫代码片段 |
表三:边界划分决策表(纠结某个东西放哪时,按顺序问)
| 顺序 | 问题 | 是 → | 否 → |
|---|---|---|---|
| 1 | 去掉它,另一个项目还用得上吗? | 进模块 | 留在游戏层 |
| 2 | 它知道玩家、关卡、UI 这类游戏专属概念吗? | 留在游戏层 | 继续问 |
| 3 | 它的数据主要由谁改? | 按改动方归堆 | 继续问 |
| 4 | 把它整个删掉,项目还能编译吗? | 可以做成可选模块 | 那就是核心模块 |
这三张表合起来就是一条纪律:模块的价值取决于「能被干净地删掉」的程度。一个删掉就全崩的东西,不叫模块,叫地基。
双路径:初级用户速览 / 进阶深入
初级路径:先别急着做模块。
我知道这话从一个讲模块的人嘴里说出来有点怪,但这是真心话。模块化的收益在第二次复用的时候才兑现,在那之前你只是在为一个还不存在的需求付利息。触发条件就一个:你第二次复制了同一坨代码。那一刻起,把它变成模块就是划算的。
最小可用模块三件套,一个下午能搞完:一,一个自包含的文件或目录,里面放这个模块全部的组件声明、系统、初始化逻辑;二,一个初始化入口,接收世界和一份配置,把类型注册进去、系统挂上去,跑完就结束,不留任何全局状态;三,一个配置结构,做成世界里的单例组件,把所有可调参数塞进去,给一套默认值。
然后是三步走:把你现有代码里跟物理相关的组件全搬进去,把系统搬进去,把散落在各处的常量收进配置组件。搬的过程中你一定会发现有两处的重力值不一样,恭喜你,你已经抓到一个潜在 bug 了。
别做的事:别一开始就写文档,别一开始就发独立仓库,别一开始就设计「以后要支持 3D」的扩展点。我做过第三个,后来发现 3D 需求压根没来,抽象层倒是把我绊了半年。
进阶路径:多 World、热重载、以及模块的状态洁癖。
当你开始同时跑两个世界,比如权威服务器一个、客户端一个,或者做预测回滚需要世界快照,模块设计的隐藏问题会集中爆发。核心纪律一句话:模块只负责注册定义,绝不持有状态。
听起来抽象,落到具体就是:模块里不许有静态变量缓存「上一次的碰撞对数」,不许有全局数组记住「这个世界的玩家实体列表」,不许有单例指针指向某个世界的资源。所有状态必须活在组件里、活在实体上,这样世界被复制、被丢弃、被序列化的时候,状态跟着走,不会串。一个模块里藏了一个静态缓存,多 World 场景下两个世界会互相污染,症状是客户端偶尔诡异地「预测」到服务器的位置,你查一周都查不出来。
再往上一层是热重载。如果模块的类型和系统是运行时注册的,理论上可以做到改完配置立刻生效。这在 FlecsScript 那套声明式场景描述里是原生支持的(第 26 篇专门聊它的潜力和争议)。如果你要自己做,注意一点:重新注册类型之前,必须保证世界里没有旧类型的实例,否则你会得到一堆挂在旧类型上的孤儿实体,它们不会报错,只是永远不被任何查询命中,像幽灵一样占着内存。
最后一个进阶话题:模块和调度阶段的约定。多模块协作最容易出的错是「物理模块在自己的阶段里跑,玩法模块在另一个阶段里读物理结果」,两个阶段顺序如果没定死,玩法就会读到上一帧的数据。我的做法是在模块文档里明确写出「本模块的系统运行在哪个阶段、产出哪些数据可供下游读取」,这比任何注释都管用。
争议与常见坑:模块化最容易掉的五个坑
坑一:模块变成第二个游戏。最典型的表现是模块里出现了玩家按键绑定、出现了 UI 文本、出现了「如果关卡编号是 3 就特殊处理」。一旦出现这些,它就不再可复用。判断标准很简单:这个模块能不能在一个完全不同的游戏里跑起来?不能,就别叫模块。我见过一个「可复用战斗模块」,里面写死了主角的技能表,后来第二个项目要用,光剥离配置就花了一个月。
坑二:循环依赖。A 模块导入 B,B 又反过来导入 A。编译器可能不报错,运行时可能也跑得起来,但你的构建顺序会变得不可预测,而且你永远没法单独测试任何一个。发现循环依赖的唯一办法是画一张依赖图,定期看。图一画出来,问题一目了然,比读代码快十倍。
坑三:对外暴露内部实体句柄。这是 Flecs 特有的坑,很有代表性。模块把自己的内部实体(某个求解器、某个管理器)的句柄给了外部,外部存起来准备以后用。结果模块内部因为某种原因把这个实体删了,外部手里就剩一个失效句柄,下次用它就是一发子弹。规矩是:模块内部实体不外包,外面要东西就通过事件或者查询拿,别捏着句柄不放。
坑四:模块里藏静态状态。上面进阶路径里说过了,这里再强调一次,因为它真的很容易犯,尤其是从 OOP 转过来的人,手会不自觉地写一个全局缓存。多 World 一跑,全线崩溃。
坑五:把参数写成编译期常量。短期看最省事,长期看是给自己上刑。策划每调一次参数你重编译一次,一天二十次,你会开始躲着策划走。
接下来聊一个争议,这个问题我在社区里见过好几次:「在 ECS 里搞模块,是不是又在重建 OOP 的层级?」
问得有道理。ECS 的卖点之一就是「扁平、无继承、组合优于分类」,你搞一堆模块,会不会又把树给建回来了?
我的回答分两半。第一半:模块是装配期的东西,并非运行时的层级。运行时的世界仍然是平的,一万个实体互不相识,查询照样一把梭。模块只在「往世界里装东西」的那一瞬间存在,装完它就退场了(甚至查询都扫不到它)。第二半:判断标准很硬,把模块全删掉,世界还平不平?平,那就没退化成 OOP;不平,说明你把模块当基类用了,那就真的退回去了。
还有一个反对声音值得听:「小项目根本不需要模块,这是给大团队准备的重武器。」这话对了一半。小项目确实不需要「发布给别人用」的模块,但小项目同样需要「三个月后还敢改代码」。我的折中是:小项目只做一件事,就是把配置收进组件,别的都可以先放着。这一件事花不了两小时,回报率却最高。
FAQ
问:Flecs 的 Module 和 CMake 的库目标是什么关系?能划等号吗?
不能,它们是两层。库目标管的是「哪些代码被编译进来」,Module 管的是「哪些类型和系统被装进这个世界」。一个库目标里可以装三个模块,一个模块也可以横跨两个库。最省心的做法是让它们大致对齐(一个模块一个库目标),但别指望对齐了就没有问题,因为很多人踩的坑恰恰是「库链进来了,模块忘了导入」,然后系统一个都不跑,游戏一动不动,你查半天以为是逻辑问题。
问:同一个模块能导入到两个世界吗?
能,而且这是它的强项,前提是模块本身干净、不持有全局状态。服务器一个世界、客户端一个世界,各导入一份物理模块,两边的类型定义一模一样,但实体和状态完全隔离。做预测回滚的时候你会更需要这个能力,因为回滚本质上是把世界倒回若干帧之前(第 15 篇)。如果你的模块里有静态缓存,这一步会直接崩。
问:模块里的系统怎么保证执行顺序?
两层保险。第一层是调度阶段,把系统挂到明确的阶段上,物理积分和碰撞检测天然属于不同阶段。第二层是系统之间的显式依赖声明,让调度器知道谁必须先跑。别靠「注册顺序」这种隐式约定,那玩意儿在单人开发时能用,多人协作时一定翻车。还有一个土办法特别有效:在模块文档里画一张时序图,哪怕只有三行字,也比没有强。
问:我的模块该不该把内部组件暴露出去?
分情况。只读的、语义稳定的组件(比如位置、碰撞体尺寸)可以暴露,因为别人要渲染、要做 UI 都得看。内部的、随时可能重构的组件(比如求解器内部用的临时约束数据)坚决不暴露,外面要用就给一个查询入口或者一个事件。判断标准:这个组件明年会不会改名?会,就别暴露。
问:模块升级之后,老存档怎么办?
这是模块化最容易被忽略的成本。组件加字段、改名字、换语义,存档全废。我的做法是模块自己带版本号,存档里也记一份,读档时先看版本再决定要不要走迁移。真要长期运营,模块的序列化格式得当成对外 API 一样严肃对待(第 17 篇有完整的增量存档方案)。顺便说一句,这也是为什么我一直劝人「模块稳定之前别急着发布」,你发布的不是代码,是一份要维护好几年的契约。
最后留个开放问题给你:如果明天有个陌生人要用你的模块,你能在十分钟内让他跑起来吗?能,那它就是模块;不能,那它只是你写给自己的情书。