《Cocos 游戏开发入门》15.3 开发者控制台与作弊命令

调手感时最烦的就是「改个数 → 保存 → 刷新 → 重玩到那一关」,一晚上全耗在等待上。本节给 mini2d 做一套游戏内开发者控制台:用命令注册表加参数解析,实现改数值、跳关、无敌、加速时间,并把执行结果实时回显在屏幕上,且只在开发模式启用。

15.3 开发者控制台与作弊命令

15.1 让我们「看见」,15.2 让我们「测准」,这一节要解决的是**「改得快」**。

调游戏手感时有一个极其消耗时间的循环:想试试把重力从 980 调到 1200,于是改代码、保存、等热更新、刷新、再从第一关玩到想验证的那一关——一次试错三分钟,一晚上调不出几个值。更糟的是,很多问题只在特定关卡、特定数值下才复现,靠手动复现成本极高。

**游戏内开发者控制台(dev console)**把这一整套变成敲一行命令:set gravity 1200、goto 12、god on、timescale 2。本节把它做出来,并解决三个工程问题:命令怎么注册、参数怎么解析、结果怎么回显。

与站内 game/ 顶层「引擎概念综述」的分工:那批文章讲概念,本套书把控制台从命令表到 UI 亲手写出来。Cocos Creator 3.8 的 EditBox / Label 对照放在本节末。

15.3.1 为什么不用浏览器自带 console

浏览器 DevTools 的 console 能执行任意 JS,理论上也能 game.gravity = 1200。但它有几个硬伤:

  • 要切窗口、要记字段名:全屏游戏或微信小游戏根本没有 DevTools,切出去游戏还会暂停;而 game.gravity 这种内部结构,改一次还得翻代码。
  • 没有回显与校验、不能给测试同学用:敲错了没提示,敲 game.gravity = 'abc' 直接污染状态;QA 也不会用 DevTools,但会敲 goto 12。

游戏内控制台的价值就在于:它是一个受控的、有校验、有回显、能打包给测试的入口。它暴露的不是任意 JS,而是一张精心设计过的命令表。

15.3.2 命令注册表:一张受控的命令表

控制台的核心是一个命令注册表(command registry):命令名 → 帮助文本 → 执行函数。用 Map 存,注册即用:

// mini2d/console/registry.ts
export type CommandHandler = (args: string[]) => string;

export interface Command {
  name: string;
  help: string;
  run: CommandHandler;
}

export class CommandRegistry {
  private commands = new Map<string, Command>();

  register(name: string, help: string, run: CommandHandler): void {
    this.commands.set(name, { name, help, run });
  }

  list(): Command[] {
    return [...this.commands.values()];
  }

  help(): string[] {
    return this.list().map((c) => `  ${c.help}`);
  }
}

每个命令的 run 只返回一个字符串,而不是自己 console.log。这是刻意的设计:执行与显示解耦,命令逻辑就能在 vitest 里直接断言返回值,不依赖 DOM。

15.3.3 参数解析:把一行字符串拆成命令与参数

用户敲的是 set gravity 1200 这样一整行。先要分词(tokenize),还要支持带引号的参数(比如关卡名里有空格):

export function tokenize(input: string): string[] {
  const out: string[] = [];
  let cur = '';
  let quote = '';
  for (const ch of input.trim()) {
    if (quote) {
      if (ch === quote) quote = '';
      else cur += ch;
    } else if (ch === '"' || ch === "'") {
      quote = ch;
    } else if (ch === ' ' || ch === '\t') {
      if (cur) { out.push(cur); cur = ''; }
    } else {
      cur += ch;
    }
  }
  if (cur) out.push(cur);
  return out;
}

execute 负责「取命令名 → 查表 → 分发参数 → 兜底错误」:

  execute(input: string): string {
    const tokens = tokenize(input);
    if (tokens.length === 0) return '';
    const name = tokens[0];
    const args = tokens.slice(1);
    const cmd = this.commands.get(name);
    if (!cmd) return `未知命令: ${name}(输入 help 查看可用命令)`;
    try {
      return cmd.run(args);
    } catch (err) {
      return `命令出错: ${(err as Error).message}`;
    }
  }

