《Cocos 游戏联机实战》7.2 机器人压测

机器人压测不是拿真玩家去撞服务端,而是把客户端逻辑层抽出来,在 Node 里同时驱动成百上千个无渲染的机器人,用它们压自己这套模拟与消息处理。本节给出机器人客户端与脚本化行为的纯 TS 实现、只回最小快照的假服务端测试替身、用 vitest 断言规模下的单帧耗时预算,并说明压测结论该怎么写才可信。

7.2 机器人压测

上一节造出了弱网,这一节造规模。提到「压测」,多数人第一反应是压服务端——站内 game/server/ 里那批文章和书稿《游戏服务端编程实践》讲的就是服务端怎么扛并发。本节要压的却是客户端自己:当同一台机器上同时跑着成百上千个玩家逻辑时,我们这套模拟、消息处理与快照消费还扛不扛得住。

为什么客户端也需要压测?因为联机把「一个玩家」变成了「N 个玩家」,而客户端的逻辑层——确定性模拟、快照应用、预测和解——是要每个玩家各跑一份的。机器人压测就是把这 N 份同时放进一个进程里,看它先在哪里喘不过气。

7.2.1 压的是谁:为什么是「客户端压自己」

先厘清边界。真实压测里,机器人是客户端:它连服务端、收快照、跑逻辑,和真人客户端走同一套代码路径。我们压的不是服务端吞吐(那是服务端的事),而是:

  • 逻辑层的单帧成本:N 个玩家各跑一份模拟,一帧要花多少毫秒;
  • 消息处理的吞吐:N 份快照在客户端解析、应用的开销;
  • 内存与 GC:N 份状态常驻会不会把堆推爆。

这三项都是客户端指标。它们和服务端指标(QPS、连接数、CPU 占用)是两条独立的线,不要把两者混在一张表里。

7.2.2 机器人客户端的构造:复用 L1 逻辑层

机器人之所以能「成百上千个跑在一个 Node 进程里」,靠的是卷一就定下的分层:逻辑层不依赖渲染、不依赖引擎。机器人和真实玩家共用同一份 Bot.step 逻辑,区别只在输入来源——真人来自键盘,机器人来自脚本。

这个「共用同一份」是硬要求。如果机器人另写一套简化逻辑,压出来的数字就没有意义:它测的不是你线上真正会跑的那份代码。

7.2.3 脚本化行为:走、跳、收集

机器人行为要确定且有代表性。「确定」是为了可复现——同配置跑两次结果一致;「有代表性」是为了让负载接近真人——不能所有机器人都在原地不动。下面用 id 与帧号生成相位错开的走跳收集行为:

// net/bot.ts —— 纯 TS,无任何 cc 依赖
export interface BotState {
  x: number;
  y: number;
  vy: number;
  grounded: boolean;
  collected: number;
}

export interface BotInput {
  jump: boolean;
  moveRight: boolean;
  collect: boolean;
}

export interface BotConfig {
  speed: number;
  gravity: number;
  jumpVelocity: number;
}

/** 一个无渲染的机器人客户端:只跑逻辑,不碰节点与引擎 */
export class Bot {
  readonly id: number;
  state: BotState;
  private readonly cfg: BotConfig;
  private tick = 0;

  constructor(id: number, cfg: BotConfig) {
    this.id = id;
    this.cfg = cfg;
    this.state = { x: 0, y: 0, vy: 0, grounded: true, collected: 0 };
  }

  /** 脚本化行为:每帧产出一个输入,由 id 与帧号决定,完全确定 */
  think(): BotInput {
    this.tick += 1;
    const phase = (this.tick + this.id * 7) % 90;
    return {
      jump: this.state.grounded && phase % 30 === 0,
      moveRight: true,
      collect: phase % 15 === 0,
    };
  }

