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 与假时钟 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。