乌啦呀哈呀哈乌啦!

欢迎光临,这里是喵pass的个人博客,希望有能帮到你的地方

0%

帧同步和状态同步

帧同步 (Lockstep / Frame Sync)

核心思想:所有客户端执行相同的输入,保证逻辑完全一致,只同步”操作指令”不同步状态。

  1. 客户端收集本帧输入
  2. 将输入发送给服务器
  3. 服务器等待所有客户端输入(或超时补默认输入)
  4. 服务器广播所有客户端的输入
  5. 所有客户端用相同输入推进同一逻辑帧
  6. 定期做 checksum 校验防止逻辑分叉
帧同步客户端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
// 输入数据结构
[System.Serializable]
public struct FrameInput
{
public int playerId;
public int frame;
public Vector2 moveDir;
public bool firePressed;
}

// 帧同步管理器
public class LockstepManager : MonoBehaviour
{
public static int CurrentFrame { get; private set; }
private const float FrameInterval = 0.066f; // ~15帧/秒逻辑帧
private float _timer;

// 存储每帧所有玩家的输入
private Dictionary<int, List<FrameInput>> _frameInputBuffer = new();

// 本地玩家输入
private FrameInput _localInput;

void Update()
{
CollectLocalInput();

_timer += Time.deltaTime;
if (_timer >= FrameInterval)
{
_timer -= FrameInterval;
TrySendInput();
}
}

void CollectLocalInput()
{
_localInput = new FrameInput
{
playerId = NetworkManager.LocalPlayerId,
frame = CurrentFrame,
moveDir = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical")),
firePressed = Input.GetButton("Fire1")
};
}

void TrySendInput()
{
// 发送本地输入到服务器
NetworkManager.Send(_localInput);
}

// 服务器广播回来后调用
public void OnReceiveFrameInputs(List<FrameInput> inputs)
{
_frameInputBuffer[CurrentFrame] = inputs;
ExecuteFrame(CurrentFrame, inputs);
CurrentFrame++;
}

void ExecuteFrame(int frame, List<FrameInput> inputs)
{
foreach (var input in inputs)
{
var player = PlayerManager.GetPlayer(input.playerId);
player.ApplyInput(input); // 确定性逻辑,不用 Time.deltaTime
}

// checksum 校验
int checksum = ComputeChecksum();
NetworkManager.SendChecksum(frame, checksum);
}

int ComputeChecksum()
{
// 简单示例:对所有玩家位置做哈希
int hash = 0;
foreach (var player in PlayerManager.AllPlayers)
{
hash ^= player.LogicPosition.GetHashCode();
}
return hash;
}
}

// 玩家逻辑(必须确定性,避免浮点误差用定点数)
public class PlayerLogic : MonoBehaviour
{
public Vector2Int LogicPosition; // 用整数或定点数
private const int Speed = 5;

public void ApplyInput(FrameInput input)
{
// 确定性移动,不依赖 Time.deltaTime
LogicPosition += Vector2Int.RoundToInt(input.moveDir * Speed);
transform.position = new Vector3(LogicPosition.x * 0.01f, 0, LogicPosition.y * 0.01f);
}
}

状态同步

核心思想:服务器是权威,定期将游戏状态广播给所有客户端,客户端做插值表现。

  1. 客户端发送输入/操作到服务器
  2. 服务器执行逻辑,更新权威状态
  3. 服务器定期广播状态快照
  4. 客户端收到状态后做插值/预测修正
  5. 客户端本地预测(可选,减少延迟感)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
// 状态数据结构
[System.Serializable]
public struct PlayerState
{
public int playerId;
public Vector3 position;
public Vector3 velocity;
public float timestamp;
}

// 服务器端逻辑(Server-side,可用 Mirror/Netcode 等框架)
public class ServerPlayerController : MonoBehaviour
{
private Rigidbody _rb;
private float _broadcastInterval = 0.05f; // 20次/秒
private float _timer;

void FixedUpdate()
{
_timer += Time.fixedDeltaTime;
if (_timer >= _broadcastInterval)
{
_timer = 0;
BroadcastState();
}
}

// 收到客户端输入
public void OnReceiveInput(Vector2 moveDir, int playerId)
{
// 服务器执行权威逻辑
Vector3 force = new Vector3(moveDir.x, 0, moveDir.y) * 10f;
_rb.AddForce(force);
}

void BroadcastState()
{
var state = new PlayerState
{
playerId = GetPlayerId(),
position = transform.position,
velocity = _rb.velocity,
timestamp = Time.time
};
NetworkManager.BroadcastToAll(state);
}
}

