开发日志(三):视觉分层、行为记录与独立模拟
一、游戏状态机的简化
本次游戏流程的一个核心调整,是把早期较细碎的回合阶段重新收敛为更少、更稳定的状态机结构。最早的实现中,GameSession 曾显式区分 Action、Movement、Encounter 和 Finished 四个阶段,其中“移动”被单独提升为一个枚举状态。
一方面,移动、探索、潜伏本质上都属于“当前角色正在执行自己的普通行动”;另一方面,真正会打断普通流程、并需要冻结其他角色推进的,是遭遇与战斗。因此在这次重构中,将状态机收缩为三个核心状态:Action、Encounter 和 Finished。其中,Action 不再只是“准备动作”,而是一个更宽泛的正常行动态,内部容纳探索、移动、潜伏以及若干道具与交互操作;Encounter 则明确表示当前角色已进入交战流程,需要等待双方提交战斗决策并统一结算;Finished 则不再代表“一回合结束”,而只表示角色已经死亡,不会再参与后续轮询。
Action 是角色的常规活动状态:它既包括探索与拾取,也包括移动、潜伏,以及在控制器中由 AI 或玩家输入触发的若干普通决策。只要行动过程中没有引发特殊事件,控制器就会在该角色完成动作后调用 BeginNewAction(...) 重置其局部行动标记,并通过 AdvancePollTurn(...) 将行动权切换给下一位仍然存活的角色。也就是说,这里的“回合推进”并没有被单独建模为一个枚举状态,而是体现在 Action 的自循环与轮询逻辑之中。
Encounter 的地位则更加明确:它是整个系统中唯一真正意义上的“中断态”。当角色在行动中发现其他生物,或者在移动、探索、伏击等过程中满足交战条件时,控制器会调用 StartEncounter(...),将双方的 ActorState 都切换到 TurnPhase.Encounter,同时记录当前遭遇信息。之后,游戏不再继续推进其他角色,而是集中处理这一场战斗。双方分别提交“攻击”或“回退”等战斗决策后,由 TryResolveFight() 统一调用战斗结算器。如果战斗结果为 Continue,说明这场冲突尚未结束,那么参与双方继续停留在 Encounter,进入下一轮战斗决策;这也是图中 Encounter 自环的含义。
如果战斗已经结束,状态机会产生两类后继分支。对仍然存活的参与者,控制器会将其重新置回 Action:这并不表示它立即继续自由行动,而是说明从“状态语义”上看,它已经脱离战斗流程,恢复为一个可被轮询系统继续调度的普通角色。随后,AdvancePollTurn(...) 会继续把行动顺序交给下一位可行动角色。对已经死亡的参与者,则其状态被置为 Finished,之后会被轮询逻辑永久跳过。这里的 Finished 因而具有非常明确的含义:它不再是“本回合临时结束”,而是“该角色的整个对局生命周期已经结束”。
二、视觉、记录与模拟的边界
前两次迭代建立了可交互的对局。这次提交同时改动房间显示、行为记录、角色知识、物品数量和独立模拟。它要解决两个具体问题:玩家看到的线索如何对应真实事件;开发者又如何在不启动界面的情况下复现和检查对局过程。
1. 视觉层从单一画面拆成多种职责
新增素材包括地板、墙面、墙角、门和尸体。显示代码按信息来源拆分:RoomBackdrop 绘制房间背景,RoomActivityTraceLayer 绘制活动痕迹,CorpseShadowLayer 绘制尸体和血迹,MainUi.RoomView 将这些层接入界面。本次提交还使用 RoomVisual 绘制房间素材。
这些元素的更新条件不同:地板随房间类型确定,活动痕迹随行为产生和消退,尸体由死亡事件留下。分层后,可以分别决定何时重绘或隐藏每类信息。
2. 世界日志:ActionJournal
本次加入 ActionJournal,记录可供程序读取的行为事件。ActionEvent 包含事件顺序、角色、事件类别及相关状态,SimulationTrace 则将一局模拟保存为文件。结构化事件与面向玩家的文字消息用途不同:前者支持检索和比较,后者负责描述玩家可感知的结果。
设计事件日志时至少有三种不同的数据需要区分:
- 事件:某角色做了什么,结果是什么,用来重建变化过程。
- 状态快照:某一步之后角色处于哪里、是否存活,用来核验和快速查看。
- 描述消息:玩家看见或听见什么,用来服务交互和叙事。
仅保存随机种子不足以长期复现一局对局:AI 算法或随机数调用顺序改变,都可能让重新运行的结果偏离原局。事件流保留已发生的动作和结果,但本版日志尚未包含完整地图快照;要实现不重跑规则的回放,还需继续补全记录内容。
3. 玩家所知不等于世界真相:NoteBook 与 CardPool
NoteBook 保存角色个人获得的发现、信号、访问和威胁记录,并按回合筛选可见条目。它把角色的知识与游戏世界的真实状态分开:笔记可能缺失信息,也可能因时间流逝而过时。NPC 策略应读取自身笔记,而不是直接读取所有角色的真实位置。
CardPool 管理全局卡牌剩余量,可初始化 A 版配额,并在取卡时扣减库存。它与房间战利品分开,避免各房间分别生成物品而超出全局数量。ItemCollection 和 Bag 整理物品数量与角色背包;从卡池取出的物品进入这些持有状态后,不再计入卡池剩余量。
4. 让游戏可脱离前端运行:SimulationRunner
Simulation 工程只编译 C# 规则代码,不启动 Godot 场景。SimulationRunner 负责创建并推进对局,命令行入口读取参数并保存日志。由此可以自动运行多局 AI 对局,观察规则在不同随机输入下的行为,而不必通过界面逐局操作。
Tests 与模拟器回答不同问题。“移动是否会穿墙”有明确的期望结果,适合写成测试;“某策略平均存活多久”需要重复模拟并给出样本量。模拟结果不能代替边界测试,因为罕见的非法状态可能在有限样本中始终没有出现。
5. 将“发现”写成玩家能够使用的知识
NoteBook 的记录并非一串没有结构的故事文本。NoteKind 区分事件、发现、信号、访问与威胁;条目可以带回合、位置、方向和信号强度。角色进入房间时的访问记录、听到动静时的方向提示,以及战斗后记住危险地点,都可以进入同一个个人笔记系统。VisibleEntries 只返回最近五回合的记录,Prune 移除过期条目并把容量限制在 150 条;ImportVisible 则允许把另一角色当时可见的记录作为缴获情报导入。
笔记本使 AI 的信息边界可以检查。敌人的真实位置属于全局状态;笔记中的威胁位置仅是角色曾获得的线索。策略可依据过时线索,但不能直接查询所有敌人的当前位置。分析一次决策时,应同时查看角色当时可见的笔记与最终动作,否则容易把有限信息下的合理选择误判为策略错误。
6. 全局卡牌数量与房间拾取
CardPool.InitializeAEdition 设置两把长刀、三把左轮、二十张子弹卡和一个消音器,并把子弹配额分配给允许产出子弹的房间。TryTake 扣减全局库存;带房间参数的版本还检查该房间的剩余配额。房间分别保存原有的 Loot 和角色丢弃或死亡后留下的 DroppedLoot。区分来源后,可以追踪一件物品是由探索产生,还是从角色转移到地面,也能检查全局数量是否被重复使用。
物品进入背包后,还会影响装备和弹药状态。Character.TryDrop 移除最后一件已装备道具时同步解除装备;丢弃枪械也要处理弹仓。若只减少背包数量而留下装备引用,角色可能继续使用已丢弃的物品。因此多角色模拟还需检查拾取、丢弃和死亡连续发生时的物品数量与装备状态。
三、规则与模拟的进一步改进拆解
1. 数值曲线
在本次提交中,Combat.cs 用数值曲线的离散表,替代了早期 CombatRules 的线性扣减。核心变化是:命中、追击、先手不再共用一条线性公式,而是各自使用离散概率表。
(1) 命中
命中索引等于,并限制在 −4 到 4。对应概率如下:
Table 1: 命中索引的命中率表
| 命中索引 | −4 | −3 | −2 | −1 | 0 | 1 | 2 | 3 | 4 |
| 命中率 | 2% | 4% | 8% | 14% | 20% | 30% | 45% | 60% | 75% |
Figure 2: 命中率随命中索引变化
(2) 追击
追击使用另一张表:同样把限制在 −4 到 4,概率依次为 1%、3%、6%、10%、14%、21%、32%、42%、53%。
Table 2: 追击索引的追击率表
| 追击索引 | −4 | −3 | −2 | −1 | 0 | 1 | 2 | 3 | 4 |
| 追击率 | 1% | 3% | 6% | 10% | 14% | 21% | 32% | 42% | 53% |
但有一个额外条件:若武器的追击等级不大于零,则无法追击,概率为零。
Figure 3: 追击率随追击索引变化;且武器追击等级 ≤ 0 时不尝试追击
(3) 行动顺序
双方都攻击时,首轮仍由先手决定顺序;以后各轮改用灵敏差决定优先行动概率:
- 基础为 50%。
- 己方每比对方高一级灵敏增加 20 个百分点。
- 低一级则减少 20 个百分点。
- 最终限制在 10% 到 90%。
Table 3: 后续轮次中,按灵敏差决定的优先行动概率;超出范围取边界值
| 灵敏差 | −2 或更低 | −1 | 0 | +1 | +2 或更高 |
| 优先行动概率 | 10% | 30% | 50% | 70% | 90% |
概率表与行动顺序表分开,允许武器在命中、追击、先手三个维度形成不同特点。例如,一把武器可以命中曲线一般、追击率很高、但先手概率普通;另一把武器则可能命中很高、追击很低、先手很强。
(4) 角色装备
角色装备上的数值也相应换成等级:
- 命中等级由角色基础命中、武器
HitScore与潜伏加成组成。 - 灵敏则汇总基础值、武器和防具。
另外:
- 命中曲线决定这一击是否击中。
- 生命与防护决定击中以后是否死亡。
2. 声音、视野和行动记录共享同一语义
地图听觉从初版的曼哈顿距离与分段半径,改为切比雪夫距离:暴露等级为 时,提示半径为 。因此斜向相邻的房间也可落在半径一内。SignalDirection 扩为八个方向,使提示能够区分东北、东南等方位。距离定义的变化会改变同一布局下哪些角色收到信号,进而影响追踪行为。
ActionJournal 把决策写成结构化事件。Decision 除最终动作外,还保存当时的 AgentObservation:血量、伤口、潜伏状态、装备与弹药、背包、近期可见笔记、已知房间、信号和合法动作。若 AI 选择撤退,日志可用来核对它当时是否听到威胁、还有没有弹药或出口。评价策略时必须使用决策时可见的信息,不能用事后已知的完整局面替代。
3. 第 2 版日志的能力与局限
本次 SimulationTrace 使用第 2 版格式,保存配置、事件序列、总步数、胜者和结束原因,但不包含完整地图快照,也没有为每条事件保存独立的模拟步骤。命令行 replay 读取配置与种子后重新运行游戏,再用 AssertSameEvents 找出新旧事件流的第一处分歧。因此它检查的是同一版本输入能否重现相同事件,而不是直接播放旧事件。
这种检查依赖随机数调用顺序。增加一次 AI 抽样或改变地图生成顺序,都可能使后续随机数错位,导致重跑结果不同。默认日志只保存行为事件;指定 --snapshots 才额外记录每步角色状态,以控制文件大小。达到 MaxSteps 时,结果标为 StepLimit,不会计作角色获胜。下一阶段若要直接播放旧对局,需要补足地图和事件时间信息。
4. 模拟记录的阅读
一份记录首先给出 SimulationSettings:地图种子、过程随机种子、NPC 与怪物数量、地图宽高、双方难度、最大步数及是否包含快照。事件流从角色出现开始,之后可以依次看到访问、个人决策、移动、探索、拾取、潜伏、声音、遭遇、战斗和死亡。Sequence 给所有事件一个全局顺序;Turn 表示对局回合,而最终的 Steps 是模拟器推进次数。它们不能随意互换:一个推进动作可能留下多条可观察事件。
若某次模拟以 StepLimit 结束,含义仅是达到预设上限时仍未决出胜者,不是平局规则已经正常触发。MutualDeath 则明确表示没有存活角色。保存这两种不同原因,后续统计才不会把“算法跑得太久”与“双方同归于尽”混成同一类终局。第三次提交已经可以通过固定种子做确定性回归;第四次提交才让旧事件不经重跑就能在地图上逐条展示。
四、新阶段暴露的设计问题
首先,正式游戏的 GameController 仍承载较多调度职责。其次,事件记录必须有稳定语义:角色移动前后的位置、遭遇顺序、死亡后掉落是否各有唯一来源;否则日志虽完整,回放仍难以解释。最后,AI 的观测接口应只包含角色可见的信息,而不能把完整世界状态直接传给策略。这些问题决定了下一阶段日志与回放是否能真正用于验证。