《游戏引擎实现原理》6.2 控制台与作弊命令

上一节的调试可视化需要有人去开关它。本节做开发者控制台:命令注册表、一行文本的解析、Tab 补全,以及与 CVar 系统打通的内置 set/get。再给作弊命令加一道开关,让同一份命令表在开发档可执行、在正式档被挡住。全部 L1 真跑并附单测。

6.2 控制台与作弊命令

上一节让引擎能把内部状态画出来,但「画什么、什么时候画」还得有人按开关。开发期最快的开关不是 UI 面板,而是一个能敲命令的控制台——god、spawn rock、set r.shadowQuality 3。这一节把控制台做成引擎内核里一个纯逻辑模块,它不认识 DOM、不认识渲染,只负责「一行文本 → 一次动作」。

边界先划清:控制台是开发工具,不是游戏玩法的一部分。它进开发档,被裁掉时不影响任何玩法逻辑——这也让它天然适合用 helio-eng-05-2 特性开关与死代码消除 里那套机制整块摇掉。

一行文本要经过哪几道关

用户敲的是一串字符,引擎要的是一次有类型保证的动作。中间隔着一串必须逐个处理的关卡,任何一关偷懒,都会变成运行期的静默错误:

  • 切词。set game.name "微光岛 主岛" 里的引号必须把空格包住,否则 game.name 会被切成三段。切词器要能区分「引号内的空格」和「分隔用的空格」。
  • 分发。第一个 token 是命令名,剩下的是参数。名字不认识就直接回一句提示,绝不抛异常——一次手误不该让游戏崩在控制台上。
  • 执行。命令处理器可能因为参数个数不对而抛错。这一层必须兜住异常,把它转成一行可读的回显。
  • 权限。god、noclip 这类作弊命令要受一个总开关约束:正式档里它们是死的,开发档里才活。
  • 补全。输入 s 按 Tab,应该列出 set 与 spawn。补全只是前缀匹配,但顺序要稳定(字典序),否则每次补全结果都在跳。

这五道关里,前三道是「无状态的纯函数」,后两道是「带状态的策略」。把它们放进同一个 Console 类,是因为它们共享同一张命令表;但每个关卡的逻辑都短到可以单测。

可运行产物:命令注册表 + 补全 + 作弊开关

Console 只存命令表和一个布尔开关。命令的处理器签名统一成 (args) => string——返回的就是要回显的那行文本,抛错则由 execute 兜住。内置的 set / get 直接接到 helio-kernel-03-3 的 CVar 系统 上,于是「控制台改配置」和「命令行改配置」共用同一套强类型校验。

// file: src/core/console.ts
// tier: L1
// verify: npx vitest run src/core/console.test.ts
//
// helio 内核:开发者控制台。三件事:命令注册表、一行文本的解析、作弊码开关。
// 与 cvar.ts 配合——内置的 set / get 命令直接读写 CVar 系统,于是「控制台」
// 和「命令行开关」共用同一套强类型配置。
//
// 关键设计:命令处理器抛出的异常由 execute 统一兜住,转成 ok=false 的文本,
// 这样一次手误不会让游戏崩在开发控制台上。

import { CVarSystem } from './cvar.ts';

export interface CommandSpec {
  readonly name: string;
  readonly description: string;
  /** 参数提示,如 `<cvar> <value>`。 */
  readonly usage?: string;
  /** 标记为作弊:只有 cheatsEnabled 时才可执行。 */
  readonly cheat?: boolean;
  readonly run: (args: readonly string[]) => string;
}

export interface CommandResult {
  readonly ok: boolean;
  readonly output: string;
}

/**
 * 把一行文本切成 token:空白分隔,双引号内的空白保留。
 * `set game.name "微光岛"` -> ['set', 'game.name', '微光岛']。
 */
export function tokenize(line: string): string[] {
  const out: string[] = [];
  let cur = '';
  let inQuote = false;
  let hasChar = false;
  for (const ch of line) {
    if (ch === '"') {
      inQuote = !inQuote;
      hasChar = true;
      continue;
    }
    if (!inQuote && (ch === ' ' || ch === '\t')) {
      if (hasChar) {
        out.push(cur);
        cur = '';
        hasChar = false;
      }
      continue;
    }
    cur += ch;
    hasChar = true;
  }
  if (hasChar) out.push(cur);
  return out;
}

export class Console {
  readonly #commands = new Map<string, CommandSpec>();
  #cheats = false;