// 客户端插值
public class ClientPlayerInterpolation : MonoBehaviour
{
private PlayerState _from;
private PlayerState _to;
private float _lerpT;
private const float InterpDelay = 0.1f; // 100ms 缓冲

private Queue<PlayerState> _stateBuffer = new();

// 收到服务器状态
public void OnReceiveState(PlayerState state)
{
_stateBuffer.Enqueue(state);
}

void Update()
{
float renderTime = Time.time - InterpDelay;

// 找到合适的插值区间
while (_stateBuffer.Count > 1 && _stateBuffer.Peek().timestamp <= renderTime)
{
_from = _stateBuffer.Dequeue();
}

if (_stateBuffer.Count > 0)
{
_to = _stateBuffer.Peek();
float t = Mathf.InverseLerp(_from.timestamp, _to.timestamp, renderTime);
transform.position = Vector3.Lerp(_from.position, _to.position, t);
}
}
}

// 客户端预测(本地玩家)
public class ClientPrediction : MonoBehaviour
{
private Vector3 _predictedPos;
private Rigidbody _rb;

void Update()
{
// 本地立即响应输入
Vector2 input = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical"));
_rb.AddForce(new Vector3(input.x, 0, input.y) * 10f);

// 发送输入到服务器
NetworkManager.SendInput(input);
}

// 服务器校正
public void OnServerCorrection(PlayerState authoritative)
{
float posError = Vector3.Distance(transform.position, authoritative.position);
if (posError > 0.5f)
{
// 误差过大,强制修正
transform.position = authoritative.position;
_rb.velocity = authoritative.velocity;
}
else
{
// 平滑修正
transform.position = Vector3.Lerp(transform.position, authoritative.position, 0.3f);
}
}
}

对比

维度 帧同步 状态同步
核心职责 客户端跑逻辑,服务器转发输入 服务器跑逻辑,客户端渲染状态
网络带宽 低(只传输入指令) 高(传完整状态数据)
服务器压力
延迟敏感度 高,需等待最慢玩家 低,可做客户端预测掩盖延迟
反外挂能力 弱,逻辑在客户端 强,服务器权威不可篡改
断线重连 慢,需回放所有历史帧 快,直接同步当前状态快照
实现复杂度 高(必须保证确定性) 中(需处理插值和预测修正)
适用场景 RTS、MOBA、格斗游戏 FPS、MMO、竞技射击

帧同步(Lockstep)

所有客户端共享相同的初始状态,服务器只负责收集并广播每帧所有玩家的输入指令。每个客户端用完全相同的输入自行推进逻辑,只要逻辑是确定性的,所有客户端的状态就会始终一致。本质是”同步输入,各自计算”。

  • 浮点数在不同平台/编译器结果可能不同,必须用定点数
  • 不能用任何随机函数,需用确定性伪随机(相同种子)
  • 物理引擎(如 Unity PhysX)本身不确定,需自研或用确定性物理库

状态同步(State Sync)

服务器是唯一的权威,负责运行所有游戏逻辑并定期将结果(位置、血量等状态)广播给客户端。客户端只负责展示,不参与权威计算。本质是”服务器计算,同步结果”。

  • 网络延迟导致操作反馈滞后,需要客户端预测
  • 预测结果和服务器权威状态不一致时需要平滑修正(防止位置跳变)
  • 状态数据量大时需要做差量同步(delta compression)降低带宽

为什么帧同步必须用定点数

帧同步的前提是:所有客户端用相同输入,得到完全相同的结果。浮点数破坏这个前提:

浮点数在不同平台/编译器的问题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
float a = 1.0f / 3.0f;

// x86 FPU (80位精度中间计算): 0.33333334326744...
// ARM NEON (32位严格计算): 0.33333331346511...
// 结果不同!

// 累积误差示例:同样的逻辑跑1000帧
float pos = 0f;
for (int i = 0; i < 1000; i++)
pos += 0.1f; // 每帧微小误差累积

// 平台A结果: 99.99999...
// 平台B结果: 100.00001...
// 1000帧后两个客户端玩家位置已经不同了
定点数:整数运算,在所有平台结果完全一致
1
2
3
4
5
6
7
Fixed pos = Fixed.From(0);
Fixed step = Fixed.From(0.1f); // 初始化时转换一次

for (int i = 0; i < 1000; i++)
pos += step; // 整数加法,任何平台结果都是同一个整数

// 所有平台:raw = 6553600,ToFloat() = 100.0 ✓

状态同步中客户端收到的是服务器算好的结果,不存在”两台机器算同一件事结果不同”的问题。

