《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡

公平与手感之间的取舍。本节讲延迟补偿的常见做法与服务端回滚判定所需的客户端配合,把「双人竞速各跑各的」讲透——纯计时不需要补偿,只有碰撞干扰才需要,并给出带死区与上限的 L1 补偿决策函数、配套单测,以及一张把零补偿到无上限动态补偿逐项对比的公平与手感权衡表。

9.3 延迟补偿与体验权衡

9.1 把远端玩家插值出来,9.2 把网络状态告诉玩家,本节处理最敏感的一环:当两个玩家延迟不同时,要不要「补偿」,补到什么程度。这个问题的答案没有对错,只有取舍——而取舍的边界,取决于你做的到底是什么玩法。

与站内 game/server/ 判定文的分工:服务端回滚、战斗裁决是服务端实现,那里已经讲透,本节不重复;本节只讲客户端该做什么配合,以及客户端能独立决定的补偿上限。

9.3.1 补偿的本质:主动制造一点不公平

两个人隔着网络,看到的从来不是同一时刻的世界。A 的屏幕上是 50 ms 前的 B,B 的屏幕上是 180 ms 前的 A。如果服务端严格按「收到的时刻」判定,高延迟玩家会觉得「我明明打中了却不算」;如果服务端严格按「发指令的时刻」判定,低延迟玩家会觉得「我明明躲开了却被打中」。

补偿就是在这两者之间选一个点。它不是消除延迟,而是把延迟带来的不公平,从一种形式换成另一种形式:

  • 偏向前者(按收到时刻判定):公平,但高延迟玩家手感差。
  • 偏向后者(按发指令时刻判定):高延迟玩家手感好,但他在「过去」判定命中,等于拿到了优势。

大多数联机游戏取中间:判定按服务端权威,表现上给高延迟玩家一点帮助,并且这个帮助有上限。上限是本节后半段的重点——没有上限的补偿,就是「延迟越高越强」。

9.3.2 服务端回滚判定的客户端侧配合

「回滚判定」指服务端把判定回溯到某个玩家按下指令的那一刻去算——这件事本身在服务端做,客户端不实现。客户端能做、也必须做的是给服务端提供可回滚的历史:

  1. 给每条输入打上本地单调时间戳(clientTimeMs),而不只是序号。
  2. 保存输入历史:最近的若干帧输入,按 clientTick 索引。
  3. 保存状态历史:本地预测过的状态,供和解时重演(4.2 已经做过)。

客户端上报的输入消息约定如下(服务端实现见《游戏服务端编程实践》,本卷不贴代码):

字段类型方向含义
typestringC→S固定为 "input"
seqnumberC→S输入序号,单调递增,用于幂等(5.2)
clientTicknumberC→S该输入对应的本地逻辑帧号
clientTimeMsnumberC→S该输入的本地单调时钟读数(毫秒)
movenumberC→S水平输入,取值 -1 / 0 / 1
jumpbooleanC→S是否触发跳跃

客户端时间戳只是相对量。 服务端不该拿 clientTimeMs 的绝对值去回滚——那等于把判定交给客户端,8.2 讲过为什么不能信。服务端应结合「收到该包的服务端时刻」与「已测 RTT」推算回滚窗口,clientTimeMs 只用来对齐同一客户端内部的先后关系。

9.3.3 客户端本地表现补偿

客户端能独立决定的补偿有两类,都不需要服务端参与:

  • 命中特效提前播。这是 4.1 本地预测的直接应用:本地判定「我撞到了对手」,先播命中特效和音效,不等服务端确认。若服务端随后否定了这次命中,再静默撤销(不播「撤销特效」——那比不播还难受)。
  • 位移吸附(soft correction)。服务端和解后给出的权威位置,和本地预测位置之间会有偏差。直接 setPosition 瞬移会让画面「跳一下」,所以按偏差大小分档处理:
位置偏差处理方式理由
小于 2 px忽略,交给插值自然收敛肉眼不可见,硬拉反而引入抖动
2 ~ 32 px每帧向目标线性靠拢(lerp)短距离平滑,玩家几乎察觉不到
大于 32 px直接瞬移通常是重连 / 传送,平滑移动反而像穿墙作弊

吸附组件(L2)的骨架如下,它每帧把节点朝权威位置拉一点:

// assets/scripts/net/sync/SoftCorrection.ts
import { _decorator, Component, Node, Vec3 } from 'cc';

const { ccclass, property } = _decorator;

@ccclass('SoftCorrection')
export class SoftCorrection extends Component {
  @property(Node)
  target: Node | null = null;

  @property
  smoothSpeed = 12;

  @property
  snapDist = 32;

  private readonly desired = new Vec3();
  private readonly tmp = new Vec3();

  /** 服务端和解后调用,把节点朝权威位置平滑吸附。 */
  correctTo(x: number, y: number): void {
    this.desired.set(x, y, 0);
  }

