5.3 弱网降级策略
前两节把「怎么回到同一局」「怎么把消息可靠送到」做完了。但还有一种情况它们都没覆盖:链路没断、消息也没丢,只是慢得离谱。RTT 忽高忽低、抖动几百毫秒、偶尔抽风丢一两个包。这时候断线重连帮不上忙(没断),重传也治标不治本(不是丢,是慢)。
这一节回答的是:网络差到一定程度,客户端主动放弃一部分体验,换取「还能玩」。这是一门取舍的学问——降什么、降多少、什么时候降、什么时候恢复。第 7 章会讲怎么在本地造出这种弱网来复现问题,本节先讲遇到弱网时客户端该做什么。
5.3.1 三种网络病:延迟、丢包、抖动
弱网不是一个笼统的「卡」,它至少是三种不同的病,破坏的东西各不相同:
| 病 | 表现 | 破坏什么 |
|---|---|---|
| 延迟高 | RTT 稳定但很大 | 输入到反馈的滞后感,预测误差变大 |
| 丢包 | 部分消息根本没到 | 远端玩家动作跳帧,需要重传或外推 |
| 抖动大 | RTT 忽大忽小 | 插值缓冲被打乱,画面一顿一顿 |
三种病要分开测、分开治。把它们揉成一个「网速」指标,是弱网优化的头号误区——延迟 300ms 但稳定的链路,和延迟 50ms 但抖动 200ms 的链路,处理方式完全不同。
5.3.2 分级降级表
把网络质量分成四档,每档触发一组递进的动作。核心原则是从「少发」到「降质」再到「兜底」,尽量保住核心玩法,牺牲非关键表现:
| 档位 | 触发条件(参考值) | 客户端动作 |
|---|---|---|
| Good | 延迟 < 80ms,丢包 < 1% | 正常同步,全特效 |
| Fair | 延迟 80–200ms 或丢包 1–3% | 降同步频率、加大插值缓冲、关闭非关键特效 |
| Poor | 延迟 200–400ms 或丢包 3–8% | 切换到「本地优先」模式,减少远端实体更新 |
| Bad | 延迟 > 400ms 或丢包 > 8% | 停止等待服务端,仅保留本地表现,提示玩家;持续则走断开 |
「本地优先」模式指的是:远端玩家位置不再逐帧插值,而是用外推继续往前推,收到新快照时再拉回。这会在远端角色身上造成可见的误差,但比冻住不动好。表里的阈值都是参考值,需在你的网络条件下复测,不同品类容忍度差很多。
5.3.3 滞回:别在阈值上反复横跳
降级表里最大的坑是抖动。如果 Fair 的进入线是「延迟 ≥ 80ms」,那链路在 80ms 上下浮动时,档位就会 Good↔Fair 来回跳,画面上的特效一开一关,比一直弱网还难受。
解法是滞回(hysteresis):进入某档的阈值和退出该档的阈值不相等,中间留一条「带」。比如进入 Fair 要 80ms,退出 Fair 要低于 50ms——一旦降下来,得明显好转才升回去:
进入线: 80ms ──────► 升到 Fair
退出线: 50ms ◄────── 降回 Good
↑ 50~80ms 之间保持原档,不抖
滞回还有第二个作用:档位变化每次只动一档。哪怕网络瞬间从 Good 掉到 Bad,也一档一档走,给插值缓冲和特效开关留出缓冲时间,避免一帧内改太多东西。
5.3.4 可配置项:把阈值做成 const 对象
阈值绝不能散落在代码各处写死。做成一个 const 配置对象,按机型与地区调整时只改一处:
export interface QualityConfig {
readonly windowSize: number;
/** 进入 Fair / Poor / Bad 的劣化分阈值 */
readonly enter: readonly [number, number, number];
/** 退出 Fair / Poor / Bad 的劣化分阈值(低于对应 enter,形成滞回带) */
readonly exit: readonly [number, number, number];
}
export const DEFAULT_QUALITY_CONFIG: QualityConfig = {
windowSize: 30,
enter: [80, 200, 400],
exit: [50, 140, 300],
};
为什么把三个指标折成一个「劣化分」而不是逐指标判档?因为三指标分别判档再取最大值,逻辑更绕、更难调;折成一个分数后,滞回只需要维护一条阈值线。折算公式见下节的 badness,权重是可调的参考值。
5.3.5 L1:质量评估器 + 降级决策器
滑动窗口统计与滞回决策器,纯 TypeScript、零 cc 依赖:
// assets/scripts/net/sync/quality.ts —— 纯 TS,无 cc 依赖
export interface NetSample {
readonly rttMs: number;
readonly lossRate: number; // 0..1
readonly jitterMs: number;
}
export const NetQualityLevel = {
Good: 0,
Fair: 1,
Poor: 2,
Bad: 3,
} as const;
export type NetQualityLevel =
(typeof NetQualityLevel)[keyof typeof NetQualityLevel];
/** 滑动窗口统计,窗口大小即平均的历史样本数 */
export class QualityWindow {
private readonly samples: NetSample[] = [];
private readonly capacity: number;
constructor(capacity: number) {
this.capacity = capacity;
}
push(sample: NetSample): void {
this.samples.push(sample);
if (this.samples.length > this.capacity) {
this.samples.shift();
}
}
average(): NetSample {
if (this.samples.length === 0) {
return { rttMs: 0, lossRate: 0, jitterMs: 0 };
}
let rtt = 0;
let loss = 0;
let jitter = 0;
for (const s of this.samples) {
rtt += s.rttMs;
loss += s.lossRate;
jitter += s.jitterMs;
}
const n = this.samples.length;
return { rttMs: rtt / n, lossRate: loss / n, jitterMs: jitter / n };
}
get size(): number {
return this.samples.length;
}
}
/** 把三个指标折成一个「劣化分」,越大越差 */
export function badness(s: NetSample): number {
return s.rttMs * 0.5 + s.lossRate * 1000 + s.jitterMs * 1.0;
}
/** 滞回决策器:每次只动一档,恶化用 enter 线、恢复用 exit 线 */
export class DegradeDecider {
private readonly config: QualityConfig;
private level: NetQualityLevel = NetQualityLevel.Good;
constructor(config: QualityConfig) {
this.config = config;
}
get current(): NetQualityLevel {
return this.level;
}
update(sample: NetSample): NetQualityLevel {
const score = badness(sample);
if (
this.level < NetQualityLevel.Bad &&
score >= this.config.enter[this.level]
) {
this.level = (this.level + 1) as NetQualityLevel;
} else if (
this.level > NetQualityLevel.Good &&
score < this.config.exit[this.level - 1]
) {
this.level = (this.level - 1) as NetQualityLevel;
}
return this.level;
}
}
两个断言,一个证明「阈值附近不抖」,一个证明「持续恶化逐级降级」:
import { describe, expect, it } from 'vitest';
import {
DEFAULT_QUALITY_CONFIG,
DegradeDecider,
NetQualityLevel,
} from './quality';
describe('DegradeDecider', () => {
it('质量在阈值附近抖动时档位不抖', () => {
const d = new DegradeDecider(DEFAULT_QUALITY_CONFIG);
// badness = rtt×0.5,令 rtt = score×2 使 badness 正好等于 score
const seq = [85, 75, 85, 75, 85, 75, 85, 75];
const levels = seq.map((s) =>
d.update({ rttMs: s * 2, lossRate: 0, jitterMs: 0 }),
);
expect(levels[0]).toBe(NetQualityLevel.Fair);
// 升到 Fair 之后就一直停在 Fair,不再回退
expect(levels.slice(1).every((l) => l === NetQualityLevel.Fair)).toBe(true);
});
it('质量持续恶化时逐级降级', () => {
const d = new DegradeDecider(DEFAULT_QUALITY_CONFIG);
expect(d.current).toBe(NetQualityLevel.Good);
expect(d.update({ rttMs: 200, lossRate: 0, jitterMs: 0 })).toBe(
NetQualityLevel.Fair,
);
expect(d.update({ rttMs: 500, lossRate: 0, jitterMs: 0 })).toBe(
NetQualityLevel.Poor,
);
expect(d.update({ rttMs: 1000, lossRate: 0, jitterMs: 0 })).toBe(
NetQualityLevel.Bad,
);
});
});
跑起来:
cd mini2d && npx vitest run src/net/quality.test.ts
第一个断言是关键:样本在 75 和 85 之间来回,badness 也就在 75 和 85 之间来回。第一次 85 越过进入线 80,档位升到 Fair;之后 75 没跌破退出线 50,所以一直停在 Fair。如果把 exit 去掉(进入线等于退出线),档位就会 Good↔Fair 反复横跳——这正是滞回存在的意义。
5.3.6 降级不能影响公平性
降级有一条不能碰的红线:不能因为一方网络差就给他优势。降级只能改变「表现」与「发送频率」,绝不能改变「谁说了算」。具体来说:
- 可以降:插值缓冲长度、特效开关、远端实体的更新频率、本地预测的激进程度。
- 不能降:命中判定、分数结算、到达终点的先后。这些一律以服务端快照为准,客户端降级只影响自己看到什么,不影响谁赢。
最容易越界的是「本地优先」模式:如果客户端在弱网下自作主张地推进自己的位置,就相当于给自己开了后门。正确做法是——本地优先只作用于「表现层的远端角色」,自己角色的权威位置永远等服务和。
5.3.7 把档位接进表现层
决策器输出的档位,最终要落到节点上——关特效、改角标。下面这个组件把 level 翻译成表现层动作,只依赖 cc 公开 API,能过 tsc --noEmit:
import { _decorator, Component, Label, Node } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('QualityLodController')
export class QualityLodController extends Component {
/** 非关键特效根节点:Fair 起整体停用,Good 时恢复 */
@property(Node)
fxRoot: Node | null = null;
/** 网络状态角标,弱网时提示玩家 */
@property(Label)
badge: Label | null = null;
/** level 取自 L1 决策器:0=Good 1=Fair 2=Poor 3=Bad */
applyLevel(level: number): void {
if (this.fxRoot) {
this.fxRoot.active = level === 0;
}
if (this.badge) {
this.badge.string = level === 0 ? '' : `网络不佳 Lv${level}`;
}
}
}
注意这里关的是 fxRoot 这个整体开关,不是逐个粒子系统去 stop。整体开关一步到位、恢复时也干净;逐节点控制容易漏关漏开,反而制造出「特效开了一半」的怪状态。
5.3.8 客户端该做什么
第 5 章讲完,客户端在 assets/scripts/net/ 下多了三个模块:
| 模块 | 文件 | 职责 |
|---|---|---|
| 会话 | session/session.ts | 连接、断线、重连的状态机 |
| 可靠层 | protocol/reliable.ts | 序号信封、去重表、动态超时、重传 |
| 质量 | sync/quality.ts | 滑动窗口评估、滞回降级决策 |
对应的可配置项也有三组:ReconnectConfig(重连次数与退避)、ReliableConfig(心跳间隔、最大重传、去重窗口)、QualityConfig(窗口大小、进入/退出阈值)。这些都能在编辑器里做成组件的 @property 暴露出来,按机型与地区调参而不用改代码。
第 6 章接手时,这里已经有了一条「连得上、断了能回来、消息不丢不重、弱网能降级」的底座。下一章往上盖:玩家怎么被匹配进一局、房间从创建到销毁客户端要管什么状态——也就是匹配与房间。
阅读导航:上一节:5.2 超时、重传与幂等 · 下一节:6.1 匹配流程与客户端角色 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。