定点数 -> 用整数放大 65536 倍模拟小数 -> 牺牲数值范围,换取跨平台一致性

帧同步需要定点数:多个客户端必须算出完全相同的结果,必须保证结果一致 -> 必须用定点数
状态同步不需要:只有服务器一处计算,结果天然唯一 -> 浮点数完全没问题,只有一份唯一结果广播给所有客户端


在Unity中实现状态同步

NetworkVariable — 自动状态同步

使用NetCode库中的 NetworkVariable,在数值变化时自动同步给所有客户端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
using Unity.Netcode;
using UnityEngine;

public class PlayerStats : NetworkBehaviour
{
// 服务器修改,自动同步到所有客户端
public NetworkVariable<int> Health = new(100);
public NetworkVariable<Vector3> Position = new(Vector3.zero);

public override void OnNetworkSpawn()
{
// 监听变化
Health.OnValueChanged += OnHealthChanged;
}

void OnHealthChanged(int oldVal, int newVal)
{
// 客户端收到变化后更新 UI
UIManager.UpdateHealthBar(newVal);
if (newVal <= 0) PlayDeathEffect();
}

// 只有服务器能修改
public void TakeDamage(int damage)
{
if (!IsServer) return;
Health.Value = Mathf.Max(0, Health.Value - damage);
}
}

插值 (Interpolation) — 让移动平滑

客户端缓存服务器状态,在两个快照之间做插值,消除网络抖动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class InterpolatedTransform : NetworkBehaviour
{
private struct TransformSnapshot
{
public float time;
public Vector3 position;
public Quaternion rotation;
}

private Queue<TransformSnapshot> _buffer = new();
private const float BufferDelay = 0.1f; // 100ms 缓冲

// 服务器广播位置
[ClientRpc]
public void UpdateTransformClientRpc(Vector3 pos, Quaternion rot)
{
if (IsOwner) return; // 本地玩家不需要插值
_buffer.Enqueue(new TransformSnapshot
{
time = Time.time,
position = pos,
rotation = rot
});
}

void Update()
{
if (IsOwner || _buffer.Count < 2) return;

float renderTime = Time.time - BufferDelay;

// 找到插值区间
TransformSnapshot from = _buffer.Peek();
TransformSnapshot to = default;

foreach (var snap in _buffer)
{
if (snap.time <= renderTime) from = snap;
else { to = snap; break; }
}

if (to.time == 0) return;

float t = Mathf.InverseLerp(from.time, to.time, renderTime);
transform.position = Vector3.Lerp(from.position, to.position, t);
transform.rotation = Quaternion.Slerp(from.rotation, to.rotation, t);
}
}

客户端预测 (Client Prediction) — 消除操作延迟感

本地玩家立即响应输入,同时发送给服务器,收到服务器校正后平滑修正。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class ClientPrediction : NetworkBehaviour
{
private Rigidbody _rb;
private Vector3 _serverPosition;
private bool _hasCorrection;

void Update()
{
if (!IsOwner) return;

// 1. 本地立即执行输入(预测)
Vector3 input = new Vector3(
Input.GetAxisRaw("Horizontal"), 0,
Input.GetAxisRaw("Vertical")
);
_rb.MovePosition(transform.position + input * 5f * Time.deltaTime);

// 2. 发送输入到服务器
MoveServerRpc(input);

// 3. 收到服务器校正后修正
if (_hasCorrection)
{
float error = Vector3.Distance(transform.position, _serverPosition);
transform.position = error > 1f
? _serverPosition // 误差大:强制修正
: Vector3.Lerp(transform.position, _serverPosition, 0.2f); // 误差小:平滑修正
_hasCorrection = false;
}
}

[ServerRpc]
void MoveServerRpc(Vector3 input)
{
// 服务器执行权威逻辑
_rb.MovePosition(transform.position + input * 5f * Time.deltaTime);
// 广播权威位置给该客户端
CorrectPositionClientRpc(transform.position);
}

[ClientRpc]
void CorrectPositionClientRpc(Vector3 authoritative)
{
if (!IsOwner) return;
_serverPosition = authoritative;
_hasCorrection = true;
}
}

RPC — 触发瞬时事件

