开发日志(一):从规则模型到可测试原型

一、规则模型与对象职责

DarkForest 的初始目标是在网格地图上实现生存对抗。玩家、非玩家角色(NPC)和游荡者探索房间、移动、获得有限信息,并在相遇时战斗。开发首先要确定一件事:角色执行一次行动后,哪些状态允许改变,改变的顺序是什么?

第一次提交据此建立了独立于 Godot 节点的 C# 规则类,并配套编写 WPF 桌面原型和测试工程。正式场景尚未完成时,就可以检查回合、移动和战斗能否按预期运行。规则不依赖界面,也使测试能够注入随机数,并为后续命令行模拟保留入口。

1. 角色、物品与地图状态

Character 保存角色 ID、名称、类别、生命、位置、装备和弹仓。CharacterKind 用 Player、Npc、Wanderer 标识规则上的角色类别,并不要求为三类角色建立三种 Godot 节点。CreateHuman 统一设置人类角色的初始生命和装备,避免不同调用入口给出不一致的初始状态。

Items 用 ItemDefinition 及武器、防具、照明、医疗等类型描述物品的固定规则;背包数量和弹仓余量则属于对局中的可变状态。将物品定义与持有状态分开后,同一种武器的基础属性只需定义一次,界面也只负责呈现当前实例的状态。

GridPosition、Room、GameMap 分别表示坐标、房间状态和地图通行规则。GameMap.CanMove 集中判定相邻房间能否连通,使玩家按钮与 NPC 寻路使用同一条规则。听觉提示也应由地图距离和角色暴露状态计算,再交给界面显示。

MapGenerator.GenerateMaze 先用随机深度优先搜索连接全部可进入房间,形成一棵生成树;再按 extraPassageChance 为部分相邻房间增加通道。前一步保证连通,后一步控制环路数量。生成算法与 GameMap 的通行接口分离,因此以后更换地图生成方式,无须改变移动和战斗规则。

2. 回合状态转换

地图和角色给出了可变状态,还需要一个对象规定行动顺序。GameSession 承担早期流程入口:注册角色、开始回合、探索、移动、潜伏,以及进入和结束遭遇。阶段枚举限定各动作何时可用。例如移动是否合法由规则层检查;按钮置灰只能提示用户,不能替代这一检查。

CombatResolver 结算单轮战斗中的攻击或回退、先手、命中、追击、弹药消耗和死亡,并返回 CombatRoundResult。战斗类只给出结算结果;GameSession 再依据结果决定回合是否继续。由此,战斗规则不必了解 Godot 场景如何切换。

NpcController 负责早期 NPC 的动作选择:记录到过的房间,优先前往未探索区域,探索完成后巡逻或潜伏,并在遭遇时选择攻击或回退。它提出动作,GameSession 和其他规则对象判定动作是否合法。策略因此可以调整,而不改变规则的执行边界。

注 1.
职责边界:地图判定通行,角色维护自身状态,战斗解析器结算对抗,会话推进回合,NPC 控制器选择候选动作。界面读取结果并发送命令,不直接改写这些规则。

3. 规则检验边界

上述分工能否成立,需要用具体规则检验。GenerateMaze 从随机起点开始,以栈执行深度优先遍历,每次只向未访问的相邻房间开通道。若所有房间均可进入,遍历结束时通道构成覆盖地图的树,任意两个房间之间都存在路径。随后算法只检查东、南两个方向尚未连通的邻接边,并按 extraPassageChance 决定是否增加通道,避免重复抽样。同一参数既决定地图是否出现绕行路线,也会影响追逐时可用的逃跑路径。

CanMove 不只比较坐标:起点和终点须在地图内、对应房间均可进入、曼哈顿距离为一,且两者之间存在通道。初版听觉也使用曼哈顿距离,但只用于感知。暴露值为零时不提示;等级一、二分别覆盖同房间和一格;等级三、四覆盖两格。对角位置可对应两个等距方向,代码固定优先报告水平方向,使同一输入得到稳定提示。听觉范围不会改变地图的通行关系。

