跳到正文
Virok

确定性模拟:我为什么把游戏逻辑从 MonoBehaviour 里全部抽出来

把战斗逻辑从 MonoBehaviour 搬到纯 C# Sim 层,换来的是 bug 可复现、可重放、可测试。代价是前期多写一层,以及必须放弃一些 Unity 的便利。

AI 摘要

在Unity游戏中修复因时序不精确导致的偶发抖动bug时,将战斗逻辑从MonoBehaviour抽离为独立于引擎的确定性Sim层。该层严禁访问UnityEngine和时间系统,通过种子化随机数和事件日志实现完全可复现的模拟,代价是增加约40%代码量,但换来可测试、可重放的未来扩展能力。

5 分钟更新于 2026年9月2日
目录

《Way Of The Warrior 3D》做到 M3 的时候,我遇到了一个很典型的困境:某个波次里,敌人偶尔会卡在两个障碍物之间抖动。概率大概三十分之一,复现全靠运气。

花了两天才定位到根因——不是寻路算法的问题,是 Update() 里读了一次 Time.deltaTime,而 FixedUpdate() 里又读了一次。两帧之间的 deltaTime 不是同一个值,位移计算就产生了微小的不一致。在绝大多数情况下这个误差看不见,但只要敌人挤在一起,它就会被放大成肉眼可见的抖动。

这类 bug 的共性:它们不是逻辑错误,是时序错误。逻辑你读代码就能看出来,时序错误不行——你得让同一段输入跑出一模一样的结果才能稳定复现。

于是我把整个战斗逻辑从 MonoBehaviour 里抽了出来。

什么是 Sim 层

Sim 层是一份不知道自己跑在 Unity 里的代码。

// Sim/Combat/Simulator.cs
public sealed class CombatSimulator
{
    private readonly SimWorld _world;
    private int _tick;

    public void Step(in SimInput input)
    {
        _world.ApplyInput(input);
        _world.UpdateMovement();
        _world.ResolveCollisions();
        _world.ProcessDamage();
        _world.CleanupDead();
        _tick++;
    }

    public SimSnapshot Capture() => _world.ToSnapshot();
}

几个硬性约束:

  • 不引用 UnityEngine。不碰 Time、不碰 Transform、不碰 Random.Range
  • 不持有 GameObject。Sim 里只有 EntityId 和纯数据结构。
  • 唯一的随机源是自己实现的确定性 PRNG,种子写在事件日志头里。
  • 唯一的输入是 SimInput,一份扁平的结构体。

第三条最容易踩坑。C# 的 System.Random 在不同运行时版本下实现会变,UnityEngine.Random 更是跟引擎内部状态绑死。自己写一个 xorshift 只要二十行,但这是确定性的前提。

// Sim/Core/DeterministicRandom.cs
public sealed class DeterministicRandom
{
    private uint _state;

    public DeterministicRandom(uint seed) => _state = seed == 0 ? 0x9E3779B9u : seed;

    public uint Next()
    {
        _state ^= _state << 13;
        _state ^= _state >> 17;
        _state ^= _state << 5;
        return _state;
    }

    /// <summary>返回 [0, 1) 区间,用定点比例避免浮点累积误差</summary>
    public float NextFloat() => (Next() >> 8) * (1.0f / 16777216.0f);
}

表现层如何跟上

抽出来之后,Unity 那边只剩三件事:收集输入、跑 Sim、把 Sim 的状态画出来。

// Presentation/CombatRunner.cs
public sealed class CombatRunner : MonoBehaviour
{
    private CombatSimulator _sim;
    private float _accumulator;

    private void Update()
    {
        _accumulator += Mathf.Min(Time.deltaTime, MaxFrameTime);

        while (_accumulator >= FixedDelta)
        {
            _sim.Step(_inputBuffer.Consume());
            _accumulator -= FixedDelta;
        }

        // alpha 用于在两个 sim tick 之间插值,避免 30Hz 的视觉卡顿
        _view.Sync(_sim.Capture(), alpha: _accumulator / FixedDelta);
    }
}

