《Cocos 游戏联机实战》9.2 网络状态 UI 与提示

延迟、掉线、重连该怎么告诉玩家,以及哪些网络数据不该给玩家看。本节给出网络质量档位到图标与文案的映射表、提示的时机与频率控制、无障碍与本地化考虑,并落到一个订阅会话状态与诊断数据的 L2 UI 组件,以及一个可单测、覆盖多档位边界的 L1 档位映射函数。

9.2 网络状态 UI 与提示

9.1 让远端玩家动起来了,可玩家仍然不知道「为什么我的角色慢半拍」。网络问题不告诉玩家,玩家只会觉得「这游戏手感差」;但什么都告诉玩家,又变成仪表盘。本节要画的是一条线:哪些网络信息值得出现在屏幕上,怎么把它们映射成图标与文案,什么时候该弹提示、什么时候该闭嘴。

与站内 game/server/ 诊断文的分工:那边面向运维与开发者,讲怎么采集与排查;这里面向玩家,讲怎么把已经采集到的状态翻译成不吓人的提示。

9.2.1 该显示什么,不该显示什么

先立规矩。玩家需要的是**「我该不该继续」的决策信息**,不是**「到底哪一环出了问题」的诊断信息**。

显示理由不显示理由
信号格 / 图标一眼看懂,跨语言RTT 抖动曲线那是给开发者的,玩家看不懂也帮不上忙
延迟档位(好 / 一般 / 差)决策够用精确到毫秒的 RTT数字只会放大焦虑,且测量本身有噪声
重连倒计时告诉玩家「在恢复、别退出」逐包丢包率玩家无从应对
对手掉线提示解释「为什么对面不动了」逻辑帧号、回滚次数纯诊断数据,属于 7.1 的诊断面板

一句话:屏幕上的每一个网络元素,都要能回答「玩家看到它之后能做什么」。 答不出来的,就是开发者数据,放进 7.1 那个诊断浮层(默认关闭),不要放在主界面。

9.2.2 档位从哪来:先平滑,再分档

原始的 RTT 抖得很厉害——一个包晚到 200 ms,下一包又正常,直接拿瞬时值分档会让图标每秒变色十几次。所以链路是先平滑,再分档:

  1. 会话层每秒收到若干次 RTT 采样,用指数移动平均(EMA)压掉抖动,得到 rttMs。
  2. 丢包率同理,用滑动窗口统计近期丢包,得到 lossPct。
  3. 平滑后的两个标量 + 连接标志,喂给分档函数,得到四档之一。

这个「四档」刻意呼应 5.3 的弱网降级档位——同一套档位既驱动 UI,也驱动插值延迟、同步频率这些行为,避免出现「图标显示网络良好,但游戏已经在降级」的自相矛盾。

9.2.3 档位到 UI 的映射表

档位信号格颜色文案附带行为
good3绿网络良好不显示文案,只留图标(或整体隐藏)
fair2黄网络一般图标常驻,不弹提示
poor1橙网络较差图标常驻 + 首次进入弹一次提示
offline0灰连接已断开图标 + 重连倒计时进度条

注意 good 那一行:网络好的时候,最好的 UI 是不存在的 UI。竞速过程中玩家最不需要的就是一个「网络良好」的绿点占着屏幕。常见做法是 good 档把整个组件 opacity 压到 0,只在档位下降时才淡入。

9.2.4 L1:档位与文案映射函数

分档与映射都是纯逻辑,抽出来就能单测。下面是 assets/scripts/net/sync/net-quality.ts(L1,无 cc 依赖):

// sync/net-quality.ts —— 纯 TS,网络质量档位与展示映射
export type NetTier = 'good' | 'fair' | 'poor' | 'offline';

export interface NetMetrics {
  rttMs: number; // 已平滑的往返延迟
  lossPct: number; // 已平滑的丢包率,0~100
  connected: boolean;
}

export interface TierDisplay {
  bars: number; // 信号格 0~3
  label: string; // zh-CN 兜底文案,生产环境替换为 i18n key
}

/** 把平滑后的指标映射到四档。边界取 >=,档位越低越差。 */
export function classify(m: NetMetrics): NetTier {
  if (!m.connected) return 'offline';
  if (m.rttMs >= 300 || m.lossPct >= 5) return 'poor';
  if (m.rttMs >= 150 || m.lossPct >= 1) return 'fair';
  return 'good';
}

const DISPLAY: Record<NetTier, TierDisplay> = {
  good: { bars: 3, label: '网络良好' },
  fair: { bars: 2, label: '网络一般' },
  poor: { bars: 1, label: '网络较差' },
  offline: { bars: 0, label: '连接已断开' },
};

