照着《大灾变》搭引擎:一个自研 roguelike 的系统构成
本文目录
前两篇讲了 Outland 的世界观和音乐。
这篇讲引擎本身——一个 C++20 + SDL2 自己写的 roguelike 引擎,以及它为什么要照着 CDDA 搭。
上一句其实有个问题:“照着 CDDA 搭”到底是什么意思?
CDDA 是开源项目,代码在那儿摆着,我完全可以读。但我并不想抄它的代码——它的历史包袱很重,几十年演进下来很多设计是被迫的。
我想学的是它的方法论:它怎么把”活过这个末日”这件事拆成一套能算的系统。
一、先说清楚抄了什么、没抄什么
一句话:引擎自己写,方法学 CDDA,内容全原创。
具体分三层:
| 层 | 做法 |
|---|---|
| 引擎核心 | 自己写。ECS、地图、时间、FOV、寻路、RNG、数据层、渲染,都是自己搭的 |
| 模拟方法 | 学 CDDA。回合调度、身体部位、体征互咬、物品生成、怪物 AI 这些做法照着它对标 |
| 内容 | 全原创。八个世界的世界观、物品、怪物、配方,一个都不抄 |
唯一一个直接拿来用的第三方是 EnTT(ECS 容器库)。用它的理由是:这是”容器”,不是”引擎”。渲染、地图、时间、模拟、数据层这些核心逻辑还是自己写的。设计上 ECS 接口和 EnTT 是解耦的,哪天要换,改一个地方就好。
(还有一个技术选型纠结了一会儿:一开始考虑过自研最小 ECS,可控但费时。后来决定用 EnTT——它能省下几个月的踩坑时间,而我想把时间花在模拟设计上,不是花在容器实现上。)
二、时间:一切从 moves 开始
如果要挑一个”最 CDDA”的设计,我会选时间系统。
它看着不起眼,但整个游戏的手感都从这里来。
规则是:
- 1 turn = 1 秒游戏时间
- 每个实体有个
speed属性,默认 100 - 一个标准速度的实体走一格 = 消耗 100 moves
- 玩家行动花了 N moves → 其他每个实体按
N × (speed/100)累积 moves - 累积到够自己一个动作的实体依次行动 → 回到玩家
这意味着什么?速度不是”行动条快慢”,而是”时间配额”。
一个 speed 200 的怪物,你走一格它就能走两格。一个 speed 50 的僵尸,你走两格它才动一次。所有实体共用同一条时间轴,按自己消耗的速度去”花”时间。
实现上用一个优先队列按”下次行动时刻”排序,能高效调度任意数量的实体。
为什么不直接用”回合数”?
因为 CDDA 式生存模拟里,动作的耗时是不一样的。撬锁要几十秒,包扎伤口要几分钟,睡觉要几小时。如果只有”回合”这个粒度,这些动作没法自然地嵌进同一个时间轴里。
用 moves 制之后,一个”缝合伤口”的动作消耗 3000 moves(30 秒),在这 30 秒里,附近的怪物积累了自己的 moves,各自行动好几次。你包扎的时候真的会挨打。 这才是我们要的手感。
三、地图分三层,只模拟你身边
CDDA 的地图是个三层抽象,这设计很聪明:
| 层 | 是什么 | 粒度 |
|---|---|---|
| Overmap | 抽象世界大地图(城市、森林、道路的布局) | 1 格 = 1 个区块 |
| Submap | 实际可玩的地图块,按块分片 | 格级 |
| Tile | 单格:地形 + 家具 + 场地 | 最小单位 |
单格(Tile)本身就是三层内容叠在一起:
- terrain(地面/墙/水)—— 决定能不能走
- furniture(门/桌/床)—— 可以交互的东西
- field(火/烟/血迹)—— 可以叠加、会随时间演化的东西
这个”三层叠一个格”的设计我特别喜欢,因为它让”地上有滩血、血上烧着火、火中间有张桌子”这种状态变成了自然而然的事,不需要任何特殊处理。
现实气泡(Reality Bubble)
三层之上还有个关键设计:只有玩家附近的实体被真正模拟。
具体说,玩家周围一定范围内的实体照常逐格 tick、寻路、攻击;范围之外的实体冻结或者低频 tick,怪物做”抽象推进”(“这些僵尸朝你的方向移动了一段”),不做逐格模拟。
这是硬核模拟和性能之间的妥协点。没有这个,一个上千只怪的世界根本跑不动(尤其还想着以后上移动端)。
而且这个设计还有个副作用挺好的:远处的世界”在动,但动得很粗糙”,玩家走回去的时候,世界不是死的,也不是被精确算过的——有种”你没看的地方发生了些事”的感觉。
四、生存模拟:体征互相咬合
这块是引擎的”魂”。CDDA 最难学的也不是数值,是系统之间怎么互相咬。
核心是一个 Vital 组件,按时间 tick 递减或结算。列一下维度,感受一下这个密度:
| 体征/资源 | 影响 | 来源 |
|---|---|---|
| 饥饿 | 空腹掉速,饿死 | 随时间涨,进食降 |
| 口渴 | 比饥饿更快致命 | 喝水 |
| 体温 | 过冷过热致伤致死 | 环境温度 × 衣物保暖 × 活动代谢 × 潮湿 |
| 疲劳 | 降命中、降速度 | 持续活动累加,睡眠清除 |
| 疼痛 | 降命中、降情绪 | 受伤产生,随时间缓降 |
| 情绪 | 影响专注和速度 | 事件增减,随时间回中 |
| 体力值 | 一切动作的底层资源 | 动作消耗,时间/休息/睡觉恢复 |
| 法力 / 元炁 | 施法资源(魔法和道术共用) | 施法消耗,冥想/打坐/丹药恢复 |
| 人性 | 精神非人化 | 植入体消耗;娱乐/信仰/丹药恢复 |
| 信仰 | 驱动奇迹 | 祈祷、仪式、经历 |
| san 值 | 理智稳定 | 超自然、恐怖、魔变下降 |
注意”体力值”那一格的描述:一切动作的底层资源。这句话是关键。
在 CDDA 式设计里,体力不是一个孤立的条,它是所有动作的通行费。你跑、你挥刀、你砸门、你撬锁,全都在扣它。饿着肚子挥刀的代价,不是”伤害减 5”,而是”你体力恢复得慢,所以下一刀挥得更虚”。
系统之间不是各算各的,是串起来的。
身体部位也一样:头、躯干、左右臂、左右腿、眼、口、手、脚,每个部位有独立的 hp 和状态(出血/骨折/感染)。伤害打中哪个部位要判定——命中躯干可能致命,打断腿你就只能爬。
状态用”效果栈”
所有负面状态(出血、感染、骨折、辐射病、变异)不是布尔值,是带修饰器的效果栈:每个 effect 携带对体征和属性的增减量,系统每 tick 结算一遍,检查触发条件。
比如”伤口感染”这个 effect,它会持续扣健康、累积一个内部计时器,到阈值就引发”发烧”。发烧不是个状态,是感染跑到一定程度的结果。
五、确定性随机:为什么 RNG 也要认真做
这个可能看起来是过度设计——随机数有什么好讲的?
但 roguelike 有个硬需求:读档重来必须能复现。
如果一局游戏里”打这一刀中不中”和”开这个箱子出什么”共用一条随机流,那玩家存档、读档、再攻击一次,可能把箱子的掉落也变了。这会让”读档试一下”变成一种可以利用的作弊。
所以做法是:多流随机。
- 底层用 xoshiro256**(快、质量好)
- 世界生成、战斗命中、掉落、事件各用独立流(从主种子派生)
- 存档保存每个流的完整状态
这样”读档重来”只影响当前动作那一条流,不会污染别的判定。
世界生成也用固定种子——同种子同世界,方便测试复现,也方便以后做地图分享。
六、交互:i 和 e 两个中心
这是我被明确要求推翻重做过的一块。
最初的交互是散的——每个功能一个键:a 使用、w 装备、v 装配、b 建造、c 关闭……
后来被否了。理由是:CDDA 的精髓是”中心化交互”。
也就是两个键:
- i(物品中心):在背包里选中一个物品 → 弹出这个物品的全部可能动作(使用/穿戴/手持/装填/改装配件/拆解/查看详情/丢弃/和当前装备比较)
- e(环境交互中心):对当前格和周围格做上下文感知(捡起脚下物/检查/开炉子/上下床/检查载具/开关门)
为什么这样更好?
因为它是上下文驱动而不是按键驱动。按键驱动的问题是:玩家得记住”锤子要用 w 还是 a 还是 v”,而且一个键只能对应一种动作。上下文驱动的问题是自动的——选中锤子,菜单里自然就有”用它砸东西 / 用它修理 / 把它当武器”这些选项。
键位也跟着对齐 CDDA:w 手持、W 穿戴、g 捡起、o 开、c 关、r 装填、x 查看……大小写复用键位。
七、踩的坑
技术这块,有几个坑印象特别深。
1. 一个哨兵值造成的整个面板失效
鼠标悬停在地图上应该显示那格的详细信息。我把 hover_x_ < 0 当作”没有悬停”的哨兵值。
结果:世界坐标可以是负的(截图里出现过 x = -17)。玩家往地图左上角看,悬停面板就不显示了。
而且它只在鼠标事件里更新,所以镜头一滚动,显示的还是个过期的格。
修法是加一个独立的 hover_valid_ 标记,每帧按当前镜头重算。
教训:拿一个业务上合法的值当”空/无效”哨兵,迟早会炸。
2. 时钟初值引发的开局饥饿
新角色一开局就有饥饿口渴,很快营养不良。
查了半天,根因是:时钟初值设成 43200(也就是 12:00),但记录”上次结算时间”的变量是 0。
于是第一帧把”从 0 到 12 点”这 12 小时当成经过时间,一次性扣掉了一大堆体征。
修法就是时钟设置完之后同步那个变量(3 处 + 读档路径都要改),再把开局的热量/水分池填满。
这个 bug 特别典型:两个本该同步的变量,只在正常路径上同步了。
3. 不喝水 1.25 天渴死
数值调试阶段,测试发现不喝水大概一天多一点就渴死。
算了一下:耗水速率设的是 1800/天,但一次饮水补的量(quench)只有大约 70。
这意味着玩家每天得喝 26 次水。
改成 250/天之后,差不多一天喝三四次,不喝水约三天渴死——这才合理。
教训:数值不能只看单项,得看”补一次的量 ÷ 每天的消耗率”这个比值。
4. 武器技能形同虚设
近战结算里完全没读技能,只有枪械影响射击命中。
也就是说你练了一辈子刀法,砍出来的伤害和不练是一样的。
修法是根据武器伤害类型(斩击/刺击/钝击/拳脚)分别加技能伤害。
5. 测试脚本把自己的编译失败吞了
构建脚本里写着 bash build.sh | tail。
某天发现这个命令的 $? 拿到的是 tail 的退出码,不是 build.sh 的。
也就是说——编译失败的时候,这个命令返回成功。 这个坑掩盖了很久的构建问题。
修法是让构建脚本自己记录真实退出码。
(顺带一提,这也是为什么现在我要求”构建必须出现 BUILD EXIT: 0 才算过”——不能只看命令有没有报错。)
八、现在做到哪了
src/game/ 里那个主文件已经一万两千多行了。这个规模下有个实际的开发约束:不能整个文件读。
所以工作流是 grep 定位行号 + sed 只读那一段。这条规则也写进了给 AI 协作者的守则里。
已经落地的系统(大致按时间):
- 世界生成、回合与时间调度
- 身体部位、伤势、状态效果栈
- 生存体征(饥饿/口渴/体温/疲劳/疼痛/情绪)
- 营养与代谢(宏量热量 + 五项微量营养素 + 缺乏症)
- 疾病系统(病原体、感染病程、免疫、药剂)
- FOV / 光照 / 记忆地图
- 物品与装备(版本化的物品实例)
- 容器与液体(容积、嵌套、液体量)
- 制作与建造(配方、材料组、长耗时活动)
- 结构生命周期(搭建→维护→崩塌)
- 载具(部件网格、燃料、电力、生命周期)
- AI(网格视线、听觉、驯服、跨楼层)
- NPC 与对话交易
- 采矿与采集(资源枯竭)
- 八个世界各自的专属机制(部分)
还差什么:
拿内容量来说,配方现在只有十几条,而 CDDA 有大约五千条——这是最大的体量短板,也是接下来要长期补的。
最重的那几块(星图与飞船、跨世界势力,以及前面提过的终局内容)都放在了最后。因为它们是”重系统”,得等前面的地基稳了再动。
世界观那边倒是全定稿了——八个世界每份都写了灾变形态、社会结构、力量规则、资源禀赋和专属机制清单,几百行的内容清单在那儿等着铺。
九、一个还没解决的设计难题
最后说一个我觉得挺有意思的、目前还没想好怎么做的事。
Outland 的八个世界,力量体系是互斥的。
- 你在奇幻世界练魔法,会累积”魔变”——到某个阈值就永久不能再回头做人,也不能接受其他体系的力量
- 你在赛博世界装植入体,会消耗”人性”——低了会植入体失控,归零会昏迷,反复归零会得赛博精神病
- 仙侠世界更狠,修为越高 san 掉得越快,因为天道本身被污染了
这些设计单个看都很好,它们让”变强”这件事永远带着代价。这也是从 CDDA 学来的——没有免费的力量。
但它们叠在一起会打架。
如果玩家在奇幻修了魔法、在赛博装了植入体,那他到底算哪个体系的?魔变和人性消耗是同时推进还是互相抵消?“体系隔离技能书”能解决一部分,但那个东西要后期才拿得到。
现在的答案(写在设定里)是:跨世界的力量需要”破解”才能通用,没破解的只能在本世界用。 科幻装备尤其严格——不破解甚至连弹药都得花信用点订阅。
但这套规则实际落到引擎里会怎么样,得等真正玩起来才知道。
这也是我为什么觉得这个项目值得慢慢做——它总在给你出新的、没法照抄的题。
这个系列就到这儿。后面有新进展再写。