跳转到内容

第 43 章 · 加入体感优化

约 6 分钟 难度:5 动手章

这一章解决:玩家按下攻击后,如何立即得到反馈,同时让伤害、分数、敌人死亡仍以 Owner 确认为准。

先看一下

第 42 章已经能打敌人,但流程偏硬:交互发请求,等待 GameState 和 EnemyObject 裁决,再看到 HP、死亡和分数变化。网络延迟只有 100 ms 时,按钮也会显得慢。第 43 章加的是体感层,不改共识层。

这一章会拿到什么

  • 一张「本地先播 / 等确认 / 可降级」判断表
  • 命中请求的 pending 记录:pendingHitRequestId、目标、预估伤害
  • 确认回包:接受、拒绝、修正三种结果
  • 远端命中特效与本地预演的分层
  • 网络拥塞时的反馈降级规则

依赖前面

  • 第 29 章的本地预演边界
  • 第 30 章的 Owner 确认回包
  • 第 32 章的修正、插值与降级
  • 第 42 章的 EnemyObject 命中裁决
  • 闭环 4 的 matchId

第 43 章不改第 42 章的结果归属。伤害仍由 EnemyObject Owner 写,分数仍由 GameState Owner 写,结算仍由 GameState 写。

内容何时发生写权
按钮按下态、音效、准星反馈本地立即发生本地客户端
命中请求本地发出,带 requestId玩家自己的 PlayerObject
敌人 HP 变化Owner 接受请求后发生EnemyObject Owner
分数和剩余敌人数敌人死亡后发生GameState Owner
击中特效广播Owner 确认后发生Network Event

这张表的边界和第 29 章一致:本地预演可以提前改变表现,不能提前改变共识状态。合作防守世界里,玩家会感受到「按下去立刻有声音和动画」,但迟入玩家只看同步字段里的结果。


玩家每次攻击时,本地先记录一个 pending。这个 pending 不同步,只服务本地 UI。

private int pendingHitRequestId = -1;
private int pendingEnemyId = -1;
private int pendingDamage;
private double pendingStartTime;
public void PressAttack(int enemyId)
{
if (pendingHitRequestId >= 0) return;
int requestId = NextLocalRequestId();
pendingHitRequestId = requestId;
pendingEnemyId = enemyId;
pendingDamage = localDamagePreview;
pendingStartTime = Networking.GetServerTimeInSeconds();
ApplyLocalAttackPreview(enemyId);
RequestHitEnemy(enemyId, localDamagePreview);
}

ApplyLocalAttackPreview 可以播放音效、按钮冷却、准星扩散、近端枪口火光。它不改 EnemyObject.hp,也不改 GameState.totalScore

private void ApplyLocalAttackPreview(int enemyId)
{
attackButton.interactable = false;
localAudio.PlayOneShot(attackPreviewClip);
muzzleFlash.Play();
ShowPendingMarker(enemyId);
}

pending 的存在还能防连点。未确认前再次攻击同一个目标,可以直接挡掉,也可以排队一条。第一版采用挡掉,避免多个 pending 同时修正。


第 42 章 GameState 裁决命中后,给发起玩家一个确认回包。回包里至少带请求号和结果。

结果含义本地处理
accepted命中成立,伤害已交给 EnemyObject清 pending,进入真实冷却
rejected阶段不对、目标无效、敌人已死、请求过旧撤销按钮 pending,显示短提示
corrected命中成立,但伤害或目标被修正清 pending,显示修正后的伤害

片段如下:

[NetworkCallable]
public void OnHitConfirmed(int requestId, byte result, int finalDamage)
{
if (!Networking.IsOwner(gameObject)) return;
if (requestId != pendingHitRequestId) return;
pendingHitRequestId = -1;
pendingEnemyId = -1;
if (result == HIT_ACCEPTED)
{
StartConfirmedCooldown();
return;
}
if (result == HIT_CORRECTED)
{
ShowCorrectedDamage(finalDamage);
StartConfirmedCooldown();
return;
}
CancelLocalAttackPreview();
}

