Article

Unity 入门实战 15:网络同步预研(本地预测、状态回放与校验)

路线阶段:Unity 入门实战第 15 章。
本章目标:不直接做完整联网,而是先把战斗系统改造成“可同步”的形态。

学习目标

完成本章后,你应该能做到:

  1. 明确“状态同步”与“输入同步”两种联机模型的差异。
  2. 为当前战斗系统接入帧输入缓存与预测执行。
  3. 在出现偏差时执行回滚重演,保证最终一致性。
  4. 用回放工具验证同步链路可复现。

为什么先做预研而非直接上联机

直接联机会暴露三个根因问题:

  1. 逻辑依赖实时输入和不稳定随机,难以一致。
  2. 系统没有帧级快照,无法回滚。
  3. 调试缺少“同一输入下两端结果对比”能力。

所以这章先做“可联机架构准备”。

同步模型选择

本项目建议优先:输入同步 + 本地预测 + 回滚校正

理由:

  1. 现有命令系统天然适合封装输入帧。
  2. 带宽成本低于全量状态同步。
  3. 可复用回放系统做一致性验证。

帧输入结构

public struct NetInputFrame
{
    public int Tick;
    public int PlayerId;

    public InputIntent Intent;
    public int LocalSequence;
}

帧驱动时钟

public sealed class FixedTickClock : IUpdatable
{
    public int Order { get { return 1; } }

    private readonly float _tickInterval;
    private float _acc;

    public int CurrentTick { get; private set; }

    public event Action<int> OnTick;

    public FixedTickClock(int tickRate)
    {
        _tickInterval = 1f / tickRate;
        _acc = 0f;
        CurrentTick = 0;
    }

    public void Tick(float dt, float unscaledDt)
    {
        _acc += dt;
        while (_acc >= _tickInterval)
        {
            _acc -= _tickInterval;
            CurrentTick++;
            if (OnTick != null) OnTick(CurrentTick);
        }
    }
}

输入缓存与预测执行

public sealed class PredictedInputBuffer
{
    private readonly Dictionary<int, NetInputFrame> _local = new Dictionary<int, NetInputFrame>(512);
    private readonly Dictionary<int, NetInputFrame> _remote = new Dictionary<int, NetInputFrame>(512);

    public void PushLocal(NetInputFrame frame)
    {
        _local[frame.Tick] = frame;
    }

    public void PushRemote(NetInputFrame frame)
    {
        _remote[frame.Tick] = frame;
    }

    public bool TryGetLocal(int tick, out NetInputFrame frame)
    {
        return _local.TryGetValue(tick, out frame);
    }

    public bool TryGetRemote(int tick, out NetInputFrame frame)
    {
        return _remote.TryGetValue(tick, out frame);
    }
}

预测流程

  1. 本地采样输入并立即执行(预测)。
  2. 同时发送给远端。
  3. 收到服务器确认输入后对比。
  4. 若不一致,触发回滚。

帧快照与回滚

快照结构

public struct WorldSnapshot
{
    public int Tick;
    public Vector3 PlayerPos;
    public Vector3 EnemyPos;
    public int PlayerHp;
    public int EnemyHp;
    public int RandomState;
}

快照缓存

public sealed class SnapshotBuffer
{
    private readonly Dictionary<int, WorldSnapshot> _map = new Dictionary<int, WorldSnapshot>(512);

    public void Save(WorldSnapshot snapshot)
    {
        _map[snapshot.Tick] = snapshot;
    }

    public bool TryGet(int tick, out WorldSnapshot snapshot)
    {
        return _map.TryGetValue(tick, out snapshot);
    }

    public void DropOlderThan(int tick)
    {
        var keys = ListPool<int>.Get();
        foreach (var kv in _map)
        {
            if (kv.Key < tick) keys.Add(kv.Key);
        }

        for (var i = 0; i < keys.Count; i++) _map.Remove(keys[i]);
        ListPool<int>.Release(keys);
    }
}

回滚控制器

public sealed class RollbackController
{
    private readonly SnapshotBuffer _snapshots;
    private readonly PredictedInputBuffer _inputs;
    private readonly BattleSimulator _sim;

    public RollbackController(SnapshotBuffer snapshots, PredictedInputBuffer inputs, BattleSimulator sim)
    {
        _snapshots = snapshots;
        _inputs = inputs;
        _sim = sim;
    }

    public void Reconcile(int mismatchTick)
    {
        WorldSnapshot baseSnap;
        if (!_snapshots.TryGet(mismatchTick - 1, out baseSnap))
        {
            FoundationLog.Warn("Net", "rollback_missing_snapshot tick=" + mismatchTick);
            return;
        }

        _sim.Restore(baseSnap);

        var tick = mismatchTick;
        var current = _sim.CurrentTick;

        while (tick <= current)
        {
            NetInputFrame local;
            if (_inputs.TryGetLocal(tick, out local))
            {
                _sim.ApplyInput(local);
            }

            NetInputFrame remote;
            if (_inputs.TryGetRemote(tick, out remote))
            {
                _sim.ApplyInput(remote);
            }

            _sim.Step();
            tick++;
        }

        FoundationLog.Info("Net", "rollback_done from=" + mismatchTick + " to=" + current);
    }
}

一致性校验

每隔固定帧发送轻量校验:

public struct StateChecksum
{
    public int Tick;
    public uint Hash;
}

Hash 输入建议包含:

  1. 关键实体位置(量化后)
  2. HP/MP
  3. 技能冷却关键值
  4. 随机数状态

与现有系统接入

  1. 命令系统InputIntent -> CastSkillCommand/MoveCommand 不变。
  2. 回放系统:复用帧输入记录,离线验证同步结果。
  3. 伤害系统:确保随机使用确定性序列。
  4. 流程状态机:只在 Battle 状态启用同步环。

WebGL 预留点

  1. 网络层抽象成接口,后续可接 WebSocket。
  2. 降低包体:只传输入帧与校验值。
  3. 弱网下插值显示与预测纠偏分层实现。

验收清单

  1. 本地双端模拟下可跑通输入同步流程。
  2. 人为注入延迟/丢包时可触发回滚并恢复一致。
  3. 校验值一致率达到预期(例如 > 99%)。
  4. 回放验证可重现一次纠偏过程。

常见坑

坑 1:随机源未统一

两端随机不同步会导致频繁回滚。必须固定随机序列。

坑 2:快照字段不完整

回滚后状态漂移。快照必须覆盖“会影响战斗结果”的全部字段。

坑 3:回滚期间触发重复事件

会出现重复音效/弹字。表现层事件应可在回滚模式抑制或重放。

本月作业

实现“本地双实例同步模拟器”:

  1. 同进程内模拟 ClientA/ClientB。
  2. 配置延迟、抖动、丢包参数。
  3. 导出 5 分钟一致性报告与回滚次数统计。

下一章开始 Unity 路线收束:小型可发布 WebGL 玩法整合(主循环、关卡、结算、可持续内容更新)。