  update(dt: number): void {
    if (!this.target) return;
    const cur = this.target.getPosition(this.tmp);
    const dist = Vec3.distance(cur, this.desired);
    if (dist > this.snapDist) {
      this.target.setPosition(this.desired);
      return;
    }
    const k = Math.min(1, this.smoothSpeed * dt);
    Vec3.lerp(this.tmp, cur, this.desired, k);
    this.target.setPosition(this.tmp);
  }
}

smoothSpeed * dt 这个系数决定收敛快慢:12 大约意味着 0.1 秒内走完大半偏差。它和服务端判定用的补偿量是两个独立的旋钮——一个管画面,一个管判定,别混用。

9.3.4 双人竞速:各跑各的,计时与干扰分开

这是本节最该讲透的一点。竞速看起来「两个人在一起跑」,但拆开看,它由两件独立的事组成:

  • 进度:谁先到终点。
  • 交互:谁撞了谁、谁抢了谁的道具。

进度不需要补偿。 因为「谁先到终点」由服务端的绝对时间戳判定:每个玩家经过检查点时,服务端记录的是「服务端收到该检查点上报的时刻」(再扣掉已测 RTT),这个值与另一个玩家此刻在哪、看到了什么毫无关系。A 先到就是先到,B 就算延迟 300 ms 也改变不了 A 的服务端时间戳更早。纯计时的竞速,补偿量为零。

交互才需要补偿。 当两个玩家会互相碰撞、推挤、抢道具时,判定依赖「在某个时刻,两人各自在哪」——这时两人的延迟差才会让判定出现分歧,回滚判定才有意义。

玩法元素是否需要补偿原因
经过检查点 / 冲线计时否判定基于服务端绝对时间戳,与对手无关
抢道具(同一道具只有一个)是依赖「谁先到道具点」,是两人的相对竞争
身体碰撞 / 推挤是依赖两人在同一时刻的相对位置
各自躲障碍否障碍对两人独立,不涉及相对位置

所以《方块冲刺》双人竞速里的正确做法是:把纯计时的部分做成零补偿,把碰撞干扰单独拎出来做补偿,而不是给整局游戏套一个统一的补偿系数。给计时也加补偿,等于让高延迟玩家在「过去」冲线——纯属自找麻烦。

9.3.5 L1:补偿决策函数

补偿量本身可以写成一个纯函数:输入双方延迟估计,输出补偿量。下面是 assets/scripts/net/sync/lag-compensation.ts(L1):

// sync/lag-compensation.ts —— 纯 TS,延迟补偿策略
export type RaceMode = 'pure-time' | 'interference';

export interface CompInput {
  localRttMs: number; // 本地往返延迟(已平滑)
  remoteRttMs: number; // 对手延迟估计
  baseMs: number; // 基础补偿量
}

export interface CompResult {
  compMs: number; // 实际施加的补偿量
  capped: boolean; // 是否被上限截断
}

export const MAX_COMP_MS = 150; // 补偿上限:再高就等于给高延迟玩家开挂
export const DEADZONE_MS = 60; // 死区:低于此延迟差不补偿

/** 纯计时竞速不需要补偿;只有会互相干扰的赛道才需要。 */
export function needsCompensation(mode: RaceMode): boolean {
  return mode === 'interference';
}

/** 根据双方延迟估计给出补偿量。只补劣势,不惩罚低延迟玩家。 */
export function computeCompensation(input: CompInput): CompResult {
  const { localRttMs, remoteRttMs, baseMs } = input;
  const disadvantage = localRttMs - remoteRttMs;
  if (disadvantage <= DEADZONE_MS) return { compMs: 0, capped: false };
  const raw = baseMs + (disadvantage - DEADZONE_MS) * 0.5;
  const compMs = Math.min(raw, MAX_COMP_MS);
  return { compMs, capped: compMs < raw };
}

三条规则都写进了代码:死区(DEADZONE_MS)让正常延迟差不进补偿流程;只补劣势(disadvantage <= DEADZONE_MS 直接返回 0)保证低延迟玩家不被拉慢;上限(MAX_COMP_MS)保证补偿不会大到改变胜负。

9.3.6 L1:单测

补偿函数最需要钉死的是两件事:低延迟玩家不被惩罚、补偿量有硬上限。

import { describe, expect, it } from 'vitest';
import {
  computeCompensation,
  needsCompensation,
  MAX_COMP_MS,
  type CompInput,
} from './lag-compensation';

const input = (localRttMs: number, remoteRttMs: number, baseMs = 20): CompInput =>
  ({ localRttMs, remoteRttMs, baseMs });

