《魔女的遗产》是一款用 Wolf RPG Editor 制作的已发售游戏。我用 Godot 4.7 把它重写了两遍:第一版从反编译结果做语义映射,第二版完全自主控制。两版加起来大概一个月、一百多个小时。

这篇文章我不想讲「我做了个游戏」,我想讲的是两次重写里真正有价值的那部分:怎么组织一个数据流转复杂的小型游戏。

代码在两个仓库:WitchLegacy_Godot(映射版,205 commits)、WitchLegacy_Godot_2(自主版,58 commits)。

两个版本,两条路线

第一版走的是「把反编译结果映射成数据」。我用 UberWolfCli 解密原版 21 个加密包,得到 500 个事件 JSON(18.5 万条指令)和 3 个数据库。核心判断来自一个统计:全游戏有 20302 个条件判断,对话文本只有 9808 条,比例 2:1,而且没有序章事件。这说明它不是线性叙事,而是状态驱动的,给定状态和动作,查表选出一条回应。

于是第一版搭了「State + Response Resolver + Sequencer」三层查表。我自研了一个转译器,把每个事件的条件树机械映射成 ResponseSet 资源,再用一个 GDScript 协程解释器当差分 oracle。这个解释器保证的是忠实度,也就是绞杀者模式的做法。

这条路对查表型事件有效,但很快撞到了边界。实时互动场景没法逐指令映射,单个事件最多能到 6008 条指令。转译器对大量 opcode 只能标 opaque 留人工,事件级保真「架构性不可达」,只能降级成行为对照。

立绘驱动(PortraitDriver)也始终没法统一接入核心场景。因为接入要重建 AnimationTree 状态机,风险收益不划算。

第一版证明了一件事:从反编译结果映射,能走多远,以及它走不到哪里。

第二版我放弃了映射,架构从零写。不再依赖任何反编译数据,把架构本身当作宪法来设计。第一版登记的那批「诚实边界」(指令流黑盒、GameState 知道裸变量 ID、autoload 互相调用成网),在第二版被系统性回应。

核心收获:用信息拓扑管理数据流转

这是两次重写里最值得记下的一条。Godot 的开发理念是用场景组织业务逻辑:一个游戏由若干核心场景组成,每个场景挂载它需要的功能模块。功能模块本身又是一个场景(对话是场景、立绘是场景),各自的 GDScript 挂在节点上。一层套一层,形成金字塔式的组织结构。

这种结构对演出型场景很有效。房间、地牢、结局,每个是一个独立 .tscn + 控制器,魔女和弟子同动作类型拆成镜像两个场景。第二版落地了 23 个核心场景 + 3 个模态面板,按「场所 × 动作 = 独立画面」组织。

但它处理不了数据流转重的系统。对话(各种条件触发、多语言激活)、合成、物品,这些系统的逻辑横切在所有场景之上。它们没法塞进某个场景的节点树。如果硬塞,状态就会被节点生命周期绑架:场景一销毁,数据就没了,或者数据散落在各个场景里互相找不到。

我的解法是把这类系统的核心数据抽出来,放在一个全局数据中枢里,所有系统统一访问。第二版里这个中枢叫 WorldState,是一个 autoload 单例,内部按领域分成 11 个 private 子模块:好感度、日数时刻、道具、装备、探索、结局、进度、监禁、日常路由、统计,加上一个 H 场景瞬态槽。配套还有一个只读的静态数据中枢 ContentRegistry,放道具、配方、敌人的出厂定义。两者构成静态和动态的二分:前者回答「这是什么」,后者回答「玩家现在怎么样」。

这就是我说的信息拓扑:把系统之间原本乱七八糟的互相依赖,改成只跟一个中枢打交道。系统之间不许直接调用,数据变了由中枢往外广播。落到代码上就是下面这张图。

        Dialogue(只读)   Alchemy(读写)   Inventory(子模块)
              │                │                │
              └────────────────┼────────────────┘
                               ▼
                          ┌──────────┐
        所有系统 ──读写──▶ │WorldState│ ──广播信号──▶ 所有系统
                          └──────────┘
              │                │                │
              ▼                ▼                ▼
         GaugeStore      InventoryStore    EndingStore ...

关键约束是星型拓扑:所有系统可以读写 WorldState,但系统之间禁止互相调用。WorldState 自己也不知道任何系统的存在,它只负责广播信号。系统 A 写了数据,WorldState 自动广播,系统 B 在自己的 _ready() 里 connect 信号,自己判断要不要响应。影响方完全不知道被影响方是谁。这套机制用了双层信号:一个通用的 data_changed(key, old, new),加 23 个领域专用信号(affection_changed、item_added、route_flag_changed 等)。

为什么不用场景组织对话系统

对话系统的数据是横切能力:在任何房间都能触发,状态决定分支。如果对话是某个场景的子节点,换场景就会丢失上下文;如果每个场景各存一份对话状态,又会数据分裂。把它从场景树里抽出来,变成一个全局服务,状态归中枢,才是正确的归属。这条判据适用于所有「横切」型系统。

数据要分层,而且得分开

草稿里提到的「静态/动态、全局/单周目/临时」数据分层,第二版落成了四类,各有各的归属和持久化:

