路线阶段:Unity 入门实战第 15 章。
本章目标:不直接做完整联网,而是先把战斗系统改造成“可同步”的形态。
学习目标
完成本章后,你应该能做到:
- 明确“状态同步”与“输入同步”两种联机模型的差异。
- 为当前战斗系统接入帧输入缓存与预测执行。
- 在出现偏差时执行回滚重演,保证最终一致性。
- 用回放工具验证同步链路可复现。
为什么先做预研而非直接上联机
直接联机会暴露三个根因问题:
- 逻辑依赖实时输入和不稳定随机,难以一致。
- 系统没有帧级快照,无法回滚。
- 调试缺少“同一输入下两端结果对比”能力。
所以这章先做“可联机架构准备”。
同步模型选择
本项目建议优先:输入同步 + 本地预测 + 回滚校正。
理由:
- 现有命令系统天然适合封装输入帧。
- 带宽成本低于全量状态同步。
- 可复用回放系统做一致性验证。
帧输入结构
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);
}
}
预测流程
- 本地采样输入并立即执行(预测)。
- 同时发送给远端。
- 收到服务器确认输入后对比。
- 若不一致,触发回滚。
帧快照与回滚
快照结构
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 输入建议包含:
- 关键实体位置(量化后)
- HP/MP
- 技能冷却关键值
- 随机数状态
与现有系统接入
- 命令系统:
InputIntent -> CastSkillCommand/MoveCommand不变。 - 回放系统:复用帧输入记录,离线验证同步结果。
- 伤害系统:确保随机使用确定性序列。
- 流程状态机:只在
Battle状态启用同步环。
WebGL 预留点
- 网络层抽象成接口,后续可接 WebSocket。
- 降低包体:只传输入帧与校验值。
- 弱网下插值显示与预测纠偏分层实现。
验收清单
- 本地双端模拟下可跑通输入同步流程。
- 人为注入延迟/丢包时可触发回滚并恢复一致。
- 校验值一致率达到预期(例如 > 99%)。
- 回放验证可重现一次纠偏过程。
常见坑
坑 1:随机源未统一
两端随机不同步会导致频繁回滚。必须固定随机序列。
坑 2:快照字段不完整
回滚后状态漂移。快照必须覆盖“会影响战斗结果”的全部字段。
坑 3:回滚期间触发重复事件
会出现重复音效/弹字。表现层事件应可在回滚模式抑制或重放。
本月作业
实现“本地双实例同步模拟器”:
- 同进程内模拟 ClientA/ClientB。
- 配置延迟、抖动、丢包参数。
- 导出 5 分钟一致性报告与回滚次数统计。
下一章开始 Unity 路线收束:小型可发布 WebGL 玩法整合(主循环、关卡、结算、可持续内容更新)。