这里有个必须处理的细节:渲染频率是 60/120Hz,Sim 是 30Hz。直接把 Sim 状态画出来,画面会有肉眼可见的顿挫。所以每个可渲染实体需要保存上一 tick 和当前 tick 两份状态,渲染时按 alpha 插值。

这件事在架构上有个明确含义:表现层允许插值,Sim 层绝不允许。插值是为了好看,它引入的误差不能回流到逻辑里。这条线一旦模糊,确定性就废了。

换取了什么

1. Bug 可复现。

Sim 层的每一步都可以序列化成事件日志。一次战斗跑完,日志 + 初始种子就是完整的复现材料:

seed=0x5A3F2C11  tick=0..5400  events=18244  checksum=0x7E3A91B2

玩家举报“某个波次打不过去”,我拿到种子就能在本地重放出完全一样的过程。这比看十段录屏都有用。

2. 测试不再需要引擎。

Sim 层是纯 C#,可以直接在 NUnit 里跑:

[Test]
public void TwoEnemiesConverging_DoNotOverlap()
{
    var sim = new CombatSimulator(seed: 12345);
    // ... 布置两个相向移动的敌人
    for (int i = 0; i < 600; i++) sim.Step(SimInput.Empty);

    Assert.Less(sim.Capture().MinEntityDistance, 0.001f);
}

不需要进 Play Mode,不需要等场景加载,测试跑完是毫秒级。这意味着战斗平衡数值可以做成回归测试——改一个数值,几百个模拟战斗自动跑一遍,看胜率曲线有没有突变。

3. 重放、回滚、以及未来的联机。

确定性模拟最诱人的副产品是:只要输入一致,客户端可以自己算出结果。这给网络同步留了一条最简单的路——只同步输入,不同步状态。

现在还用不上,但架构上留着这个口子,比日后推翻重来便宜得多。

代价

不写清楚代价就是耍流氓。

  • 前期多写一层。 每个功能都要写 Sim 结构 + Sim 逻辑 + View 同步,代码量大概多 40%。
  • 不能用 Unity 的便利设施。 物理、动画状态机、粒子系统全部要自己在 Sim 层建模。碰撞检测我最后是自己写的 AABB + 空间网格,没有用 PhysX。
  • 团队要守规矩。 只要有一个人图省事在 Sim 层 Debug.Log 里读了 Time.time,或者偷偷用了 UnityEngine.Random,确定性就破了一个洞。这种 bug 极难查——它只在特定帧序列下才显现。

第三条是最难防的。我的做法是在 CI 里加一条静态检查:扫描 Sim/ 目录下所有文件,出现 UnityEngine 命名空间引用就构建失败。用规则约束,而不是靠自觉。

// Editor/SimLayerGuard.cs —— 仅在编辑器下运行
[MenuItem("WOTW/Verify Sim Layer Purity")]
public static void Verify()
{
    var violations = AssetDatabase.FindAssets("t:Script", new[] { "Assets/Scripts/Sim" })
        .Select(AssetDatabase.GUIDToAssetPath)
        .Where(path => File.ReadAllText(path).Contains("UnityEngine"))
        .ToList();

    if (violations.Count > 0)
        throw new BuildFailedException($"Sim 层不得引用 UnityEngine:\n{string.Join("\n", violations)}");
}

什么情况下别这么做

不是所有项目都值得上这套。

  • 回合制、卡牌、解谜:没有连续的物理积分,确定性问题天然不存在,别自找麻烦。
  • 单人、无重放需求、生命周期短的小游戏:多写 40% 代码换不到什么。
  • 团队没人愿意守规矩:半套确定性比没有确定性更糟——你会以为能重放,结果重放出来的跟线上不一样。

判断标准其实只有一条:你是否需要“同一段输入永远得到同一个结果”。如果需要,越早抽越便宜;如果不需要,别碰。

我做这个决定的时候 M3 已经写完了,抽层花了将近两周。如果 M0 就定下这个架构,大概只需要多花三天。这是我在这个项目上交过的最贵的一笔学费。

Related · AI

相关阅读

基于语义相似度推荐