  /** 本地应用输入,与真实客户端共用同一套逻辑 */
  step(input: BotInput, dt: number): void {
    if (input.jump && this.state.grounded) {
      this.state.vy = this.cfg.jumpVelocity;
      this.state.grounded = false;
    }
    this.state.vy += this.cfg.gravity * dt;
    this.state.y += this.state.vy * dt;
    if (this.state.y <= 0) {
      this.state.y = 0;
      this.state.vy = 0;
      this.state.grounded = true;
    }
    if (input.moveRight) this.state.x += this.cfg.speed * dt;
    if (input.collect) this.state.collected += 1;
  }
}

think 用 (tick + id * 7) % 90 给每个机器人一个错开的相位,于是同一帧里有人跳、有人收集,负载分布接近真实。这是「脚本化行为」的最小形态;真实项目里可以把它换成录制的真人输入回放,更贴近线上。

7.2.4 L1:机器人驱动循环

驱动循环负责「一帧里把 N 个机器人各推进一步」,并把这一帧的耗时量出来:

// net/bot-driver.ts
import type { Transport } from './transport';
import type { Bot } from './bot';

export interface BotFrameReport {
  bots: number;
  stepMs: number;            // 本帧驱动全部机器人的耗时
  handledMessages: number;   // 累计处理的消息数
}

export class BotDriver {
  private readonly bots: Bot[];
  private readonly dt: number;
  private readonly clock: () => number;
  private handledMessages = 0;

  constructor(bots: Bot[], transport: Transport, dt: number, clock: () => number) {
    this.bots = bots;
    this.dt = dt;
    this.clock = clock;
    // 消息处理也计入客户端开销,不能只算 step
    transport.onMessage(() => { this.handledMessages += 1; });
  }

  /** 单帧:每个机器人思考一次并前进一次 */
  tick(): BotFrameReport {
    const t0 = this.clock();
    for (const bot of this.bots) {
      bot.step(bot.think(), this.dt);
    }
    return { bots: this.bots.length, stepMs: this.clock() - t0, handledMessages: this.handledMessages };
  }
}

clock 同样注入。测试里传 performance.now() 量真实耗时,也可以传假时钟让循环在零成本下跑完逻辑——两种用法共用同一个驱动类。

7.2.5 假服务端:只回最小快照

机器人需要一个「对面」才像联机。这里给出的是一个测试替身——它不做任何服务端逻辑,只在收到消息时回一个最小快照,用来触发客户端侧的消息处理路径。它不是服务端实现;真正的服务端裁决见站内服务端专题。

// net/fake-server.ts —— 测试替身,只回最小快照,不是服务端实现
import type { Transport } from './transport';

export class FakeSnapshotServer implements Transport {
  private listener: ((p: Uint8Array) => void) | null = null;
  private snapshots = 0;

  send(payload: Uint8Array): void {
    const ack = new Uint8Array(12);   // 1 字节类型 + 4 字节回显 + 1 字节序号 + 保留
    ack[0] = 0x01;                    // 0x01 = 快照
    ack.set(payload.subarray(0, 4), 4);
    ack[8] = this.snapshots++ & 0xff;
    this.listener?.(ack);
  }

  onMessage(cb: (p: Uint8Array) => void): void { this.listener = cb; }
  close(): void { this.listener = null; }
  get snapshotCount(): number { return this.snapshots; }
}

它实现的是同一个 Transport 接口,所以能直接替换真实连接塞进 BotDriver。这就是第 1 章把传输层抽象成接口的回报——压测不需要真服务端,一个 12 字节的假快照就够触发整条客户端处理链。

7.2.6 vitest:单帧耗时预算

下面把「N 个机器人下单帧耗时在预算内」写成断言。60 Hz 的单帧预算是 16.6 ms,逻辑层应该远远低于它——这里取 8 ms 作为门槛:

import { describe, expect, it } from 'vitest';
import { Bot } from './bot';
import { BotDriver } from './bot-driver';
import { FakeSnapshotServer } from './fake-server';

const cfg = { speed: 120, gravity: -900, jumpVelocity: 320 };

function makeBots(n: number): Bot[] {
  return Array.from({ length: n }, (_, i) => new Bot(i, cfg));
}