export function describe(tier: NetTier): TierDisplay {
  return DISPLAY[tier];
}

/** 是否值得给玩家弹一次提示:抑制抖动,只在有意义的变化上打扰。 */
export function shouldNotify(prev: NetTier, next: NetTier): boolean {
  if (prev === next) return false;
  if (next === 'poor' || next === 'offline') return true; // 恶化,立刻提示
  if (prev === 'poor' || prev === 'offline') return next === 'good'; // 恢复,只提示彻底好转
  return false; // good 与 fair 之间不打扰
}

三个函数各管一件事:classify 只管指标→档位,describe 只管档位→展示,shouldNotify 只管「要不要弹提示」。把它们拆开,是为了让「分档阈值」和「提示策略」能各自独立调整——改阈值不该动到提示逻辑。

9.2.5 L1:单测(含多档位边界)

阈值函数最怕的是边界含糊:> 还是 >=、两个条件谁先短路,都会让「恰好卡在 150 ms」的玩家被分到不同档。用测试把边界钉死:

import { describe, expect, it } from 'vitest';
import { classify, describe, shouldNotify, type NetMetrics } from './net-quality';

const m = (rttMs: number, lossPct: number, connected = true): NetMetrics =>
  ({ rttMs, lossPct, connected });

describe('classify 档位边界', () => {
  it('rtt 恰好 150 进 fair,149 仍是 good', () => {
    expect(classify(m(149, 0))).toBe('good');
    expect(classify(m(150, 0))).toBe('fair');
  });

  it('rtt 恰好 300 进 poor', () => {
    expect(classify(m(299, 0))).toBe('fair');
    expect(classify(m(300, 0))).toBe('poor');
  });

  it('丢包边界 1% 与 5%', () => {
    expect(classify(m(0, 0.9))).toBe('good');
    expect(classify(m(0, 1))).toBe('fair');
    expect(classify(m(0, 5))).toBe('poor');
  });

  it('断连优先于一切指标', () => {
    expect(classify(m(0, 0, false))).toBe('offline');
  });
});

describe('describe 映射', () => {
  it('档位越差格数越少', () => {
    expect(describe('good').bars).toBe(3);
    expect(describe('fair').bars).toBe(2);
    expect(describe('poor').bars).toBe(1);
    expect(describe('offline').bars).toBe(0);
  });
});

describe('shouldNotify 抑制抖动', () => {
  it('good 与 fair 之间不打扰', () => {
    expect(shouldNotify('good', 'fair')).toBe(false);
    expect(shouldNotify('fair', 'good')).toBe(false);
  });

  it('恶化到 poor / offline 立即提示', () => {
    expect(shouldNotify('good', 'poor')).toBe(true);
    expect(shouldNotify('fair', 'offline')).toBe(true);
  });

  it('恢复只在彻底好转时提示', () => {
    expect(shouldNotify('poor', 'good')).toBe(true);
    expect(shouldNotify('poor', 'fair')).toBe(false);
  });
});

最后一条测试是这套 UI 的性格:从「较差」恢复到「一般」,不弹提示。因为一次弹窗本身就是打扰,而 fair 对玩家意味着「还能玩」——真正值得说一句的是「彻底恢复正常了」。

9.2.6 提示的时机与频率

光有 shouldNotify 还不够,组件层还要一层冷却,防止玩家在 good↔poor 之间来回抖时被反复弹窗:

规则取值(建议)作用
恶化提示冷却3 秒同一恶化在 3 秒内只弹一次
恢复提示冷却5 秒恢复比恶化更不值得频繁提示
提示最短展示1.5 秒太短的 toast 玩家看不到
常驻图标淡入0.2 秒图标变化用淡入,别硬切

冷却只作用于「弹窗/toast」,不作用于常驻图标。常驻图标该随时反映当前档位——它是状态,不是事件。事件才需要节流,状态不需要。

9.2.7 L2:网络状态 UI 组件

组件订阅两处数据:第 5 章的会话状态(连没连上、在不在重连)与 7.1 的诊断数据(平滑后的 RTT 与丢包)。它自己不做平滑,只做映射与展示。以下是 assets/scripts/net/ui/NetStatusView.ts 的骨架(L2):

// assets/scripts/net/ui/NetStatusView.ts
import { _decorator, Color, Component, Label, ProgressBar, Sprite, UIOpacity } from 'cc';
import { classify, describe, shouldNotify, type NetMetrics, type NetTier } from '../sync/net-quality';

const { ccclass, property } = _decorator;

@ccclass('NetStatusView')
export class NetStatusView extends Component {
  @property(Label)
  text: Label | null = null;