RPC 不用于持续状态同步,专门处理”一次性事件”如特效、音效、技能触发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class CombatEffects : NetworkBehaviour
{
[SerializeField] private ParticleSystem _hitEffect;
[SerializeField] private AudioClip _hitSound;

// 服务器判定命中后,通知所有客户端播放特效
public void OnHit(Vector3 hitPoint)
{
if (!IsServer) return;
PlayHitEffectClientRpc(hitPoint);
}

[ClientRpc]
void PlayHitEffectClientRpc(Vector3 hitPoint)
{
_hitEffect.transform.position = hitPoint;
_hitEffect.Play();
AudioSource.PlayClipAtPoint(_hitSound, hitPoint);
}

// 客户端请求服务器执行技能
[ServerRpc(RequireOwnership = true)]
public void CastSkillServerRpc(int skillId, Vector3 targetPos)
{
// 服务器验证并执行
SkillManager.Execute(skillId, targetPos, OwnerClientId);
}
}

RPC特性

[ClientRpc] 是 C# 的 Attribute(特性) 语法,是 Unity Netcode for GameObjects (NGO) 框架提供的标记。

标记特性
1
2
[AttributeName]          // 标记在类、方法、字段上
public void MyMethod() { }

本质是一种元数据标签,告诉框架”这个方法有特殊行为”。

NGO 中的 RPC 特性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class MyNetworkObject : NetworkBehaviour
{
// [ClientRpc] = 服务器调用,在所有客户端执行
// 方法名必须以 ClientRpc 结尾
[ClientRpc]
public void PlayEffectClientRpc(Vector3 pos)
{
// 这段代码在每个客户端上运行
Instantiate(effectPrefab, pos, Quaternion.identity);
}

// [ServerRpc] = 客户端调用,在服务器执行
// 方法名必须以 ServerRpc 结尾
[ServerRpc]
public void RequestFireServerRpc(Vector3 direction)
{
// 这段代码只在服务器上运行
SpawnBullet(direction);
}
}

// 调用方式
void Update()
{
if (IsServer)
PlayEffectClientRpc(transform.position); // 服务器调用,广播到所有客户端

if (IsOwner)
RequestFireServerRpc(aimDirection); // 客户端调用,发送到服务器
}

NGO 2.0 之后推荐统一用 [Rpc]:

NGO 2.0 新写法(Rpc Attribute)
1
2
3
4
5
6
7
8
9
10
11
12
13
// 新写法
[Rpc(SendTo.ClientsAndHost)]
public void PlayEffectRpc(Vector3 pos) { }

[Rpc(SendTo.Server)]
public void RequestFireRpc(Vector3 dir) { }

// 旧写法(仍然支持)
[ClientRpc]
public void PlayEffectClientRpc(Vector3 pos) { }

[ServerRpc]
public void RequestFireServerRpc(Vector3 dir) { }

[ClientRpc] → 方法名必须以 ClientRpc 结尾,否则报错
[ServerRpc] → 方法名必须以 ServerRpc 结尾,否则报错

差量同步 (Delta Compression)

核心思想:只发送”变化的部分”,不发送没变的数据。

最简单的实现:字段级脏标记

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
[System.Flags]
public enum PlayerDirtyFlags : uint
{
None = 0,
Position = 1 << 0,
Rotation = 1 << 1,
Health = 1 << 2,
Mana = 1 << 3,
Animation = 1 << 4,
}

public class PlayerState
{
public Vector3 Position;
public float Rotation;
public int Health;
public int Mana;
public int AnimationId;

private PlayerState _lastSent = new(); // 上次发送的快照
private PlayerDirtyFlags _dirty;

// 修改字段时标记脏
public void SetPosition(Vector3 pos)
{
if (Position == pos) return;
Position = pos;
_dirty |= PlayerDirtyFlags.Position;
}

public void SetHealth(int hp)
{
if (Health == hp) return;
Health = hp;
_dirty |= PlayerDirtyFlags.Health;
}

// 序列化:只写入脏字段
public byte[] SerializeDelta()
{
using var writer = new BinaryWriter(new MemoryStream());

writer.Write((uint)_dirty); // 先写标记位

if (_dirty.HasFlag(PlayerDirtyFlags.Position))
{
writer.Write(Position.x);
writer.Write(Position.y);
writer.Write(Position.z);
}
if (_dirty.HasFlag(PlayerDirtyFlags.Health))
writer.Write(Health);
if (_dirty.HasFlag(PlayerDirtyFlags.Mana))
writer.Write(Mana);
// ... 其他字段

_dirty = PlayerDirtyFlags.None; // 清除脏标记
return ((MemoryStream)writer.BaseStream).ToArray();
}

// 反序列化:根据标记位读取对应字段
public void DeserializeDelta(byte[] data)
{
using var reader = new BinaryReader(new MemoryStream(data));

var flags = (PlayerDirtyFlags)reader.ReadUInt32();

if (flags.HasFlag(PlayerDirtyFlags.Position))
{
Position = new Vector3(
reader.ReadSingle(),
reader.ReadSingle(),
reader.ReadSingle()
);
}
if (flags.HasFlag(PlayerDirtyFlags.Health))
Health = reader.ReadInt32();
if (flags.HasFlag(PlayerDirtyFlags.Mana))
Mana = reader.ReadInt32();
}
}

