模拟层笔记

模拟层笔记

核心文件:

  • include/lab/sim/StateSnapshot.h
  • include/lab/sim/InputCmd.h
  • include/lab/sim/InputBuffer.h
  • include/lab/sim/StateHistory.h
  • src/sim/World.cpp
  • src/sim/Hasher.cpp

模拟层是整个项目最核心的部分。网络同步、客户端预测、服务端权威、回滚重放和压力测试,最后都依赖同一个模拟入口:

1
World::Step(cmds, dt)

1. 模拟层的目标

模拟层要做到:

  • 同样初始状态 + 同样输入 + 同样 tick = 同样结果。
  • 不依赖 SDL、socket、真实网络。
  • 能生成完整快照。
  • 能从快照恢复。
  • 能被 hash 检查一致性。
  • 能被测试直接调用。

这就是确定性模拟的基础。


2. InputCmd:最小输入命令

InputCmd 表示某个玩家在某个 tick 的输入。

字段:

  • tick
  • buttons
  • moveX
  • moveY

设计重点:

  • 输入必须带 tick。
  • 输入只描述玩家意图,不描述最终结果。
  • 输入要足够小,适合 UDP 高频发送。
  • 输入可以重复发送,服务端按 tick 写入缓冲。

3. WorldSnapshot:状态快照

WorldSnapshot 包含:

  • tick
  • 玩家数组
  • 子弹数组
  • mazeSeed
  • 迷宫宽高
  • 迷宫网格

它用于:

  • 服务端下发权威状态。
  • 客户端回滚恢复。
  • 压测保存权威历史。
  • hash 对账。

判断一个字段是否应该进入 Snapshot,可以问:

1
这个字段是否会影响未来模拟结果?

如果答案是会,就必须进入 Snapshot。

例如 aimX/aimY 很重要。玩家停下后开火时,开火方向依赖上一次瞄准方向。如果回滚时没有恢复这个字段,子弹方向就可能和服务端不同。


4. World::Step 推进流程

World::Step 做的事情:

  1. 检查输入数量是否等于玩家数量。
  2. 检查所有输入 tick 是否一致。
  3. 确保迷宫存在。
  4. 设置当前快照 tick。
  5. 对每个玩家处理 hitstun、移动、摩擦、墙体碰撞、朝向、射击冷却。
  6. 如果有攻击输入且冷却结束,生成子弹。
  7. 处理玩家之间 pushbox 分离。
  8. 推进子弹,处理撞墙和命中。
  9. 同步子弹和迷宫到快照。

一句话:

1
World::Step 是唯一的模拟推进入口。

5. 玩家移动

玩家移动不是瞬间设置速度,而是向目标速度插值:

1
2
targetVx = moveX * speed
v += (targetVx - v) * alpha

其中:

  • speed = 4.5
  • friction = 8.0
  • dt = 1 / 60

这样移动会更平滑。

墙体碰撞采用分轴处理:

1
2
3
先尝试 x 方向移动;
再尝试 y 方向移动;
某方向撞墙就清掉该方向速度。

6. 子弹和命中

攻击输入会生成子弹:

  • 子弹 owner 是玩家编号,1-based。
  • 子弹有位置、速度、life。
  • 子弹从玩家前方生成。
  • 子弹撞墙或命中后消失。

命中玩家后:

  • 扣 HP。
  • 设置 Action::Hitstun
  • 设置 stateTimer
  • 给目标一点击退速度。

注意:

子弹状态必须进入 Snapshot、网络包和 hash。否则子弹命中会造成服务端和客户端状态分叉。


7. 迷宫生成

迷宫由 mazeSeed 驱动,用 DFS carve 方式生成。

服务端 State 里会下发 mazeSeed,客户端用 seed 生成同样迷宫。

但注意:

当前实现使用 std::shufflestd::mt19937。同机测试通常没问题,但跨编译器、跨标准库时最好不要只依赖 seed。更稳的做法是:

  • 同步完整迷宫网格。
  • 或实现项目自己的确定性随机和 shuffle。

项目的 Snapshot 和 hash 已经包含迷宫网格,这是正确方向。


8. InputBuffer 和 StateHistory

两者都是按 tick 索引的环形缓冲。

核心公式:

1
index = tick % capacity

读取时必须校验真实 tick:

1
slot.tick == requestedTick

否则环形缓冲覆盖后,可能把旧帧误认为新帧。

用途:

结构 保存内容 用途
InputBuffer 每 tick 输入 输入冗余、预测、回放
StateHistory 每 tick 快照 回滚、hash 对账、压测

9. Hasher

Hasher 用于状态一致性检查。

它覆盖:

  • tick
  • 玩家状态
  • 子弹状态
  • 迷宫 seed
  • 迷宫尺寸
  • 迷宫网格

float 字段不会直接按内存 hash,而是做毫米量化:

1
round(value * 1000)

这样和网络包里的毫米整数保持一致,避免微小浮点差异造成假 mismatch。


10. 模拟层注意事项

  • 新增会影响未来的字段时,要同步改 Snapshot、StatePacket、Hasher、Restore、测试。
  • World::Step 必须使用固定 dt,不要传真实帧耗时。
  • 不要在模拟层访问 SDL、socket、event loop。
  • 不要直接 hash float 内存。
  • 不要让随机数在服务端和客户端各自自由推进,随机结果必须可恢复、可同步。
  • 回滚恢复时不只要恢复玩家,也要恢复子弹、迷宫、冷却、朝向等隐性状态。
  • 玩家 pushbox 后可能需要再次考虑墙体约束,这是后续可改进点。