  register(spec: CommandSpec): void {
    if (this.#commands.has(spec.name)) throw new Error(`command already registered: ${spec.name}`);
    this.#commands.set(spec.name, spec);
  }

  setCheatsEnabled(on: boolean): void {
    this.#cheats = on;
  }
  get cheatsEnabled(): boolean {
    return this.#cheats;
  }

  /** 解析并执行一行;处理器抛错被转成 ok=false。 */
  execute(line: string): CommandResult {
    const tokens = tokenize(line);
    if (tokens.length === 0) return { ok: false, output: 'empty command' };
    const name = tokens[0]!;
    const args = tokens.slice(1);
    const spec = this.#commands.get(name);
    if (spec === undefined) return { ok: false, output: `unknown command: ${name} (try 'help')` };
    if (spec.cheat === true && !this.#cheats) {
      return { ok: false, output: `command '${name}' is a cheat, and cheats are disabled` };
    }
    try {
      return { ok: true, output: spec.run(args) };
    } catch (e) {
      return { ok: false, output: e instanceof Error ? e.message : String(e) };
    }
  }

  /** Tab 补全:返回所有以 prefix 开头的命令名(字典序)。 */
  complete(prefix: string): string[] {
    return [...this.#commands.keys()].filter((n) => n.startsWith(prefix)).sort();
  }

  names(): string[] {
    return [...this.#commands.keys()].sort();
  }

  /** help 文本,一行一条命令。 */
  help(): string[] {
    return this.names().map((n) => {
      const s = this.#commands.get(n)!;
      const usage = s.usage === undefined ? '' : ` ${s.usage}`;
      const tag = s.cheat === true ? ' [cheat]' : '';
      return `${n}${usage}${tag}  ${s.description}`;
    });
  }
}

/** 装配《微光岛》开发控制台:把 cvar 与几条游戏内命令挂上去。 */
export function createConsole(cvars: CVarSystem): Console {
  const c = new Console();
  c.register({ name: 'help', description: '列出全部命令', run: () => c.help().join('\n') });
  c.register({
    name: 'set', usage: '<cvar> <value>', description: '设置一个 cvar',
    run: (a) => {
      if (a.length !== 2) throw new Error('usage: set <cvar> <value>');
      if (!cvars.setFromString(a[0]!, a[1]!)) throw new Error(`rejected: ${a[0]}=${a[1]}`);
      return `${a[0]} = ${a[1]}`;
    },
  });
  c.register({
    name: 'get', usage: '<cvar>', description: '读取一个 cvar',
    run: (a) => {
      if (a.length !== 1) throw new Error('usage: get <cvar>');
      return `${a[0]} = ${cvars.getString(a[0]!)}`;
    },
  });
  c.register({ name: 'spawn', usage: '<prefab>', description: '在玩家前方生成一个实体', run: (a) => {
    if (a.length !== 1) throw new Error('usage: spawn <prefab>');
    return `spawned ${a[0]} at (0, 0, 0)`;
  } });
  c.register({ name: 'noclip', description: '穿墙', cheat: true, run: () => 'noclip on' });
  c.register({ name: 'god', description: '无敌', cheat: true, run: () => 'god mode on' });
  c.register({
    name: 'give', usage: '<item> [count]', description: '给道具', cheat: true,
    run: (a) => {
      if (a.length < 1 || a.length > 2) throw new Error('usage: give <item> [count]');
      const n = a[1] === undefined ? 1 : Number.parseInt(a[1], 10);
      if (!Number.isInteger(n) || n <= 0) throw new Error(`bad count: ${a[1]}`);
      return `gave ${n} x ${a[0]}`;
    },
  });
  return c;
}

单测覆盖五个关卡:切词的引号、set 的强类型拒绝、未知命令、参数个数错误被兜住、作弊开关的两种状态、以及补全的字典序。

// snippet: src/core/console.test.ts 的四条关键断言
it('按空白切分,双引号内空白保留', () => {
  expect(tokenize('set game.name "微光岛 主岛"')).toEqual(['set', 'game.name', '微光岛 主岛']);
});

it('未知命令返回 ok=false 且提示 help', () => {
  const r = createConsole(makeSystem()).execute('teleport 1 2 3');
  expect(r.ok).toBe(false);
  expect(r.output).toMatch(/unknown command/);
});

it('关闭时作弊命令被挡,普通命令不受影响', () => {
  const c = createConsole(makeSystem());
  expect(c.execute('god').output).toMatch(/cheat/);
  expect(c.execute('get r.vsync').ok).toBe(true);
});

it('complete 返回所有前缀匹配,按字典序', () => {
  expect(createConsole(makeSystem()).complete('s')).toEqual(['set', 'spawn']);
});

本机实跑:npx tsc --noEmit 无错误,npx vitest run src/core/console.test.ts 为 11 passed。再把控制台接起来喂几行真实输入,看回显是否符合预期:

$ node tools/console-demo.mjs   # 为省篇幅,把每条命令的两行回显并成一行;字符串与数值原样
> tab 补全 "s": set spawn
> set r.shadowQuality 3        -> OK   r.shadowQuality = 3
> set r.shadowQuality ultra    -> ERR  rejected: r.shadowQuality=ultra
> set r.shadowQuality 2.5      -> ERR  rejected: r.shadowQuality=2.5
> get r.shadowQuality          -> OK   r.shadowQuality = 3
> set r.vsync 1                -> OK   r.vsync = 1
> set r.vsync maybe            -> ERR  rejected: r.vsync=maybe
> get r.vsync                  -> OK   r.vsync = true
> god                          -> ERR  command 'god' is a cheat, and cheats are disabled
> give berry                   -> ERR  command 'give' is a cheat, and cheats are disabled
> spawn rock                   -> OK   spawned rock at (0, 0, 0)
> spawn                        -> ERR  usage: spawn <prefab>
> teleport 1 2 3               -> ERR  unknown command: teleport (try 'help')
> (cheats on) god              -> OK   god mode on

注意 set r.shadowQuality 2.5 被拒:r.shadowQuality 是 int,2.5 不是整数。再往下的 set r.vsync 1 却成功,get 读回的是 true——因为回显的是你敲进去的原始文本 1,而存进去的是解析后的布尔 true。这两行恰好说明「回显」和「状态」是两回事:解析成功不代表文本被原样保留。非法输入不改变状态(ultra 之后 get 仍是 3),这是配置系统能进 CI 的前提;spawn 少参数、teleport 不存在,也都只回一句提示,程序继续跑。

控制台与配置系统的接缝

set / get 不是控制台自己实现的,而是转交给 CVarSystem。这条接缝的价值在于:控制台只管切词和分发,cvar.ts 只管类型与默认值,两边的职责不重叠。set 里那一行 if (!cvars.setFromString(name, value)) throw 就把强类型校验完全委托了出去——控制台不需要知道 r.vsync 是 bool 还是 float,也不需要为每种类型写一条分支。

反过来,cvar.ts 也不该知道控制台的存在。它的 applyCommandLine 面向的是启动参数(--r.vsync=true),setFromString 面向的是任意来源的一次赋值。控制台只是「任意来源」里最常来的那一个。这种单向依赖让两边都能单独测:cvar.test.ts 只测解析与类型,console.test.ts 只测切词、分发、作弊开关,谁也不用启动对方。

作弊码:开关与它的边界

作弊码的开关不该是「注册或注销命令」——那样 help 里就会随开关闪现不同的命令表,补全也会跳。更干净的做法是命令表恒定,只在执行时拦一道:spec.cheat === true && !this.#cheats 时直接返回失败。于是:

  • 命令表、help、Tab 补全在开关前后完全一致,玩家看到的 help 永远是对的;
  • 关掉作弊码不会让 god 从 help 里消失(它在正式档里本就不该被玩家看到,由构建期整块裁剪负责,而不是运行期开关);
  • 一条命令从「作弊」降级为「普通」只改一个 cheat: true 标记。

这条运行期开关解决的是「同一个可执行文件,开发档开、演示档关」;真正要防玩家作弊,还得靠服务端权威——运行期开关拦不住改内存的人,这是它必须被写清楚的能力边界。

关掉作弊码后 help 的输出依然是完整的一张表,作弊命令只是多了 [cheat] 标记:

$ node tools/console-demo.mjs   # help 段(cheats 关)
get <cvar>  读取一个 cvar
give <item> [count] [cheat]  给道具
god [cheat]  无敌
help  列出全部命令
noclip [cheat]  穿墙
set <cvar> <value>  设置一个 cvar
spawn <prefab>  在玩家前方生成一个实体

help 里 [cheat] 标记是给开发者看的;如果这行不该让玩家看到,正确做法是在构建期把整个控制台模块摇掉,而不是在运行期隐藏——隐藏只挡得住眼睛,挡不住控制台。

下一步处理一个更硬的问题:当控制台也救不了你时——线上已经崩了、现场没了——怎么靠断言分级、遥测采样和崩溃快照把现场重建出来。这是 6.3 的内容。

阅读导航:上一节:6.1 调试绘制与运行时可视化 · 下一节:6.3 断言、遥测与线上问题定位 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径