归属承载持久化文件生命周期
本局游戏状态(动态/单周目)WorldStateslot_N.json(存档槽)新游戏/读档覆盖
多周目成就(全局)MetaStateuser://meta.json永不(手动重置)
玩家偏好SettingsSystemuser://settings.cfg永不(玩家改)
静态只读游戏数据ContentRegistry不持久化(随 res:// 分发)进程结束
H 场景瞬态WorldState 内 HTempStore不进存档场景结束即销毁

这里有个细节值得记一笔:可写持久化我一律用 JSON,不用 .tres。第一版在这上面踩过坑,.tres 持久化的毛病不少:会缓存串味、嵌套类读不回、损坏文件刷引擎 error,二进制缩略图还会让 checksum 不稳定。这条教训在第二版固化成铁律。静态定义(res:// 下的道具、配方)用 .tres 是合法的,因为 Godot 对 res:// 有成熟的导入缓存;但 user:// 可写层必须 JSON。

另一条配套纪律是单一写入源:每个数据点只能由一个子模块写,派生数据读时算、不存储。这从源头杜绝了双写导致的状态不一致。

文档先行,不是口号

第二版的首提交就是「文档阶段产物入库」,代码瀑布晚了两天才启动。四层文档系统写在最前面,当作项目宪法。这四层是:架构章程(违反即拒合并)、类说明书(每个类的接口契约,一类一份)、设计规约(跨类约定)、知识库(背景参考)。

真正起作用的不是「先写文档」,而是冲突时双向同步。测试发现 API bug 改了代码,必须回写类说明书,标注「发现性修订 + 日期 + 理由」。而且回写是测试通过的前置条件。代码里大量 # 发现性修订 (2026-08-04...) 注释,就是这条纪律的痕迹。每批改动都有独立的对抗审查 + drift 审计报告,commit 信息里反复出现「全仓文档/代码一致性审计修复」。

这条纪律针对的是小型项目最常见的腐烂模式:文档和代码各走各的,半年后文档成了谎言。把回写文档卡进测试流程,是从流程上防腐。

AI 可玩性:把架构合规性变成可测的东西

第一版没有这个能力,是第二版自己加的。目标很直接:让游戏能被 AI 无头游玩,CLI 操作和 GUI 操作拥有同等权限、可反复横跳。

实现上靠一个 ActionBus,它维护「当前活跃控制器」的引用,控制器上任何 _action_<id> 命名的方法就是命令入口,免注册。外面接一个 CliAdapter,用 JSON-RPC 2.0 over stdin/stdout(每行一帧 NDJSON),后台线程读 stdin,call_deferred 回主线程执行。于是 godot --headless 和正常 godot 走的是同一套命令路径。

配套的测试设施才是这套设计真正落地的保证:13 个手写自测脚本(不用 GUT/GdUnit4,原生 extends SceneTree,靠退出码聚合),23 份 NDJSON 命令重放用例,一个三层 CI 闸门脚本。最终全流程 replay(从标题到结局,117 帧 31 断言)脚本化通关,458 个测试全绿。

为什么这条值得做

这不只是「加了个测试接口」。它的定位是架构合规性的活体测试:凡是 CLI 做不到的,说明该处业务逻辑违规塞进了 UI 层。强制要求所有逻辑都能无头跑,等于强制要求业务逻辑和表现层解耦。这条约束反过来逼出了更干净的架构。

立绘:一个具体的技术难点

原版立绘是多层差分叠加(眼、口、眉、服装、发、饰品分开图层),单个角色几十张差分图。第一版想用 AnimationTree 状态机统一驱动,最终没接上,因为接入核心场景要重建状态机。立绘系统在 native 架构下是拼凑,不是统一驱动。

第二版换了思路,不用 AnimationTree,基于 Texture2D 分层叠加,切表情就是切对应 TextureRect 的 texture。最终用 13 个图层覆盖了原版角色的全部 Wolf 图层槽位:底图、腿、内衣、胸衣、内层、外衣、嘴、眉、眼、臂、特效、额发、帽子+项圈。

日常场景和 H 场景的渲染器拆成两个类(PortraitRenderer 和 HSceneRenderer)。因为 v0.1 试图用一个类管所有场景失败过,这是踩出来的教训。渲染代码包在 if not _headless: 里,状态跟踪逻辑无条件运行,所以无头模式也能断言立绘状态。

为什么不继续了

基础功能已经跑通了:对话、探索、立绘渲染都有了,全流程 replay 能从标题打通到结局。但我不打算继续开发它了。

原因是修 bug 的投入产出不成正比。这是从反编译结果重建的别人游戏,运行时暴露的 bug 都得自己排查。而这类 bug 的根因往往埋在原版作者的设计决定里,不在自己的代码里,修起来又慢又没有积累。同一个精力投到自己的项目上,每修一个都是给自己积累资产。所以这个项目到此为止,接下来做自己的游戏。

关于游戏开发难度的一个普遍判断

这次重制的是一个体量不算大的已发售游戏(不是 demo),但开发周期不算美术也要一到两个月。由此我得到几个判断:

3D、平台跳跃、战斗,不会直接增加难度。大多数 3D 相关的逻辑引擎已经封装好了,Godot 和 Unity 都提供完整的 3D 能力,普通的攻击判定、触发器难度有限。真正的难点是独特的业务逻辑:比如 3D 游戏里的爬墙,引擎默认没有,得自己写。引擎封装的都不算难,没封装的才是难的部分。

真正随体量增长的是数据复杂度。游戏体量越大,bug 越多,数据关联越复杂,对逻辑复用和解耦的要求越高。这次重制的是一个完整但小型的游戏,已经暴露问题;如果换成更大型的游戏,问题只会更严重。很多对小型游戏适用的开发逻辑(比如把状态直接塞进场景节点),到大型游戏就不再适用。

这也是为什么前面那些架构决定(数据中枢、星型拓扑、文档先行、AI 可玩契约)值得记下来。它们不是这个游戏特有的,而是控制数据复杂度的一般手段。小型游戏能用简化的办法糊过去,但把「糊过去」的惯性带到更大的项目里,就是腐烂的开始。