FlecsScript(DSL):声明式场景描述的潜力与争议 FlecsScript: The Promise and the Argument of Declarative Scene Description
世界已经是数据了,凭什么描述它的东西还得是 C 代码——一门 DSL 的真实价值,以及它要付的工具链账单
为什么「场景怎么写下来」是 ECS 里绕不开的第一块基石
先讲个场景,你应该不陌生。
你把 ECS 啃明白了,实体在 World 里跑得飞快,缓存友好,十万个单位不掉帧。然后策划走过来,说第三个炮台的射程想从 300 调到 320。你打开代码,找到那段生成炮台的初始化逻辑,改一个数字,重新编译,等四十秒,重启,跑过去看,位置不对,因为炮台是硬编码在关卡初始化的第三个循环里,你刚才改错了那一行。
第三次之后你就想掀桌子了。
这不是 ECS 的问题,这是「世界即数据」这句口号留下了半条腿。ECS 最核心的主张是:游戏状态就是一坨组件数据,系统只读这坨数据、只写这坨数据。可你很少问一个问题——这坨数据是谁写进去的?如果唯一入口是散落在十几个源文件里的生成调用,那「世界即数据」这条腿是瘸的:数据是数据,但描述数据的语言还是 C。
FlecsScript 就是补这条腿的东西。它是一个以 .flecs 为后缀的文本文件,写完之后会被编译成一棵实体树,运行的时候把这棵树铺进 World。你可以把它理解成「世界的一段文字快照」——不是描述怎么生成世界,是描述世界长什么样。
我的态度先摆这儿:FlecsScript 是 Flecs 里被严重低估、同时被严重误解的东西。低估的人觉得它是官网上的玩具演示,认真做项目的没人用;误解的人以为它是一门 Lua,想拿它写战斗逻辑。它两个都不是。它是「给不写代码的人准备的世界编辑入口」,而这个入口,你团队里有没有那个人,决定了它是基础设施还是一堆负债。
顺带说一句,这篇不太适合当入门教程看。想学语法,官方文档比我讲得细。这篇想聊的是更麻烦的事:这东西该在什么位置、不该在什么位置,以及一门只有框架认识的语言,到底要付多少账。
第一层:它不是脚本语言,是「世界的一段文字」
先说清它到底是什么,因为这里最容易跑偏。
你写一个 .flecs 文件,框架会先把它编译一遍。编译的产物不是字节码,是一棵实体树——注意,脚本自身也是 World 里的实体,脚本里每一个节点也是实体。等你让它运行,它做的事是「把这棵树上的东西铺进你的 World 里」。铺完之后,脚本就退场了,剩下的全是普通实体,跟你在代码里手动生成的实体没有任何区别。系统不认脚本,系统只认组件。
这个设计非常重要,因为它划清了一条线:脚本是入口,不是运行时。它没有自己的执行循环,没有自己的事件,没有自己的生命周期。它就是一支笔,写完就走。
它能表达什么
语法层面其实很小,一双手数得过来。我用大白话过一遍,你对照自己项目看看够不够用。
| 能写的东西 | 大白话解释 | 对 ECS 为什么重要 |
|---|---|---|
| 实体声明 | 写个名字,后面跟一对花括号,括号里一行一个组件 | 最基础的「存在一个东西」 |
| 按成员名赋组件值 | 组件的花括号里按字段名给值,不按顺序 | 以后给组件加字段,老脚本不会全错位。这一点看起来小,实际是「能不能长期维护」的分水岭 |
| 标签 | 只有名字没有内容的空组件 | ECS 里状态标记的正统写法,比布尔字段干净 |
| 关系(带目标的边) | 可以写成「某关系指向某个目标」,一个源对多个目标也能写 | 这是 Flecs 的看家本领(见第 08 篇),也是 JSON 派系完全表达不了的东西 |
| 嵌套 | 实体里再写实体,自动变成父子关系 | 写场景层级最自然的姿势,武器挂在手上、手挂在角色上,一写完就是一个树 |
| 冒号继承 | 名字后面一个冒号接另一个名字,表示「我是它的实例」 | 这就是 Prefab。写一次模板,用 N 次 |
| 批量作用域 | 一块作用域内,里面声明的实体都自动带上某个东西 | 一批敌人全打上同一个标记,少写几十行 |
| 变量、条件、循环 | 能算数,能判断,能循环生成一串 | 做「十个等级、每级血 +15%」这类递进内容时救命 |
| 断言 | 写完可以断言某个条件成立,不成立就报错 | 相当于给数据加了一层最简陋的单元测试 |
你看这张表,会发现它缺了一样东西:它没有「发生什么」。哪怕它有条件有循环,那也是「在描述阶段算一下」,算完就固化成一个静态结果。它描述的是「存在什么」,不是「发生什么」。理解这一点,你就不会想着拿它写战斗逻辑了。
玩家(其实是开发者)能感知到什么
从体验上说,它带来的是迭代节奏的量变——但这个量变足以引发质变。
你改一个数值,存盘,游戏里立刻变。这个过程从「四十秒编译 + 重启 + 走到那个位置」压缩到「一秒钟」。听上去只是省了四十秒,实际不然。四十秒的等待会让你「懒得改」,你会攒一批改动一起验证,于是每次验证都在同时改五个变量,改坏了不知道是哪个。一秒的等待让你一次只改一个,改完立刻知道对不对。调试的本质是控制变量,而控制变量的前提是试错成本足够低。
还有个更隐蔽的收益:文件本身就是文档。你想知道这关有几个炮台、每个炮台什么配置、谁是谁的子节点,打开这个文本文件从上往下读就行,不需要在脑子里模拟一遍那段嵌套三层的循环。这一点在接手别人项目的时候尤其值钱——我没见过谁能靠读代码快速搞懂一个关卡的实体结构,但我见过很多人靠读配置文件十分钟上手。
参数:什么时候编译,什么时候运行
这里有个绕不开的选择,我建议你一开始就想清楚。
方案 A:启动时编译一次,跑完就完事。适合发布版本。脚本编译加实例化是有开销的,虽然单个几百实体的脚本通常在毫秒级,但你要是有一千个脚本文件,那就是另一个故事了。上线版本更狠一点的做法是:开发期读脚本,打包时把脚本跑一遍、把结果序列化成快照,发布版本只读快照。这样连编译都省了。这套「烘焙」思路后面还会反复提到。
方案 B:每次改动就重编。适合开发期。但要注意「重跑」到底是什么意思,这个问题留到第四层细说,因为它是把很多人坑住的地方。
给个参考数字:脚本相关的耗时(编译 + 实例化)占总启动时间的比例,控制在 5% 以内比较保险。超了基本就说明你在运行期解析了太多文本,该上烘焙了。移动端尤其敏感,你在 PC 上感觉不到的 300 毫秒,到了低端安卓机上就是启动卡顿。
一个现成的商业证明:Unity 的 Prefab
别觉得这套东西新鲜。Unity 的 Prefab 就是「声明式实体定义 + 继承 + 覆盖」的商业化大型验证,而且它验证了整整十年。
Prefab 本质是什么?一个存起来的实体模板,场景里放一个实例,实例只记录自己跟模板不一样的部分(叫覆盖)。这跟 Flecs 里「冒号继承 + 在实例上重写组件」是同一套语义。Unity 后来把 Prefab 做成了变体(Variant,变体自己再被别人继承)、嵌套(Prefab 里塞 Prefab)、覆盖(override 可以在任意层修改任意字段)。为什么值得投入这么多工程资源?因为策划天天都在提这种需求:「所有敌人血量 +10%,但 Boss 不变」「这 20 个狙击兵的射程都改成 500,别的兵种别动」。没有这套机制,你只能复制粘贴,而复制粘贴的第二十天就会开始出「为什么这个敌人的血是旧值」的 bug。
但注意,Unity 在这里教给我们的最重要一课,不是「这个能力很值钱」,而是——能力不等于体验。Prefab 的语义 Flecs 第一天就有了,可 Unity 让策划真正用起来,靠的是拖拽、是覆盖面板、是「这个字段被覆盖了」的蓝色小竖条。FlecsScript 有语义,没有面板。这是全文要反复回来的一个点。
第二层:什么该进 DSL,什么该滚出去
这一层是实操里最容易翻车的地方。我见过太多项目,一开始「我们就用脚本写点关卡配置」,半年后脚本里长出了伤害计算、长出了掉落逻辑、长出了 AI 分支判断,然后没人敢动那个目录,改一行要祈祷。
所以先给一条判断标准,比语法重要一百倍。
判断标准:看改的人是谁
我一直觉得,讨论「该不该用 DSL」的时候,大家吵的其实是语法好不好看。这是跑偏了。真正的判据只有一条:这东西以后是谁来改?
如果改它的人不写代码,那它就该是声明式的、可热改的、不需要编译的。如果改它的人天天开着 IDE,那用宿主语言的数据字面量可能更划算——编辑器补全、类型检查、断点调试,全都是免费的。这个判断跟语言优不优雅毫无关系,纯粹是组织问题。
把它拆成四个问题,你拿项目里的每类数据过一遍,基本就能分完。
| 判定问题 | 倾向脚本 / 声明式 | 倾向代码 |
|---|---|---|
| 这东西多久变一次? | 一天改十次:数值、摆位、刷怪点 | 一年改一次:核心算法、伤害公式的骨架 |
| 谁改? | 策划、关卡、美术、mod 作者 | 程序自己 |
| 需要人肉 diff 吗? | 需要:要 review、要合并、要回滚、要 blame | 不需要 |
| 改错了代价多大? | 顶多是数值难看,游戏还能跑 | 崩游戏、坏存档、玩家投诉 |
适合进脚本的三类东西
第一类:初始场景。关卡里有什么、摆在哪、谁是谁的子节点。这是最正统的用法,也是最不会翻车的用法,因为它天然是静态的。
第二类:模板与原型。敌人模板、武器模板、技能模板。这里脚本的继承能力开始发威:写一个「基础近战兵」,然后「精英近战兵继承它、血翻倍、加一个护盾标记」,五行搞定。你要是用代码写,那就是两段长得几乎一样的初始化函数,半年后其中一段被改了另一段没改,谁也发现不了。
第三类:测试夹具。这一类被严重低估,我想专门说说。你要测「三个敌人围住玩家时,仇恨系统怎么分配目标」,用 C 手搓这个场景大概要四十行生成调用,而且这四十行会散落在测试代码里。用脚本写,可能就是十来行,一眼能看懂这个场景到底长什么样。测试代码最怕的就是「看不懂在测什么」,一旦看不懂,它就再也没人维护,最后被注释掉。声明式的夹具能救命,因为它长得像场景,不像代码。
不该进脚本的四类东西
每帧变化的。移动、AI 决策、冷却推进。脚本没有 tick 概念,你硬要拿它做这个,就是在用手电筒当发动机。
需要断点的。逻辑一旦复杂到你要单步调试,文本文件就是地狱。你在脚本里打了个日志,游戏卡在某个循环里出不来,你连停都停不下来。
热路径。脚本执行本身有解释开销,跑一次无所谓,跑六十次每秒就不行了。
复杂状态机。这条我要重点说。状态机写在代码里,你能抽成表、能用可视化工具、能写单测。状态机写在文本里,就是一堆条件套条件,三个月后没人敢动,因为没人能推出来「如果先触发了 A 再触发 B 会怎样」。这种代码的最终归宿是被注释掉然后重写。
案例拆解:RimWorld 的 XML Def 体系
要说清楚「声明式定义」这个方向的成败关键,绕不开 RimWorld。它是我见过把「内容即数据」做到最彻底、也活得最久的商业游戏之一。
机制细节。RimWorld 里所有能被玩家看见的内容——武器、动物、事件、研究项目、地形、甚至小人之间的关系类型——都是 XML 文件里的一个定义节点。每个节点有个唯一标识、一个显示名、一堆参数。继承怎么做?给节点写上一个父节点名,它就继承父节点的所有字段,自己只写差异。抽象父节点(只用来被继承、本身不会被生成出来的那种)有专门的标记。这套语义跟 Flecs 里「冒号继承」几乎是同一件事,只是换了个皮。
真正让我服气的是补丁机制。Mod 作者不需要改原版文件(改了就等于把原文件绑死了),而是写一个补丁操作,声明「在某某节点下面插入一段」「把某某字段替换掉」「把某某节点删掉」。加载器按顺序把所有补丁叠上去,最后得到一个合并后的定义集合。这套设计让上千个 mod 可以同时修改同一份数据,而不需要每个人去 fork 原文件。
设计意图。它把「内容」和「行为」彻底分了家。XML 只描述参数——这把枪射程多少、伤害多少、精度多少。而「开枪之后发生什么」这件事,写在 C# 的组件里。Mod 作者能改参数、能加组件、但改不了核心逻辑。这不是技术限制,这是刻意的权力划分:它让 mod 生态不至于互相残杀,也让玩家的存档不至于被一个烂 mod 永久污染。
收益。Modding 生态撑起来的。一个没学过编程的高中生,打开别人的 XML 看一眼格式,就能做出一把自己想要的枪。本地化也几乎是免费的——所有显示文本都是节点上的一个键,翻译团队直接把键抽出来做表,不用碰代码。这个生态大到什么程度?它反过来变成了游戏的护城河,很多人买这游戏就是为了玩 mod。
代价。也真够呛。XML 的冗余程度令人发指,一个稍微复杂点的武器定义能写几十行,全是重复的标签。没有变量、没有循环,你想做「20 把逐级递进的同系列武器」,只能复制二十遍然后手动改数字——当然,mod 作者最后会写个小工具去生成它,但那已经是另一回事了。更麻烦的是补丁顺序:两个 mod 都想改同一个字段,谁赢取决于加载顺序,玩家社群管这叫「mod 冲突」,作者唯一的解法是让 mod 作者之间互相约定加载先后。这玩意儿治标不治本,但十年过去了也没什么更好的办法。
对本文的启示。我认为这是全篇最值钱的一句话:声明式定义的成功,从来不取决于语法,取决于它能不能被第三方安全地改。RimWorld 的 XML 语法丑陋到极点,但它活了十年,因为它有补丁、有加载顺序、有本地化、有一个愿意为它写工具的一整个社群。FlecsScript 现在有语法,缺的正好是这一整层。你在评估它的时候,别拿语法去比,拿这张清单去比。
第三层:设计师友好 vs 不必要复杂性,这架到底在吵什么
这一层是争议的正面战场。我先把双方最狠的话摆出来,不粉饰。
反方的原话大意
社区里最常见的抱怨,我复述一下大意:我已经被 C 折磨得够呛了,现在你让我为了改一个数值,再学一门只有你这个框架认识的语言?它没有补全,没有跳转到定义,报错告诉我第 17 行有问题但不说哪个词有问题。我用 JSON 至少编辑器还能给我括号匹配和结构折叠。
这个抱怨不是矫情。一门语言的价值,很大程度取决于它周围的工具链,而不是语法本身。C 之所以好用,不是因为 C 语法优美(一点都不优美),是因为它背后有三十年积累的编译器、调试器、静态分析、IDE。一门新的 DSL,语法可以三天学会,但它得从零开始攒这一整套东西,而且它只有 Flecs 一个用户,所以不会有谁专门为它做插件。
正方的原话大意
另一边的说法是:你迟早会需要它。因为你用 JSON 描述实体关系的时候,最后一定会发明一套自己的约定——一个字段存父节点、一个字段存模板引用、一个数组存组件列表、再加一个类型标记字段。恭喜,你手搓了一门比 FlecsScript 更烂的 DSL,而且它没有编译器帮你检查,拼错字段名要到运行期才炸。
这个也不是抬杠。我确实见过项目用 JSON 描述场景,前三个月很清爽,到第八个月那个文件变成了一坨嵌套七层的怪物,因为大家开始往里塞各种「约定字段」,而这些约定没人写下来,只有最早写它的人知道。
我的判断(有倾向,你可以不同意)
两边都对,但他们在说两个不同的项目。
如果这个项目是「我一个人,所有数据我自己写」,那 FlecsScript 的收益约等于零,甚至为负。你直接在代码里初始化 World 更快,类型检查免费,重构工具齐全,出问题能断点。这是我的真实经历——我第一个 ECS 项目里所有初始实体都是代码生成的,跑了半年没觉得有任何问题,因为就我一个人,编译一次四十秒我还能去泡杯茶。后来我兴冲冲地把它迁移到文本脚本,用完一周就撤了:因为我发现我在给自己找事做,那些数据根本没人改,只有我改,而我改的时候开 IDE 更快。
反过来,只要项目里有第二个人,且这个人不改代码,那 FlecsScript 立刻从玩具变成基础设施。因为这时候你要解决的根本不是「怎么写更爽」,而是「怎么让改数据的人不碰代码也能改,改完不用等编译,改坏了我还能一眼看出他改了什么」。这是组织问题,语法解决不了。
所以别问「FlecsScript 好不好用」,先看你的团队构成。
| 你的状态 | 建议 | 为什么 |
|---|---|---|
| 单人原型,三个月内 | 先别用,在代码里生成实体 | 学语法的时间 > 省下来的时间,而且你会频繁重构组件,脚本天天跟着炸 |
| 单人,但实体模板超过五十种 | 用,但只当「数据文件」用 | 手搓五十个模板会疯,脚本在这里的性价比是正的 |
| 二到五人,团队里有策划或关卡 | 用,并且要求策划自己能改 | 编译等待是团队税,一个人等四十秒是四十秒,五个人等就是三倍工时 |
| 五人以上 | 用,但必须配规范 + 一个能看到实体的工具 | 没规范就是灾难,十个脚本作者写出十种风格 |
关于「不必要复杂性」,我想说句实话
FlecsScript 确实有过度设计之嫌,我不打算替它辩护。
问题出在表达式、变量、循环、条件这几样东西上。它们一进来,这个文件的性质就变了——它不再是数据,它是一门语言。而一门语言就需要一整套配套:语法高亮、格式化、错误恢复、跳转、重构。Flecs 是一个 C 库,它背后没有一个 VS Code 团队。这是笔真实的账,账期还挺长。
但你说砍掉这些就更好吗?也未必。如果砍到只能写静态摆位,那很多配置内容就得退回代码,比如「生成十个等级,每级血量乘一点一五,名字后面加罗马数字」这种。纯静态描述写十遍确实能写,但改一次基础数值就要改十遍,等于把维护成本乘十。所以这是个权衡,不是谁犯了错。
我的看法是:控制它的密度。一个脚本文件里,条件、循环这些「语言特性」占的篇幅,最好压在一成以内。超了,说明你已经在拿它当编程语言用了,那就该把这段逻辑搬回 C。这条线很好用,你回头审视自己的脚本目录,一眼就能看出哪些文件长歪了。
案例拆解:Factorio 为什么不造 DSL
跟 RimWorld 形成完美对照的是 Factorio。这也是个真实商业游戏,modding 生态同样巨大,但它选了另一条路。
机制细节。Factorio 有一段专门的「数据阶段」,在游戏启动、加载任何存档之前运行。这个阶段里跑的是一个完整的 Lua 环境,所有物品、机器、配方、科技,都是往一个全局表里塞数据条目。官方提供批量注册的入口,mod 就顺着往表里推。想改别人的定义?也可以,直接拿到那张原始表,想改哪个字段改哪个字段。因为就是 Lua,所以你能写循环、能算数、能抽公共函数、能引第三方库——「做二十级逐级递进的机器」在 Factorio 里就是一段循环,不用复制二十遍。
设计意图。它的决策很清晰:不发明新语言,直接借用宿主语言最成熟的表达力和最成熟的工具链。你不是要写数据吗?那就用 Lua 的表字面量写,反正它本来就长得像配置文件。
收益。mod 生态极其庞大,而且平均质量高——因为写 mod 的人本来就是会写 Lua 的,编辑器补全、语法检查、调试器、格式化工具,全都是现成的,一分钱不用花。你想给 Factorio 做一套大型 overhaul mod,Lua 的表达力绰绰有余。
代价。门槛直接把非程序员筛掉了。你想改一个配方的时间,得会写 Lua 表。翻译和纯数值微调没法靠自己完成,要么等社区做工具,要么就干瞪眼。这就是为什么 Factorio 的 mod 生态虽然庞大,但「一个高中生花一下午做出一把自己想要的枪」这种事,不会发生在它身上——那正是 RimWorld 的地盘。
三方对比,结论就出来了
| FlecsScript | RimWorld 的 XML | Factorio 的 Lua 表 | |
|---|---|---|---|
| 为谁设计 | 改数据但不写代码的人 | 完全不懂编程的 mod 作者 | 会写代码的 mod 作者 |
| 表达力 | 中(能算,但有边界) | 低(纯静态,靠补丁) | 高(图灵完备) |
| 工具链 | 薄,靠框架自己攒 | 薄,但有十年社区工具 | 厚,全是现成的 |
| 上手门槛 | 中 | 极低 | 高 |
| 适合谁 | 团队里的策划 / 关卡 | 海量第三方 | 程序员同行 |
结论:选 DSL 还是选宿主语言,本质是在选「我要不要为那些不写代码的人,付工具链的账」。付了,你就获得一个能自己改内容的人;不付,你就省了维护工具链的力气。没有免费的选项,只有你更缺哪个。
顺手补一句 Godot 的对比,很短:它的场景文件也是纯文本,节点树加属性覆盖,人肉 diff 很友好,版本管理不会打架。但几乎没人手写它,因为配套的编辑器太顺手了。这告诉我们一个残酷的事实——文本 DSL 和它的工具是一份套餐,不能只卖一半。只卖一半的结果,就是文件在那里,但所有人还是绕过去用代码生成。
第四层:运行期形态——热重载、沙箱,以及「改个名字全爆炸」
前三层聊的是「写」,这一层聊「用」。这里藏着几个真正会让你半夜爬起来改代码的坑。
热重载:重跑到底是什么意思
脚本是数据,所以能热重载。开着游戏,改脚本,存盘,实体变了——这是它最爽的地方。但「重跑一次脚本」这句话有两个完全不同的含义,差别巨大。
含义一:推倒重来。把上次这个脚本生成的所有实体全删掉,重新生成一遍。简单、可靠、好实现。代价是运行期状态全丢——玩家给那个炮台升的级没了,那个怪身上挂的持续伤害没了。开发期改场景无所谓,只要你别在玩到一半的时候改。但如果这个脚本还兼管着一部分运行期状态,那就等着出诡异 bug 吧。
含义二:合并更新。脚本里的节点跟 World 里的实体有一一对应的稳定身份,改了哪个字段就更新哪个字段,实体本身不重建。这个明显更难做,但它才是「改了立刻看到」的正确姿势,因为它不打断游戏状态。
这两种做法选哪个,取决于你把脚本用在哪。我建议是这样的分界:只管静态初始状态的脚本,用推倒重来;会跟运行期状态纠缠的脚本,宁可慢一点做合并更新。如果你发现自己的脚本既想热重载又不能重建实体,那说明这个脚本管得太多了,该拆。
沙箱:能跑循环,就有死循环
脚本能写循环,那就必然面临一个问题:写错了怎么办。一个条件判断写反,进入了永远出不来的循环,游戏直接卡死,你连关窗口都不一定来得及。
Flecs 在运行时这块留了余地——可以限制执行、可以给脚本喂不可信输入。这个能力对于一个你可能没往那边想的场景很关键:玩家自制内容。如果你打算让玩家写点东西塞进游戏里(自定义关卡、自定义单位),那你就需要一个能限制执行时间、能拒绝危险操作的沙箱,否则一个恶意脚本就能把玩家机器干掉。
但同样的话又说回来了:能力有了,配套没有。「玩家写脚本」这件事要真正跑起来,你需要错误提示、需要一套简化的对外语法、需要防止玩家看到你内部的组件名。这些都得你自己搭。所以现阶段我的建议很朴素:脚本先只给开发团队用,别急着开给玩家。等你自己用着顺手了,再考虑往外放。
改名爆炸:文本 DSL 的通病
这是我最想提醒的一个坑,因为它不显眼,而且爆发时极其惨烈。
脚本靠名字引用组件。你把代码里一个组件从「生命值」改名成「血量」,编译通过,代码全绿——但项目里那三百个脚本文件全部静默失效。更糟的情况是它不报错,只是那些实体少了组件,于是你运行游戏,发现敌人打不死,你花两个小时才想起是改名。
Unity 是怎么解决这个的?用 GUID。每个类型有个唯一编号,序列化文件里存的是编号不是名字,所以你怎么改类名都不影响,只要你没删掉那个类。代价是 YAML 文件变得完全不可读,全是编号,人肉看一眼就头晕。这是个非常典型的交易:可读性和抗变更,你要一个。
FlecsScript 选了可读性,那就得自己承担抗变更的责任。缓解手段有几条,都是工程纪律,没有什么黑科技:组件命名有规范,别随手改;改名前先搜一遍脚本目录;最靠谱的是在持续集成里跑一遍全量脚本编译,把「静默失效」变成「构建失败」。这条我真心建议做,成本很低,一次就能救你一整天。
参数:几个我踩过坑的区间
| 参数 | 含义 | 建议区间 | 超标会怎样 |
|---|---|---|---|
| 单文件实体数 | 一个脚本里声明多少实体 | 20 到 50 | 过两百,diff 全是噪音,代码审查没人真看 |
| 单文件行数 | — | 两百行以内 | 超了,说明这个文件想管的事太多 |
| 继承链深度 | 一个模板往上追几层 | 三层以内 | 超过三层,没人能回答「这个字段最终是多少」 |
| 嵌套层级 | 父子关系嵌了几层 | 四层以内 | 调试时实体路径长到看不清 |
| 脚本实体占比 | 脚本生成实体 / 世界总实体 | 原型期可以 100%,发布期建议 5% 以下 | 发布期还高,说明你在运行期解析文本 |
| 语言特性密度 | 条件循环行数 / 总行数 | 一成以下 | 说明你已经把它当编程语言用了 |
| 热重载响应时间 | 存盘到看见变化 | 一秒以内 | 超过一秒,大家就退回去改代码了,工具白做 |
案例拆解:Unity SubScene 与 Bevy 的场景之争
两个对照案例,正好能说明「运行期形态」这一层的两种主流答案。
Unity 的答案:烘焙。Unity 在 DOTS 体系里搞了 SubScene——你在编辑器里摆好场景,它不会在运行期变成一堆实体,而是先「烘焙」成优化过的 ECS 数据资产,运行时直接加载。这一步是整个 DOTS 工作流里最关键的一环:把作者期的声明式描述,转成运行期的最优内存布局。作者期讲究的是快和直观,运行期讲究的是紧凑和缓存友好,这两个目标是打架的,烘焙就是在中间搭桥。
代价也很明显。你改一个数值,要等烘焙跑完,那感觉跟你最初想逃离的「编译四十秒」又回来了。Unity 用户对 SubScene 的抱怨主要就集中在这——它把迭代循环又拉长了。这是个很诚实的教训:烘焙解决了运行期性能问题,但它把成本转嫁回作者期了。
FlecsScript 完全有能力走这条路——脚本生成实体,实体序列化成快照,发布版本读快照。但这条路要你自己搭,框架只给你锤子,不给你流水线。
Bevy 的答案:拉回代码里。Bevy 那边有过一次很有意思的转向。早期它用 RON 格式的场景文件,靠反射把文件里的组件名映射到类型上。听着挺合理,实际用起来的痛点很一致:没有补全,拼错要到运行期才知道,大场景文件没法管理。后来社区折腾的方向是把场景描述拉回宿主语言——用宏在代码里写场景,白拿完整类型检查和 IDE 支持,写错了编译期就骂你。
这个转向值得琢磨。一个 ECS 框架,在认真试过「造一门场景语言」之后,最终选择回到宿主语言里去。这说明什么?说明在没有配套编辑器的情况下,手写文本场景的痛,会大于它带来的灵活性。
那 FlecsScript 是逆潮流而动吗?是。它的赌注是:我的语法足够小,小到学起来不吃力,小到不需要一个完整 IDE 也能用。这个赌注成不成立,不取决于作者,取决于第一批真正用它做出游戏的人能不能带动工具生态。这话听着有点无奈,但生态就是这么长出来的。
参数化设计工具:一张决策表,一张归属表
上面四层讲完,落到能用的东西上。这部分你可以直接拿去跟团队对。
表一:内容归属表
把项目里的数据种类列一遍,逐行判断。这张表定下来之后,写进团队规范,别让人凭感觉越界。
| 内容类型 | 进脚本 | 进代码 | 备注 |
|---|---|---|---|
| 关卡摆位、出生点 | 是 | 最正统用法,改得最频繁 | |
| 敌人 / 武器 / 技能模板 | 是 | 但必须配一个启动期校验,检查必填字段 | |
| 掉落表、成长曲线 | 是 | 曲线数据建议用外部表格工具生成后再贴进来 | |
| 教程关、演示场景 | 是 | 改动最多,最需要热重载 | |
| 测试夹具 | 是 | 被低估,收益极高 | |
| 每帧逻辑、AI 决策 | 是 | 脚本没有 tick 概念 | |
| 存档 / 运行期快照 | 是 | 跟第 17 篇的序列化是一套东西,别和脚本混用 | |
| 网络同步字段的标记 | 一半 | 一半 | 标记写在代码里,脚本只覆盖具体值,别让脚本决定谁能同步 |
| 数值公式的骨架 | 是 | 公式是代码,公式的参数才是数据 |
表二:规模与耗时预算
这几个数字不是什么金科玉律,是我自己项目里定下来之后觉得舒服的位置,你可以上下浮动三成。
| 指标 | 建议值 | 度量方式 |
|---|---|---|
| 脚本数量 | 原型期可以几十个随便造;中型项目控制在百来个,多了要分目录 | 数文件 |
| 脚本编译 + 实例化耗时 | 占总启动时间 5% 以内 | 在启动流程里插计时 |
| 单脚本重编译时间 | 100 毫秒以内(几百实体量级) | 热重载时打日志 |
| 全量脚本校验耗时 | 持续集成里 30 秒以内 | 构建流程计时 |
| 脚本目录占比 | 别超过仓库文件数的 20% | 超了说明你把代码也塞进去了 |
表三:三条实施路径
你处在哪个阶段,走哪条路。
- 纯代码路线:所有实体在代码里生成。适合单人、短周期、组件还在剧烈变动的项目。别觉得这低级,它是最省心的。
- 混合路线(推荐多数项目):逻辑和动态生成在代码里,静态内容和模板在脚本里。两边通过组件名对齐,中间加一层启动校验。
- 烘焙路线:开发期读脚本,发布时把结果序列化成快照,运行时读快照。适合要上线、要控启动时间、要控包体的项目。这条路多花两天工,后面省半年。
两条阅读路径:速览版与深入版
初级路径:如果你只想今天就动手
五条,按顺序做。
一、先诚实评估:你的项目里有没有第二个人在不写代码的情况下改内容?没有的话,这篇看到这儿就够了,回去在代码里生成实体,别折腾。有的话,继续。
二、写一个十行的脚本试试。要求:三个实体,其中一个是另一个的子实体,其中一个带着组件值。跑起来,在调试器里看看世界里的实体树长什么样。这一步会让你对「脚本是入口不是运行时」这件事有身体记忆。
三、立刻做一个全量校验入口。不要等内容写多了再做。一个能遍历目录、逐个编译所有脚本、失败就报出文件名和行号的入口,二十行的事,能救你无数次。
四、头一个月,禁止在脚本里写条件和循环。先用纯声明把内容撑起来。等你能明确说出「我确实需要一个循环来生成递进内容」的时候,再放开。这条纪律能防止脚本长成怪物的第一道防线。
五、配一个能看到实体的工具。Flecs Explorer 就能用(第 18 篇专门讲),或者你自己写个导出,把某个脚本生成的实体树打成文本。看不见的东西是不可维护的,这句话放之四海皆准。
进阶路径:如果你在认真考虑「要不要给游戏做一门 DSL」
那你要回答的就不是 FlecsScript 好不好用,而是五个更硬的问题。
谁会改它?具体到人,不要写「策划」这种抽象名词。如果答案是「我,用 Vim」,那这门 DSL 就不该存在。
改错了会怎样?如果改错会导致崩溃、坏存档、玩家投诉,那这门 DSL 必须带静态校验和沙箱。这两样东西的工作量,通常比 DSL 本身还大。
需要 diff 吗?需要的话,格式必须是文本、必须一行一属性、必须稳定排序。这条要求会反过来约束你的语法设计,别掉以轻心。
配套编辑器谁做?这是最容易被跳过的。你以为你做的是语言,实际你在做一个编辑器加一个编译器的套餐。没有编辑器,最后大家还是回去写代码。
工具链谁维护?三年后它还活着吗?如果你的 DSL 挂了,你的内容资产还救得回来吗?
最后这个问题引出我最想给的一条工程建议:把脚本当「开发期资产」,别当「发布期格式」。发布版本走烘焙,读序列化快照。这样一来,即使哪天这门 DSL 被砍了,你的游戏还能跑,内容还能改。这条建议不管 FlecsScript 未来如何演化,都成立。
几个我见过别人踩、自己也踩过的坑
坑一:把游戏逻辑写进脚本。这是最经典的一条,也是最致命的。起步时是「我就写个简单的巡逻路径判断」,三个月后这个文件里有十二层条件嵌套,只有原作者能看懂,而原作者已经离职了。判别方法很简单:如果这段内容你觉得「需要写测试」,那它就不该在脚本里。
坑二:以为脚本能替代编辑器。不能。脚本让你「写」得快,但不让你「看」得快。你依然需要一个能实时浏览实体树、能改值、能看到组件内容的工具。很多人做完脚本就以为万事大吉,结果策划天天问「我这把枪到底生成了没有」,因为他们看不见。
坑三:在脚本里硬编码实体名,然后拿名字去查。今天能用,明天有人改了个名字,查询全部返回空。更糟的是它不报错,只是「安静地什么都没发生」,于是你花了半天以为是自己逻辑写错了。这类 bug 的排查成本极高,因为它不制造错误,它制造沉默。
坑四:把脚本当存档格式。这两件事长得像,本质完全相反。脚本是输入,写给人看,可以有注释、有变量、有继承;存档是输出,是运行期状态的快照,必须自洽、必须能跨版本兼容。你要是拿脚本存盘,第一次改组件字段,所有老存档全废。第 17 篇讲的那套序列化,跟这个是两条线,别混。
坑五:以为它是一门外挂脚本语言。它不是。它没有事件循环、没有协程、没有跟引擎的绑定。你想做「逻辑热更」,还是去找 Lua 那类方案。FlecsScript 的位置是「世界怎么被描述」,不是「世界怎么被驱动」。
顺带说一句我自己的翻车经历,图个乐:我曾经想把整个战斗配置(技能、Buff、命中判定参数)全搬进脚本,觉得统一管理多香。搬完第二周就发现,技能之间有大量的条件互斥关系,我不得不在脚本里写一堆条件判断去保证「这两个 Buff 不能同时存在」——而这本来应该是几条断言加一段代码的事。最后我把它拆回去了:脚本只留静态参数,互斥规则回代码写断言。那两周的教训就一句话:DSL 不是万能的容器,别什么都往里塞。
FAQ
Q1:FlecsScript 能替代 Lua 之类做玩法脚本吗?
不能,定位完全不同。它没有持续运行的事件循环,没有跟引擎的深度绑定,调试体验也不在一个量级上。它是一次性的实例化工具:跑一遍,把东西铺进世界,然后退场。你要的是「每帧驱动逻辑」,它给的是「一次性描述世界」,别指望它兼职。
Q2:它和序列化是同一套东西吗?
不是。脚本是输入,序列化是输出。脚本写给人看,所以有注释、有变量、有继承、有留白;序列化是给机器看的快照,不讲人情。它们底层可能共用一部分表达机制,但用途是两个方向。混用的后果,就是我上面说的「改一个字段,老存档全废」。
Q3:性能怎么样?会不会每帧都在跑脚本?
不该每帧跑。它跑在加载阶段,跑完就走。如果你发现自己需要每帧重跑脚本,那说明你在用它做状态机,这时候应该停下来重构。真要给个数:几百实体的脚本,编译加实例化通常在毫秒级;但你有一千个脚本,累计起来就该考虑烘焙了。
Q4:策划和美术真的能上手吗?
说句实话,能不能上手,九成看报错信息说不说人话。语法本身三天就能学会,一个花括号一个冒号,能有多难。真正劝退人的是「第 17 行出错」这种报错——第 17 行有三十个字符,他不知道自己错在哪。所以如果你要推这个工具,第一优先级的工程投入不是语法糖,是把报错做成人能看懂的话。这件事的性价比,比什么功能都高。
Q5:这东西会不会哪天被作者砍掉?
这是所有「框架自带 DSL」的通用风险,我不打算安慰你。缓解方式我前面说过一遍,这里再说一遍,因为它重要:把脚本当开发期资产,发布版本走烘焙快照。这样即使这门语言明天被移除,你的游戏还能跑,内容还能改,只是迭代变慢。所有跟框架深度绑定的东西,都该留这么一条后路。
最后留个我觉得挺反直觉的观察:真正能让 FlecsScript 活下去的,可能不是它的语法设计,而是第一个用它做出来的、能被别人下载来玩的游戏。工具链是被需求喂大的,不是被设计出来的——RimWorld 的 XML 丑了十年没人管,因为有几万人在用它做 mod。所以如果你问我这东西未来会怎样,我的答案是:别看文档,看三年后有没有人在论坛上抱怨「我的脚本报错看不懂」。有人抱怨,说明它活着。