describe('computeCompensation', () => {
  it('低延迟玩家不被惩罚:本地更快时补偿为 0', () => {
    expect(computeCompensation(input(40, 200)).compMs).toBe(0);
  });

  it('延迟差在死区内不补偿', () => {
    expect(computeCompensation(input(100, 60)).compMs).toBe(0); // 差 40 < 60
  });

  it('延迟差超出死区后给出正补偿', () => {
    // 差 170,raw = 20 + (170 - 60) * 0.5 = 75
    expect(computeCompensation(input(200, 30)).compMs).toBeCloseTo(75);
  });

  it('补偿量有硬上限', () => {
    const r = computeCompensation(input(1000, 0));
    expect(r.compMs).toBe(MAX_COMP_MS);
    expect(r.capped).toBe(true);
  });

  it('补偿量随劣势单调不减,且封顶', () => {
    const a = computeCompensation(input(150, 30)).compMs;
    const b = computeCompensation(input(300, 30)).compMs;
    const c = computeCompensation(input(900, 30)).compMs;
    expect(a).toBeLessThanOrEqual(b);
    expect(b).toBeLessThanOrEqual(c);
    expect(c).toBe(MAX_COMP_MS);
  });
});

describe('needsCompensation', () => {
  it('纯计时竞速不需要补偿,干扰赛道才需要', () => {
    expect(needsCompensation('pure-time')).toBe(false);
    expect(needsCompensation('interference')).toBe(true);
  });
});

第一条测试是这套策略的底线:本地比对手快,补偿必须是 0。如果实现里不小心写成 Math.abs(localRttMs - remoteRttMs),低延迟玩家就会因为「延迟差大」而被补偿,等于把优势抹平——测试会立刻抓到。

9.3.7 补偿上限、死区与滥用风险

补偿是一把双刃剑,三个旋钮都要有,缺一个就会出事:

旋钮缺了会怎样建议值
上限 MAX_COMP_MS高延迟玩家在「过去」判定命中,延迟越高越强100 ~ 150 ms
死区 DEADZONE_MS正常延迟差也被补偿,平白引入不公平50 ~ 80 ms
只补劣势低延迟玩家被拉慢,快的人反而吃亏必须(代码硬约束)

滥用风险要讲清楚:补偿不是「照顾弱者」。如果给一个 500 ms 延迟的玩家补偿 300 ms,他就能在一个别人早已离开的位置判定命中——这不是手感优化,这是用延迟当外挂。所以上限不是可选项,而是反作弊的一部分(8.1 讲的客户端权威边界,在补偿这里同样成立)。

可配置项汇总:MAX_COMP_MS、DEADZONE_MS、baseMs、以及「是否对该赛道启用补偿」的 RaceMode。这四个值建议做成关卡配置,让「纯计时赛道」与「干扰赛道」各自取不同的值。

9.3.8 公平 vs 手感:一张权衡表

把本节所有取舍收进一张表。做联机竞速时,照着这张表逐项决策即可:

做法公平性手感适用场景
零补偿(严格按收到时刻判定)最高高延迟玩家最差纯计时竞速、回合制
固定小补偿(baseMs,无动态)高中等延迟改善有限对公平极敏感的排位
动态补偿 + 死区 + 上限中(有界)高延迟玩家明显改善有碰撞干扰的实时竞速
无上限动态补偿低高延迟玩家最好不建议;等于延迟外挂
客户端本地表现补偿(特效提前、位移吸附)不影响判定所有玩家都受益一律启用

一句话总结这四节的取舍:判定归服务端、表现归客户端、补偿必须有界。 判定层不加客户端权威,表现层尽量让玩家舒服,两者之间用「有上限、有死区、只补劣势」的补偿函数连接。

9.3.9 工程决策:本节之后客户端多了什么

  • 新增 assets/scripts/net/sync/lag-compensation.ts(L1 决策函数)与 SoftCorrection.ts(L2 吸附组件)。
  • 输入消息增加 clientTimeMs 字段,客户端维护按 clientTick 索引的输入历史(与 4.2 的状态历史并列)。
  • 可配置项:MAX_COMP_MS、DEADZONE_MS、baseMs、RaceMode、smoothSpeed / snapDist。
  • 一条硬规则:计时判定零补偿,只有碰撞与抢道具才走补偿函数——这条写进玩法配置,不要留给代码里的 if 分支临时判断。

第 9 章把表现与体验补齐了:9.1 让远端玩家动起来,9.2 让玩家看得懂网络状态,9.3 让不同延迟的两个人打得公平又不难受。下一章把这一整套接进《方块冲刺》,做成真正的双人竞速,并对齐既有服务端书稿的对接约定。

阅读导航:上一节:9.2 网络状态 UI 与提示 · 下一节:10.1 把《方块冲刺》做成双人竞速 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  2. 《Cocos 游戏联机实战》9.1 同步表现与插值动画
  3. 《Cocos 游戏联机实战》8.3 通信加密与防篡改