  @property(Sprite)
  signal: Sprite | null = null;

  @property(ProgressBar)
  reconnectBar: ProgressBar | null = null;

  @property(UIOpacity)
  reconnectGroup: UIOpacity | null = null;

  @property
  reconnectSeconds = 8;

  private tier: NetTier = 'good';
  private metrics: NetMetrics = { rttMs: 0, lossPct: 0, connected: true };
  private reconnectLeft = 0;

  /** 会话层 / 诊断层推送最新指标。 */
  onMetrics(m: NetMetrics): void {
    this.metrics = m;
  }

  /** 重连开始或结束时调用(第 5.1 节的重连状态机回调)。 */
  onReconnect(active: boolean): void {
    this.reconnectLeft = active ? this.reconnectSeconds : 0;
  }

  update(dt: number): void {
    if (this.reconnectLeft > 0) this.reconnectLeft = Math.max(0, this.reconnectLeft - dt);
    const next = classify(this.metrics);
    if (next !== this.tier) {
      if (shouldNotify(this.tier, next)) this.notify(next);
      this.tier = next;
    }
    this.refresh(next);
  }

  private notify(tier: NetTier): void {
    console.log('网络提示:', describe(tier).label);
  }

  private refresh(tier: NetTier): void {
    const d = describe(tier);
    if (this.text) this.text.string = d.label;
    if (this.signal) this.signal.color = this.tierColor(tier);
    if (this.reconnectGroup) this.reconnectGroup.opacity = this.reconnectLeft > 0 ? 255 : 0;
    if (this.reconnectBar) this.reconnectBar.progress = this.reconnectSeconds > 0 ? this.reconnectLeft / this.reconnectSeconds : 0;
  }

  private tierColor(tier: NetTier): Color {
    if (tier === 'good') return new Color(120, 200, 120, 255);
    if (tier === 'fair') return new Color(230, 200, 90, 255);
    if (tier === 'poor') return new Color(220, 120, 80, 255);
    return new Color(160, 160, 160, 255);
  }
}

describe 返回的 bars 用来驱动信号格数(三个小 Sprite 按 bars 点亮),label 直接进 Label.string。注意 refresh 里 this.reconnectGroup.opacity 用的是 UIOpacity 组件的 opacity,不是 Node 的——Node 上并没有 opacity 属性,淡入淡出必须挂 UIOpacity。

9.2.8 跨端、无障碍与本地化

跨端:微信小游戏切后台会被系统断连,重连提示要比 H5 更醒目(H5 走 visibilitychange,切回来通常还连着)。判平台用 sys.platform:

import { sys } from 'cc';

// 微信小游戏切后台会被系统断连,重连提示要更醒目;H5 走 visibilitychange。
export function reconnectHintLevel(): 'loud' | 'normal' {
  return sys.platform === sys.Platform.WECHAT_GAME ? 'loud' : 'normal';
}

无障碍:绝不能只靠颜色区分档位——红绿色盲玩家分不清绿点和红点。信号格数(0~3)+ 文案是两条冗余通道,颜色只是第三条。图标形状也应随档位变化(满格 / 半格 / 空格 / 断线符号),而不只是换颜色。

本地化:describe 返回的 label 是 zh-CN 兜底,生产环境应把它换成 i18n key(如 net.tier.poor),由本地化表查文案。文案里不要拼接数字(「延迟 173 毫秒」这种),一来数字会频繁变化导致文本刷新,二来不同语言的语序不同,拼接会翻车。要展示数字时用带占位符的模板,而不是字符串相加。

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

  • 新增 assets/scripts/net/sync/net-quality.ts(L1 纯函数)与 ui/NetStatusView.ts(L2 组件)。
  • 可配置项:分档阈值(150 / 300 ms,1 / 5% 丢包)、各档冷却时间、重连倒计时总时长(默认 8 秒,与 5.1 的重连超时对齐)。
  • 一条硬规则:诊断数据(抖动曲线、逐包统计、回滚次数)只进 7.1 的诊断浮层,绝不进主界面。
  • 一条待办:把分档阈值与 5.3 的降级档位表合并成单一配置源,避免两处各写一份、改一处漏一处。

下一节处理最棘手的问题:当两个玩家延迟不同时,要不要补偿、补偿到什么程度——以及为什么在双人竞速里,这个问题的答案比在射击游戏里简单得多。

阅读导航:上一节:9.1 同步表现与插值动画 · 下一节:9.3 延迟补偿与体验权衡 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.1 同步表现与插值动画
  3. 《Cocos 游戏联机实战》8.3 通信加密与防篡改