9.3 延迟补偿与体验权衡
9.1 把远端玩家插值出来,9.2 把网络状态告诉玩家,本节处理最敏感的一环:当两个玩家延迟不同时,要不要「补偿」,补到什么程度。这个问题的答案没有对错,只有取舍——而取舍的边界,取决于你做的到底是什么玩法。
与站内 game/server/ 判定文的分工:服务端回滚、战斗裁决是服务端实现,那里已经讲透,本节不重复;本节只讲客户端该做什么配合,以及客户端能独立决定的补偿上限。
9.3.1 补偿的本质:主动制造一点不公平
两个人隔着网络,看到的从来不是同一时刻的世界。A 的屏幕上是 50 ms 前的 B,B 的屏幕上是 180 ms 前的 A。如果服务端严格按「收到的时刻」判定,高延迟玩家会觉得「我明明打中了却不算」;如果服务端严格按「发指令的时刻」判定,低延迟玩家会觉得「我明明躲开了却被打中」。
补偿就是在这两者之间选一个点。它不是消除延迟,而是把延迟带来的不公平,从一种形式换成另一种形式:
- 偏向前者(按收到时刻判定):公平,但高延迟玩家手感差。
- 偏向后者(按发指令时刻判定):高延迟玩家手感好,但他在「过去」判定命中,等于拿到了优势。
大多数联机游戏取中间:判定按服务端权威,表现上给高延迟玩家一点帮助,并且这个帮助有上限。上限是本节后半段的重点——没有上限的补偿,就是「延迟越高越强」。
9.3.2 服务端回滚判定的客户端侧配合
「回滚判定」指服务端把判定回溯到某个玩家按下指令的那一刻去算——这件事本身在服务端做,客户端不实现。客户端能做、也必须做的是给服务端提供可回滚的历史:
- 给每条输入打上本地单调时间戳(
clientTimeMs),而不只是序号。 - 保存输入历史:最近的若干帧输入,按
clientTick索引。 - 保存状态历史:本地预测过的状态,供和解时重演(4.2 已经做过)。
客户端上报的输入消息约定如下(服务端实现见《游戏服务端编程实践》,本卷不贴代码):
| 字段 | 类型 | 方向 | 含义 |
|---|---|---|---|
type | string | C→S | 固定为 "input" |
seq | number | C→S | 输入序号,单调递增,用于幂等(5.2) |
clientTick | number | C→S | 该输入对应的本地逻辑帧号 |
clientTimeMs | number | C→S | 该输入的本地单调时钟读数(毫秒) |
move | number | C→S | 水平输入,取值 -1 / 0 / 1 |
jump | boolean | C→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 把《方块冲刺》做成双人竞速 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。