快照对比法(适合复杂状态)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
public class SnapshotDelta
{
// 完整快照
public struct WorldSnapshot
{
public Dictionary<int, PlayerSnapshot> Players;
public int Tick;
}

public struct PlayerSnapshot
{
public Vector3 Position;
public int Health;
public int Score;
}

private WorldSnapshot _baseline; // 客户端已确认的基准快照

// 服务器:对比两个快照,生成差量包
public byte[] GenerateDelta(WorldSnapshot from, WorldSnapshot to)
{
using var writer = new BinaryWriter(new MemoryStream());

writer.Write(to.Tick);

foreach (var (id, toPlayer) in to.Players)
{
// 新增玩家
if (!from.Players.TryGetValue(id, out var fromPlayer))
{
writer.Write((byte)DeltaOp.Add);
writer.Write(id);
WriteFullPlayer(writer, toPlayer);
continue;
}

// 对比变化
PlayerDirtyFlags dirty = PlayerDirtyFlags.None;
if (fromPlayer.Position != toPlayer.Position) dirty |= PlayerDirtyFlags.Position;
if (fromPlayer.Health != toPlayer.Health) dirty |= PlayerDirtyFlags.Health;
if (fromPlayer.Score != toPlayer.Score) dirty |= PlayerDirtyFlags.Score;

if (dirty != PlayerDirtyFlags.None)
{
writer.Write((byte)DeltaOp.Update);
writer.Write(id);
writer.Write((uint)dirty);
if (dirty.HasFlag(PlayerDirtyFlags.Position)) WriteVector3(writer, toPlayer.Position);
if (dirty.HasFlag(PlayerDirtyFlags.Health)) writer.Write(toPlayer.Health);
}
}

// 离开的玩家
foreach (var id in from.Players.Keys)
{
if (!to.Players.ContainsKey(id))
{
writer.Write((byte)DeltaOp.Remove);
writer.Write(id);
}
}

return ((MemoryStream)writer.BaseStream).ToArray();
}

enum DeltaOp : byte { Add, Update, Remove }
}

位置压缩(进一步降低带宽)

位置数据是最频繁变化的,可以额外压缩:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public static class PositionCompressor
{
// 全量:Vector3 = 12字节
// 压缩后:只发变化量,用short存,约4-6字节

private const float Precision = 0.01f; // 1cm精度
private const float MaxDelta = 32767 * Precision; // short最大值对应的距离

// 发送位置差值而不是绝对值
public static short[] CompressDelta(Vector3 from, Vector3 to)
{
Vector3 delta = to - from;
return new short[]
{
(short)(delta.x / Precision),
(short)(delta.y / Precision),
(short)(delta.z / Precision)
};
// 3个short = 6字节,比12字节少一半
}

public static Vector3 DecompressDelta(Vector3 baseline, short[] delta)
{
return baseline + new Vector3(
delta[0] * Precision,
delta[1] * Precision,
delta[2] * Precision
);
}
}

带宽对比

1
2
3
4
5
6
7
8
100个玩家,每人10个字段,20次/秒广播:

全量同步:
100 × 10字段 × 4字节 × 20次 = 800 KB/s

差量同步(假设每帧平均20%字段变化):
4字节标记位 + 变化字段
100 × (4 + 2字段×4字节) × 20次 ≈ 176 KB/s → 节省约78%

在实际游戏中,应该根据不同的数据类型需要使用不同的差量压缩机制:

  • 移动中的玩家:位置每帧变,用压缩差值
  • 静止玩家:位置不变,完全不发
  • 血量/状态:只在变化时发一次,不需要每帧发
  • 大地图场景:加上 AOI(Area of Interest),只同步视野范围内的玩家,带宽再降一个数量级

核心流程总结

1
2
3
4
5
6
7
客户端输入
↓ ServerRpc
服务器执行权威逻辑
↓ NetworkVariable 自动同步 / ClientRpc 触发事件
所有客户端收到状态

插值平滑表现 + 本地预测修正

NetworkVariable 管持续状态,RPC 管瞬时事件,预测+插值管体验,三者配合就是状态同步在 Unity 里的完整实现。