《魔女的遗产》是一款用 Wolf RPG Editor 制作的已发售游戏。我用 Godot 4.7 把它重写了两遍:第一版从反编译结果做语义映射,第二版完全自主控制。两版加起来约一个月、一百多个小时。这篇记录两次重写中真正有价值的部分——不是”我做了个游戏”,而是怎么组织一个数据流转复杂的小型游戏。
代码在两个仓库:WitchLegacy_Godot(映射版,205 commits)、WitchLegacy_Godot_2(自主版,58 commits)。
两个版本,两条路线
第一版:把反编译结果映射成数据。 用 UberWolfCli 解密原版 21 个加密包,得到 500 个事件 JSON(18.5 万条指令)和 3 个数据库。核心判断来自一个统计——全局 20302 个条件判断 vs 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 等)。
为什么不用场景组织对话系统
对话系统的数据是横切能力——在任何房间都能触发、状态决定分支。如果对话是某个场景的子节点,换场景就会丢失上下文;如果每个场景各存一份对话状态,又会数据分裂。把它从场景树里抽出来,变成一个全局服务,状态归中枢,才是正确的归属。这条判据适用于所有”横切”型系统。
数据要分层,且要分得干净
草稿里提到的”静态/动态、全局/单周目/临时”数据分层,第二版落成了四类,各有各的归属和持久化:
| 归属 | 承载 | 持久化文件 | 生命周期 |
|---|---|---|---|
| 本局游戏状态(动态/单周目) | WorldState | slot_N.json(存档槽) | 新游戏/读档覆盖 |
| 多周目成就(全局) | MetaState | user://meta.json | 永不(手动重置) |
| 玩家偏好 | SettingsSystem | user://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 可玩契约)值得记下来——它们不是这个游戏特有的,而是控制数据复杂度的一般手段。小型游戏能用简化的办法糊过去,但把”糊过去”的惯性带到更大的项目里,就是腐烂的开始。