← 全部文章

照着《大灾变》搭引擎:一个自研 roguelike 的系统构成

本文目录
  1. 一、先说清楚抄了什么、没抄什么
  2. 二、时间:一切从 moves 开始
  3. 三、地图分三层,只模拟你身边
  4. 现实气泡(Reality Bubble)
  5. 四、生存模拟:体征互相咬合
  6. 状态用”效果栈”
  7. 五、确定性随机:为什么 RNG 也要认真做
  8. 六、交互:i 和 e 两个中心
  9. 七、踩的坑
  10. 1. 一个哨兵值造成的整个面板失效
  11. 2. 时钟初值引发的开局饥饿
  12. 3. 不喝水 1.25 天渴死
  13. 4. 武器技能形同虚设
  14. 5. 测试脚本把自己的编译失败吞了
  15. 八、现在做到哪了
  16. 九、一个还没解决的设计难题

前两篇讲了 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 学来的——没有免费的力量。

但它们叠在一起会打架。

如果玩家在奇幻修了魔法、在赛博装了植入体,那他到底算哪个体系的?魔变和人性消耗是同时推进还是互相抵消?“体系隔离技能书”能解决一部分,但那个东西要后期才拿得到。

现在的答案(写在设定里)是:跨世界的力量需要”破解”才能通用,没破解的只能在本世界用。 科幻装备尤其严格——不破解甚至连弹药都得花信用点订阅。

但这套规则实际落到引擎里会怎么样,得等真正玩起来才知道。

这也是我为什么觉得这个项目值得慢慢做——它总在给你出新的、没法照抄的题。


这个系列就到这儿。后面有新进展再写。