《Cocos 游戏联机实战》5.3 弱网降级策略

网络差的时候怎么还能玩?本节给出延迟、丢包、抖动三档分别触发什么的分级降级表,用滞回避免档位在阈值附近反复横跳,把阈值做成 const 配置对象便于按机型与地区调,并写一个可跑的网络质量评估器与降级决策器,配 vitest 断言「阈值附近不抖」「持续恶化逐级降级」。

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 匹配流程与客户端角色 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画