try/catch 包住 run 是必须的:一个写错的作弊命令不该让整个游戏崩掉。控制台是调试工具,它自己必须先足够健壮。

15.3.4 内置命令:改数值、跳关、无敌、加速

把命令接到游戏状态上。先做一个「按路径改数值」的小工具,让 set physics.gravity 1200 这种点号路径能生效:

export function setByPath(root: Record<string, unknown>, path: string, value: number): boolean {
  const keys = path.split('.');
  let obj: Record<string, unknown> = root;
  for (let i = 0; i < keys.length - 1; i++) {
    const next = obj[keys[i]];
    if (typeof next !== 'object' || next === null) return false;
    obj = next as Record<string, unknown>;
  }
  const last = keys[keys.length - 1];
  if (typeof obj[last] !== 'number') return false;
  obj[last] = value;
  return true;
}

再逐个注册《方块冲刺》的调试命令:

// mini2d/console/commands.ts
import { type CommandRegistry, setByPath } from './registry';

export interface DevTarget {
  config: Record<string, unknown>;
  loadLevel(n: number): void;
  invincible: boolean;
  timeScale: number;
}

export function installCommands(reg: CommandRegistry, target: DevTarget): void {
  reg.register('set', 'set <路径> <数值>   修改配置,如 set physics.gravity 1200', (args) => {
    const [path, raw] = args;
    const value = Number(raw);
    if (!path || Number.isNaN(value)) return '用法: set <路径> <数值>';
    return setByPath(target.config, path, value) ? `${path} = ${value}` : `未知字段: ${path}`;
  });

  reg.register('goto', 'goto <关卡>         跳到指定关卡', (args) => {
    const n = Number(args[0]);
    if (!Number.isInteger(n) || n <= 0) return '用法: goto <关卡号>';
    target.loadLevel(n);
    return `已跳到第 ${n} 关`;
  });

  reg.register('god', 'god [on|off]        无敌模式', (args) => {
    target.invincible = args[0] !== 'off';
    return `无敌: ${target.invincible ? '开' : '关'}`;
  });

  reg.register('timescale', 'timescale <倍率>    时间缩放,如 timescale 2', (args) => {
    const n = Number(args[0]);
    if (!Number.isFinite(n) || n <= 0) return '用法: timescale <正数>';
    target.timeScale = n;
    return `时间缩放 = ${n}`;
  });

  reg.register('help', 'help                列出所有命令', () => reg.help().join('\n'));
}

四个命令覆盖了调试最高频的四类需求:

命令用途典型场景
set改任意数值配置调重力、跳跃初速、敌人速度
goto跳关复现只在第 12 关出现的问题
god无敌测试关卡后半段,不用反复闪避
timescale时间缩放慢动作观察碰撞,或加速跑流程

15.3.5 控制台 UI:输入行 + 回显区

命令逻辑与显示解耦之后,UI 层只需要维护一个输入行和一个回显区:

// mini2d/console/dev-console.ts
import type { CommandRegistry } from './registry';

export class DevConsole {
  private reg: CommandRegistry;
  private input = '';
  private lines: string[] = [];
  private open = false;

  constructor(reg: CommandRegistry) {
    this.reg = reg;
  }

  get isOpen(): boolean {
    return this.open;
  }

  toggle(): void {
    this.open = !this.open;
  }

  type(ch: string): void {
    this.input += ch;
  }

  submit(): void {
    const text = this.input.trim();
    this.input = '';
    if (!text) return;
    this.lines.push(`> ${text}`);
    const out = this.reg.execute(text);
    if (out) this.lines.push(out);
    if (this.lines.length > 50) this.lines.splice(0, this.lines.length - 50);
  }

  get view(): string[] {
    return this.lines;
  }
}

回显区只保留最近 50 行,超出就从头裁掉——这是防止长时间调试把内存撑爆的简单纪律。用反引号 ` 键开关控制台,把按键接到 DevConsole:

export function installConsoleKeys(con: DevConsole): void {
  window.addEventListener('keydown', (e) => {
    if (e.key === '`') {
      con.toggle();
      e.preventDefault();
      return;
    }
    if (!con.isOpen) return;
    if (e.key === 'Enter') con.submit();
    else if (e.key.length === 1) con.type(e.key);
  });
}