角色状态同样有可验证的约束。装备武器、防具或光源之前,Character 要求背包已经持有该物品;换弹会从背包移除子弹并增加对应武器弹仓数量;绷带只能在有伤口且背包有绷带时使用。这些检查放在角色对象内,界面即使误发命令,也不能凭空装备或治疗。CreateHuman 统一给玩家和普通 NPC 一滴基础生命、匕首与指南针,减少不同入口创建角色时的初始状态偏差。

战斗规则则把双方的选择压缩为攻击与回退。攻击需要先确定先手、命中,再更新生命;一方回退时还要考虑追击。CombatRoundResult 分别记录谁出手、谁命中、双方暴露以及本轮结局,使调用方能够解释结果。这里的随机数由外部传入 CombatResolver,因此测试可以固定随机序列,验证边界条件,而不用依赖一次偶然的实战结果。

4. 原型的验证方式

规则工程可以脱离 Godot 运行,因此 Tests 能直接检查状态转换和非法操作的处理。WPF 原型则按按钮即可完成探索、移动和遭遇,检查操作顺序与反馈是否可理解。后续迁移到 Godot 之后不会再继续 WPF 验证。


二、一局对局如何推进

1. 回合状态机

初版 GameSession 依次使用 Action、Movement、Encounter 和 Finished 四个阶段。BeginTurn 只接受存在且存活的角色,并要求上一回合已经结束。行动阶段允许调整装备和道具,但 Explore 每回合最多领取一次当前房间的资源。调用 StartMovement 后,角色可移动到相邻房间或原地 Hide;潜伏直接结束回合,没有触发遭遇的移动也结束回合。阶段与前置条件由会话统一检查,界面只需据此显示可用操作。

遭遇嵌在当前回合内,不另起一回合。若它在行动阶段触发,BeginEncounter 保存原阶段,EndEncounter 在结算后恢复行动阶段;若角色移动进入敌人房间,移动机会已经用尽,结算后直接结束回合。同一场战斗发生的阶段不同,后续可执行动作也不同。因此日志需要保留遭遇发生的阶段,才能解释战斗后的流程。

Figure 1: 该版的回合状态机

2. 初始物品数值取舍

ItemCatalog 在第一版就录入了具体道具。匕首是初始近战武器,基础命中率为 25%,提供先手与灵敏各 2;长刀命中率 30%,但先手与灵敏各为 1。左轮基础命中率 20%,先手 3、灵敏 2,弹仓最多装 5 发,攻击暴露为 4。它的优势因此不只是“远程”标签:先手高、可以追击,但必须消耗通用子弹,还会把自身位置暴露给附近角色。这里的数值属于第一版的暂定参数,后面的规格曲线会改变命中计算方式。

防具与光源提供另一组取舍。防护衣和头盔分别占身体、头部槽位,并各提供 1 点防护;潜行衣占身体槽位,不提供防护,却增加先手与灵敏。火把给 2 点探索、3 点暴露;煤油灯和电筒都给 1 点探索、2 点暴露;夜视镜给 1 点探索且不增加暴露。于是“看得更清楚”与“更容易被察觉”被写成可计算的状态关系,而不是描述性文案。背包按物品 ID 保存数量,加入同 ID 但定义不同的物品会被拒绝,避免目录被不同来源悄悄覆盖。

3. 第一版战斗概率

一次普通攻击以武器自身 HitChance 为基数,潜伏者加 10 个百分点,再按目标每点灵敏减 5 个百分点、每点防护减 10 个百分点,最后把概率限制在 0 到 1。举例说,持匕首且不潜伏的角色攻击一个灵敏为 2、防护为 1 的目标,按初版公式得到 0.25−2×0.05−0.10=0.05。这只是规则公式的示例,不代表实际对局里目标一定具有这组属性。