确认回包不是长期状态。迟入玩家不需要知道某个按钮是否曾经 pending,只需要知道敌人当前 HP、死亡状态、分数和阶段。

第 30 章讲过,单发给某个玩家通常是 All + playerId 本地过滤。第 43 章只把它用于低频攻击确认。不要把每一帧准星、连发输入或隐藏信息塞进确认回包;高频内容要么留本地,要么回到同步字段和降级策略里。


攻击反馈分本地层和远端层。

谁看到何时播放可否丢失
本地预演发起玩家按下按钮立刻播放可以丢,不进恢复
确认特效所有当前玩家Owner 接受后广播可以丢,结果已进同步字段
结果状态所有人含迟入同步字段到达后应用不能丢

本地预演解决手感。确认特效解决「其他玩家也看到这次命中」。结果状态解决迟入和结算。

GameState 或 EnemyObject 确认命中后,可以广播一条参数化事件:

[NetworkCallable]
public void NetPlayHitFx(int enemyId, int attackerPlayerId, int damage)
{
PlayHitFx(enemyId, attackerPlayerId, damage);
}

这条事件不负责加分,不负责扣血。它只是表现层。网络拥塞时可以少发或采样发送。


Networking.IsCloggedOnPostSerialization 失败增多时,按语义重要性降级。

优先级保留降级
1GameState 阶段、分数、波次不降级,只减少发送频率
2EnemyObject HP、死亡状态合并更新,减少非关键字段
3PlayerObject HP、个人分保留,必要时降低 UI 刷新频率
4确认特效广播采样发送,只播关键击杀
5本地预演本地仍可播,不占网络

网络差时,先停远端飘字、远端音效和非关键命中特效。GameState 的阶段、分数、波次不能停。否则迟入和结算会坏。

private bool ShouldBroadcastHitFx(bool isKill)
{
if (isKill) return true;
if (Networking.IsClogged) return false;
return true;
}

这段的意思很简单:击杀特效保留,普通命中在拥塞时只本地播。


Owner 拒绝请求时,反馈要短,不要弹出一长段解释。常见原因只有几类。

原因UI 文案方向
目标已经死亡「目标已清除」
不在 INGAME「比赛未开始」
请求过旧不提示,直接恢复按钮
距离或冷却不合法「距离不够」或「冷却中」
网络拥塞「同步中」

这些提示是本地 UI,不进入同步字段。它们帮助发起者理解按钮为什么恢复了,不能成为规则判断依据。


挑一条试。

  • 把普通命中特效改成「只本地播」,击杀特效仍广播给所有人。多人测试时,远端玩家是否还能读懂战斗发生了什么?
  • 把 pending 策略从「未确认前挡掉下一次攻击」改成「允许排队 2 次」。需要多保存哪些字段?拒绝第二条请求时 UI 怎么恢复?
场景预期实际
1. 单客户端按攻击按钮立即反馈,本地音效和枪口火光播放,共识字段未提前变化
2. Owner 接受命中pending 清空,EnemyObject HP 更新,必要时播放确认特效
3. Owner 拒绝命中pending 清空,按钮恢复,HP / 分数 / 阶段无跳变
4. 击杀敌人本地预演先播,确认后所有当前玩家看到击杀表现,迟入只看死亡状态
5. 网络拥塞普通远端命中特效减少,GameState 和 EnemyObject 同步仍保留
6. 连续快速点击pending 未清空前不会制造多条重复伤害
7. 迟入玩家加入不补播旧预演和旧特效,只看到当前敌人状态和分数
  • 能把攻击拆成本地预演、待确认请求、共识结果三层
  • 能解释确认回包为什么不能代替同步字段
  • 能说明网络拥塞时先降级哪些表现
  • 能指出哪些 UI 提示只属于本地层
  • 能把本章和第 42 章的命中裁决边界区分开