跳转到内容

第 29 章 · 即时反馈与本地预演

约 6 分钟 难度:4 动手章

这一章解决:玩家按下按钮后,如何立刻得到反馈,同时不让本地反馈变成最终状态。

先看一下

第 26 章的轻实时范式把子弹轨迹留在本地预演,只把命中请求交给 GameState Owner。这样做能减少同步压力,但会带来一个新问题:按下按钮到 Owner 确认之间,本地画面应该显示什么?

这一章会拿到什么

  • 一条固定边界:本地预演只改表现,不改共识状态
  • 一份 pendingRequestId + predictedState 的最小片段
  • 一张「能先播 / 必须等确认」判断表
  • 一段把 Gambetta 客户端预测翻译到 VRChat 约束下的说明

依赖前面

  • 第 20 章的 requestId / lastProcessedRequestId 幂等工具
  • 第 21 章的参数化 Network Event 和通道决策表
  • 第 26 章的「事件做反馈、状态做共识」
  • 创作者视角 6 的「体感撒谎边界」

传统客户端预测的原模型来自权威服务器架构:客户端先按输入模拟,服务器稍后返回权威状态和已处理到的输入序号,客户端再把未确认输入重放一遍。这个模型有两个关键前提:服务器是最终真相,客户端和服务器能跑接近一致的模拟。

VRChat 世界没有自定义权威服务器。GameState Owner 只是实例内某个玩家客户端,它能裁决状态,但不能成为可信服务器。第六部因此把 prediction(预测)降级为本地预演:先让玩家看到按钮按下、音效播放、准星扩散、子弹飞出去,最终分数、伤害、胜负仍等 Owner 确认。

可以把边界写成一句工程规则:本地预演可以提前改变表现,不能提前改变共识状态。

这条规则让第 29 章不去复刻完整 server reconciliation(服务器调和),只处理 30 到 200 ms 的体感空白。


同一个「释放技能」动作里会出现三层状态。它们的写权不同。

例子写权通道
本地表现按钮变灰、播放抬手动画、发出本地音效本地客户端本地变量
待确认请求requestId=42、技能类型、目标 ID玩家自己的 PlayerObject 或参数化事件同步字段 / Network Event
共识结果伤害生效、冷却开始、分数变化、敌人死亡GameState Owner 或目标对象 Owner同步字段

本地表现可以立即发生。待确认请求要能被追踪,通常带 requestId。共识结果必须由负责该状态的 Owner 写入同步字段,迟入玩家只认这一层。


下面片段只展示决策点。它不替代闭环 3 的 PlayerObject + GameState 骨架,只是在玩家本地加一层 pending 视图。

private int nextLocalRequestId;
private int pendingRequestId = -1;
private bool predictedButtonPressed;
public void PressSkillButton()
{
if (pendingRequestId >= 0) return;
nextLocalRequestId++;
pendingRequestId = nextLocalRequestId;
predictedButtonPressed = true;
ApplyLocalPreview();
IssueSkillRequest(pendingRequestId, selectedSkillId, targetId);
}
private void ApplyLocalPreview()
{
skillButton.interactable = false;
localAudio.PlayOneShot(skillCastPreview);
localMuzzleFlash.Play();
}

predictedButtonPressed 保持为本地字段。它只是本地 UI 的影子状态。IssueSkillRequest 可以沿用第 12 章的同步字段请求,也可以在第 21 章的参数化事件里带上 requestId。关键点在本地预演和共识状态分层,通道选择排在后面。


确认有两种结果:Owner 接受,或 Owner 拒绝。接受时,本地 pending 清空,UI 改成真实冷却;拒绝时,撤销预演,给一个轻量提示。

public void OnSkillConfirmed(int confirmedRequestId, bool accepted)
{
if (confirmedRequestId != pendingRequestId) return;
pendingRequestId = -1;
predictedButtonPressed = false;
if (accepted)
{
StartConfirmedCooldown();
return;
}
CancelLocalPreview();
}

这段和传统 reconciliation 的相似点是:客户端保存了未确认输入,并在确认到来后清理。差异也很明确:这里没有从权威服务器状态重放未确认输入,因为 VRChat 没有那套服务器模拟前提。第 30 章会把这个确认回包做成 lastProcessedRequestId 形态。


判断标准是:确认失败时,撤销它会不会破坏玩家对状态的信任。

内容能否本地先播确认失败时怎么处理
按钮按下态可以恢复按钮,显示短提示
本地音效可以不回收声音,避免二次突兀
枪口火光 / 施法动作可以动画自然结束,不回滚
命中飘字谨慎建议等确认后播
伤害数字谨慎PvE 可先播「预估」,确认后修正
分数、胜负、阶段切换不先播必须等同步字段
其他玩家 HP不先改必须等对应 Owner / GameState

这张表刻意把「命中飘字」放在中间。合作 PvE 里,飘一个本地预估数字通常能接受;竞技 PvP 里,预估命中会让玩家产生「明明打中了」的冲突。第 31 章会继续拆命中判定的边界。


本地预演不一定发网络事件。两者解决的问题不同:本地预演让发起者立刻有反馈,Network Event 让其他玩家也看到一次性表现。

一个技能释放可以这样分层:本地立即播抬手和音效;向 GameState Owner 发带 requestId 的请求;Owner 接受后写同步字段并向 All 播一个确认事件;所有客户端根据确认事件播命中特效,迟入玩家则只看同步字段里的最终 HP / 分数。

如果只向 All 播事件,没有同步字段,迟入玩家会丢状态。如果只写同步字段,没有本地预演,发起者会感到按钮慢一拍。两层各自保留,体感和恢复才都稳。


挑一条试。

  • ApplyLocalPreview 里的音效删掉,只保留按钮变灰。比较输入体感的差异:少掉的到底是确认速度,还是反馈密度?
  • 把命中飘字改成「等待确认后再播」。如果网络延迟 200 ms,玩家还能接受吗?在哪类玩法里会显得慢?
场景预期实际
1. 单客户端按技能按钮按钮立即变灰,本地音效立即播放;共识字段未提前变化
2. Owner 接受请求pending 清空,进入真实冷却,同步字段更新
3. Owner 拒绝请求本地按钮恢复,分数 / HP / 阶段没有跳变
4. 迟入玩家加入不补播刚才的本地预演,只看到最新共识状态
5. 网络拥塞时连续点击pending 未清空前不会重复发同一类请求
  • 能解释「本地预演只改表现,不改共识状态」
  • 能把一次技能释放拆成本地表现、待确认请求、共识结果三层
  • 能说出传统客户端预测在 VRChat 里缺少哪两个前提
  • 能判断一个反馈是可以先播、谨慎先播,还是必须等确认
  • 能说明 Network Event 和本地预演的区别