describe('BotDriver', () => {
  it('1000 个机器人单帧逻辑耗时远低于 60Hz 预算', () => {
    const driver = new BotDriver(makeBots(1000), new FakeSnapshotServer(), 1 / 60, () => performance.now());
    for (let i = 0; i < 20; i++) driver.tick();   // 预热,排除首次 JIT 抖动
    let worst = 0;
    for (let i = 0; i < 300; i++) worst = Math.max(worst, driver.tick().stepMs);
    expect(worst).toBeLessThan(8);
  });

  it('相同配置的机器人轨迹可复现', () => {
    const run = () => makeBots(50).map((b) => {
      for (let i = 0; i < 300; i++) b.step(b.think(), 1 / 60);
      return b.state;
    });
    expect(run()).toEqual(run());
  });
});

两段断言分工明确:第一段量规模下的耗时,第二段保确定性——只有行为确定,第一次跑出的耗时数字才有可比性。performance.now() 在 Node 里是全局可用的,不需要额外引入。

注意这段断言依赖机器性能,属于「本机基线」而非「通用阈值」。 换机器、换 Node 版本,8 ms 可能要调。它的价值不在于那个具体数字,而在于给出一个可重复的测量口径:同样的机器、同样的规模,改动前后各跑一次,差值就是改动带来的真实开销。

7.2.7 客户端要观测哪些指标

压测不能只看「跑没跑完」,要盯下面这几项客户端指标。注意它们全是客户端的:

指标采集方式恶化意味着
单帧逻辑耗时tick().stepMs逻辑层太重,玩家数一多就掉帧
消息处理耗时解析 + 应用快照的计时编解码或 diff 应用是瓶颈
堆内存process.memoryUsage().heapUsed每份状态太大或泄漏
GC 次数--expose-gc 或 perf_hooks分配速率过高,会有 GC 尖峰
峰值 RTT假传输层统计消息积压,处理跟不上发送

前四项是「客户端算得动吗」,最后一项是「客户端跟得上吗」。两项都要看:算得动但跟不上,同样会表现为卡。

7.2.8 结论怎么写才可信

压测最容易翻车的地方不是测,而是写结论。三条纪律:

  1. 写清机器配置与规模:CPU 型号、内存、Node 版本、机器人数量、单帧步长。没有这些,「1000 机器人 3ms」是无意义数字。
  2. 不编造精确数字:本节的 8 ms 是本机门槛,不是普适结论。你的机器上可能是 2 ms,也可能是 20 ms——必须以自己实测为准。
  3. 给复现命令:结论后面附上跑测的命令与种子,别人能一条命令复现,结论才成立。

反面教材是「实测 1000 机器人仅耗时 1.7ms」——精确到小数点却不说机器,等于没测。

7.2.9 接进 Cocos:压测开关

机器人压测会吃满 CPU,绝不能带进玩家包。用 sys.platform 把它锁在桌面浏览器与编辑器里:

import { _decorator, Component, sys } from 'cc';
const { ccclass } = _decorator;

@ccclass('BotLoadTestGate')
export class BotLoadTestGate extends Component {
  onLoad(): void {
    const desktop =
      sys.platform === sys.Platform.DESKTOP_BROWSER ||
      sys.platform === sys.Platform.EDITOR_PAGE;
    if (!desktop) {
      console.log('[bot] skip load test on', sys.platform);
      this.enabled = false;
      return;
    }
    console.log('[bot] load test enabled on', sys.platform);
  }
}

sys.platform 的取值是 sys.Platform 里的字符串枚举成员(DESKTOP_BROWSER / EDITOR_PAGE / WECHAT_GAME 等),所以判断用 === 比对成员,而不是猜数字。真机与小游戏上直接 this.enabled = false,把整个组件关掉。

下一节收尾:压测能在本地把负载压出来,可线上那些「偶发卡一下」还是得靠日志抓。我们会讲清从症状到原因的排查表、客户端该落哪些日志、以及怎么把线上抓到的网络序列喂回本节与上节的模拟器里复现。

阅读导航:上一节:7.1 弱网模拟与网络诊断 · 下一节:7.3 线上问题定位 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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