双方都攻击时,先比较先手;平局随机决定谁先行动。先手一击若已经杀死对方,后手不再攻击。双方都回退则直接结束遭遇;一攻一退时,只由攻击者执行追击判定。探索遭遇的首轮禁止回退,防止玩家在已经接触敌人的强制遭遇里无代价离开。这样的结算顺序把“谁有资格行动”“是否打中”“是否死亡”分成连续的判断点,也为测试提供了明确的断言。

4. NPC 的有限记忆与早期策略

初版 NpcController 每个实例只控制一个角色,保存自己访问过的房间。寻路使用广度优先搜索,在可通行房间里找到最近的未访问目标;邻居顺序先打乱,所以存在多条等长路径时不会永远选同一条。探索完所有房间后,NPC 会随机巡逻或潜伏。遭遇风险会改变其回退概率:伤口、对手远程武器、先手差和防护差提高回退倾向;自身处于潜伏状态则降低这种倾向。战斗后还会短暂避开威胁房间,避免刚撤退就立刻原路返回。


三、验证与下一阶段

1. 连通性需要单独验证

随机深度优先搜索每次只把一个尚未访问的房间接到已经访问的区域,因此新增通道不会在这一阶段形成环;遍历结束又覆盖全部可进入房间,因此从起点可以沿树边抵达每个房间。这是算法层面的保证,但实现仍要面对输入约束:宽和高不能小于一,额外通道概率必须位于 0 到 1,传入的房间工厂不能把某个房间设成不可进入。后一个限制尤其容易被忽略:生成器的“全部房间连通”建立在所有房间可走的前提上。正式 A 版地图出现封闭中央区域后,便不能直接沿用这个前提描述所有地图。

通道用无向的相邻房间对保存。ConnectRooms 和 DisconnectRooms 先确认两端都在地图内且仅相隔一格;Neighbors 只返回 CanMove 为真的位置。这样 NPC 广度优先搜索、玩家方向按钮和迷宫验证都可以共用一套邻居定义。若以后增加锁门或单向门,就必须重新审视这个无向通道约定,而不是只在画面上加一把锁的图标。

2. 一次探索的状态变化

初版 Explore 先检查行动阶段与本回合是否已经探索过,然后立即设置“已探索”标志,再读取当前房间的战利品堆栈。每份拾取物进入角色背包后,从房间库存中移除。若探索后出现敌人,遭遇流程再接管阶段变化。测试时可以在一个房间放入固定物品,断言背包增加、房间减少、再次探索被拒绝,最后确认战斗不会凭空复制这份道具。

独立 WPF 原型提供另一种检查:一个人实际点击探索、切换装备、移动和遭遇按钮,能发现规则调用顺序是否符合直觉。它将与 Godot 后来的正式界面使用同一类逻辑概念。后续应优先维护同一份规则来源,或者至少用相同测试覆盖两套实现。

3. 给下一阶段留下的接口

这一版为后续场景接入留下了明确接口:地图提供 CanMove 和 Neighbors,会话处理 BeginTurn、Explore、Move 与 Hide,战斗解析器返回一轮结算,NPC 控制器给出候选动作。Godot 场景可以从“读取输入—调用规则入口—刷新显示”的闭环开始,而不必在按钮、角色面板和怪物脚本中分别实现同一条规则。

也应把“已经有代码”与“设计已定稿”区分开。初始武器概率是待验证的暂定值,探索收益与剧情条件仍不完整,WPF 原型也不能证明 Godot 场景接线正确。开发日志记录这些未完成项,能解释后面为什么会出现规格校验、卡池、视觉线索和新的战斗曲线,而不是把后续重构写成前期设计失误。


四、阶段结果与待完成事项

这一阶段形成了可运行、可测试的规则骨架,尚未形成完整游戏。探索收益、剧情、事件倒计时和陷阱交互仍待定义,战斗数值也只是初版参数。下一阶段需要把这些规则接入 Godot 场景,检查多角色推进时的状态一致性,并明确每名角色能够获知的信息。

Figure 2: WPF 版游戏试完界面