3.5 实战:一次受击同步的完整链路
把前三节的内容串起来。需求:玩家 A 砍中怪物 M,要求——服务器裁决伤害、A 看到打击感、其他玩家看到 M 受击表现、M 的血量对所有端正确。
错误版本(新手典型)
A 的客户端:
碰撞检测命中 M → 本地直接 M.Health -= 50(不等服务器)
→ MulticastPlayHit(M) 想让所有人看见
结果:A 本地血量改了但服务器不知道 → 复制属性同步回来把血量冲掉;Multicast 从客户端调用无效,别人看不到。
三个错:客户端改复制状态、客户端调 Multicast、没有单一裁决。
正确版本
步骤 1 — 客户端:检测命中,发起请求(带防伪造)
// A 的武器(客户端权威检测命中盒)
if (HasAuthority()) { ApplyHit(M, 50); } // 服务器自己触发,直接走
else { ServerReportHit(M, 50); } // 客户端只「报告」
步骤 2 — 服务器:验证并裁决
bool AWeapon::ServerReportHit_Validate(AMonster* M, int32 Dmg)
{
if (!IsValid(M) || Dmg <= 0 || Dmg > 1000) return false; // 参数合法
if (!M->IsAlive()) return false; // 状态合法
if (!IsWithinRange(M)) return false; // 距离合法(防瞬移攻击)
return true;
}
void AWeapon::ServerReportHit_Implementation(AMonster* M, int32 Dmg)
{
const float Final = M->ApplyDamage(Dmg); // 服务器算最终伤害(含减伤/暴击)
// 血量是 M 的复制属性 → 自动广播给所有相关客户端(OnRep_Health 触发表现)
}
步骤 3 — 受击表现:谁看到什么
- 打击感(A 自己):客户端预测播放(挥砍顿帧、镜头抖动),不等服务器——手感第一。
- 受击表现(所有看到 M 的客户端):M 的 OnRep_Health 回调播放「掉血飘字 + 受击动画」。这就是事件通道的选择——用属性复制天然解决「所有相关端」的分发。
- 死亡.special 表现:如果伤害致死,服务器在 M 上 Multicast 一次播死亡特写(只在死亡瞬间一次,Reliable 值得)。
步骤 4 — 校正
A 的客户端可能预测了伤害 50,服务器算出 45(M 有减伤)。血量复制回来时 OnRep 把血量设成正确值——飘字可能先飘了 50 再被修正。解法:伤害飘字也由 OnRep 驱动(以服务器值为准),客户端预测的指示性伤害做小动画淡出即可。
链路总结图
A.Client Server M(复制属性) 其他客户端
│ │ │ │
│── 命中检测(本地) ─→│ │ │
│ ServerReportHit │ │ │
│ ├── Validate+裁决 ──→ Health -= Final │
│ │ │ │
│ │ 复制属性广播 ───────────→│
│ │ │ OnRep_Health │
│←────────── 复制属性(A 也是相关客户端)──────────────────│
│ OnRep_Health(A 也看到正确血量) │
四条设计原则复盘:
- 客户端只报告意图,服务器裁决结果。
- 持续状态(血量)走复制,一次性事件(死亡特写)走 RPC。
- 表现层能预测就预测,服务器值到达时校正。
- Validate 是防线,Implementation 里再做状态二次校验。
延伸:打击感与延迟的平衡
在 100ms 延迟下,A 挥刀劈中 M 的「命中停顿」如果等服务器回包,手感糊成泥。标准做法是客户端预测命中表现:本地播顿帧 + 音效(0.05s 内完成,短到服务器校正到来前就结束了),伤害飘字延迟到 OnRep。玩家感知不到延迟,逻辑仍然权威。
心得:把这套「报告-裁决-复制-表现」的链路画成项目内标准模板。我们团队所有伤害、技能、交互都套这个模板,联机 bug 率降了一个数量级——因为整个项目的网络交互只有一种形状,review 和排错都模式化。