控制台打开时要拦截其它按键输入,否则你敲的 goto 会被游戏当成移动键。实践中给控制台一个「独占输入」标志,游戏输入层读到该标志时直接跳过本帧输入即可。

15.3.6 安全与发布:控制台不能进正式包

作弊命令是把双刃剑:测试同学靠它提效,玩家用它就能毁掉游戏体验。所以控制台必须和 15.1 的调试层一样,只在开发模式存在:

// mini2d/console/index.ts
import { CommandRegistry } from './registry';
import { installCommands } from './commands';
import { DevConsole, installConsoleKeys } from './dev-console';

export function setupDevConsole(target: DevTarget): DevConsole | null {
  if (!(globalThis as { __DEV__?: boolean }).__DEV__) return null;
  const reg = new CommandRegistry();
  installCommands(reg, target);
  const con = new DevConsole(reg);
  installConsoleKeys(con);
  return con;
}

三条发布纪律:

  • 编译期消除:整段用 __DEV__ 包住,生产构建里摇树掉,连命令表都不进包。
  • 不做联网校验、发布前实测:别指望「运行时判断是不是官方账号」——作弊入口一旦进包,逆向只是时间问题;所以构建生产包后,在控制台敲反引号必须什么都打不开才算过关。

15.3.7 Cocos 对照:EditBox、Label 与 input

Cocos Creator 3.8 没有内置开发者控制台,但用 EditBox(输入框)+ Label(回显)+ input(按键)就能搭出同样的东西。以下为 L2 片段(import { ... } from 'cc'):

import { _decorator, Component, Node, Label, EditBox, input, Input, KeyCode, log } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('DevConsoleUI')
export class DevConsoleUI extends Component {
  @property(Node)
  panel: Node | null = null;

  @property(Label)
  output: Label | null = null;

  @property(EditBox)
  inputBox: EditBox | null = null;

  onLoad(): void {
    input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this);
    this.inputBox?.node.on(EditBox.EventType.EDITING_RETURN, this.onSubmit, this);
  }

  onDestroy(): void {
    input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this);
  }

  private onKeyDown(e: { keyCode: number }): void {
    if (e.keyCode === KeyCode.ESCAPE && this.panel) {
      this.panel.active = !this.panel.active;
    }
  }

  private onSubmit(box: EditBox): void {
    const text = box.string.trim();
    box.string = '';
    if (!text) return;
    if (this.output) this.output.string = '> ' + text + '\n' + '(执行命令并回显结果)';
    log('console', text);
  }
}

对照要点:

  • EditBox 的文本用 string 属性读写(get string() / set string(value)),清空输入就是 box.string = ''。占位提示用 placeholder,最大长度用 maxLength。
  • 事件挂在节点上:editBox.node.on(EditBox.EventType.EDITING_RETURN, cb, this)。可用的类型有 EDITING_DID_BEGAN、EDITING_DID_ENDED、TEXT_CHANGED、EDITING_RETURN——回车提交用 EDITING_RETURN,这正是本节控制台的「submit」。别忘了在 onDestroy 里 off。
  • 回显用 Label,改内容只需 label.string = ...;配合 fontSize / lineHeight 控制排版。多行文本用 \n 拼接即可。
  • 全局按键监听用 input.on(Input.EventType.KEY_DOWN, cb, this)(input 是引擎导出的单例 export const input: Input),按键值走 KeyCode 枚举(如 KeyCode.ESCAPE)。这与 window.addEventListener('keydown') 等价,但会被引擎统一转发,原生端也能用。
  • 日志统一用引擎的 log / debug(export function log(...data: unknown[]): void),比 console.log 多了平台适配。

至此,第 15 章的调试与工具就完整了:15.1 看见逻辑,15.2 看见时间,15.3 改得快。三件工具合起来,能把「猜测式调试」变成「数据驱动调试」。下一章转向测试与质量——当游戏能跑、能看、能改之后,如何用 vitest 的假时钟做纯逻辑单测、用 Playwright 截图做渲染端到端测试,并让整套流程可回归。

阅读导航:上一节:15.2 帧率、内存与性能剖析 · 下一节:16.1 纯逻辑单测:vitest 与假时钟 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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