命令注册表
开发者控制台的数据核心——命令表、分词器、执行、历史记录与自动补全。UI 由你来搭。
开发者控制台所需的一切,除了屏幕——命令、解析、历史记录和自动补全。
| 接口 | ICommandRegistry |
| 关闭开关 | NullCommandRegistry |
| 程序集 | CommonGameSystem.Core |
| 启动 | 启动时自动注册(23 个服务之一)——无需任何设置 |
它做什么
命令注册表是开发者控制台、作弊菜单或自动化脚本可以接入的引擎。它掌管数据:命令表(名称到处理函数)、原始输入行的分词器(把 give sword 3 拆成 give、sword、3 的那个部件)、有界的命令历史,以及前缀自动补全。它不在屏幕上绘制任何东西——控制台 UI(渲染、输入捕获、开关键)由你来构建,下方的自己搭一个控制台一节会展示怎么做。
运行时问题绝不会让调用方崩溃。未知命令、错误参数或抛异常的处理函数都会被隔离、记录,并作为失败的 CommandResult 返回。只有编程错误才抛异常:注册 null 或空名称、null 处理函数、在注册表释放后继续使用它、或从工作线程调用。
快速上手
using CommonGameSystem.Core;
using UnityEngine;
[DefaultExecutionOrder(100)] // Run after the framework has started.
public class MyCheats : MonoBehaviour
{
private ICommandRegistry _commands;
private void Awake()
{
_commands = ServiceLocator.Resolve<ICommandRegistry>(); // registered automatically at startup
_commands.Register("give", args =>
{
if (args.Count < 2) return CommandResult.Fail("usage: give <id> <count>");
return CommandResult.Ok($"gave {args[1]}x {args[0]}");
}, new CommandMetadata("Grant an item", "give <id> <count>"));
CommandResult r = _commands.TryExecute("give sword 3"); // tokenizes, dispatches, records history
Debug.Log(r.Message);
}
}
如上所示,在 Awake() 中解析并缓存服务一次。给类标注 [DefaultExecutionOrder(100)],确保它在框架启动之后运行。
API 参考
注册
| 成员 | 说明 |
|---|---|
void Register(string name, CommandHandler handler, CommandMetadata metadata = default) | 把名称映射到处理函数。注册已存在的名称时遵循你在构造时选定的重复策略:替换并告警、静默替换、或抛异常。null/全空白名称或 null 处理函数会抛异常。 |
bool Unregister(string name) | 移除一个名称。存在则返回 true;未知、null 或全空白名称返回 false。绝不抛异常。 |
bool IsRegistered(string name) | 名称是否已注册。null 或全空白返回 false。 |
bool TryGetMetadata(string name, out CommandMetadata metadata) | 返回 true 及该条目的元数据,或 false 及 default。 |
IReadOnlyList<string> RegisteredNames { get; } | 全部已注册名称的只读、已排序快照。快照不可修改。 |
执行
| 成员 | 说明 |
|---|---|
CommandResult TryExecute(string rawLine) | 对该行分词,把第一个 token 当作命令名、其余当作参数,把该行记入历史,并返回处理函数的结果。对运行时输入绝不抛异常。 |
CommandResult TryExecute(string name, IReadOnlyList<string> args) | 直接派发已解析好的名称与参数。跳过分词,且不记入历史。 |
IReadOnlyList<string> Tokenize(string rawLine) | 单遍分词器:按空白切分、双引号成组、反斜杠转义。没有 shell 展开。绝不抛异常。 |
自动补全
| 成员 | 说明 |
|---|---|
IReadOnlyList<string> Lookup(string prefix) | 以 prefix 开头的已注册名称,已排序。空或 null 前缀返回全部名称。绝不抛异常。 |
历史记录(纯数据——无 UI)
| 成员 | 说明 |
|---|---|
string Previous() | 光标向更旧的条目移动一步并返回该行。到最旧条目后停住(不回绕);历史为空时返回 null。 |
string Next() | 向更新的条目移动;越过最新条目后返回 null(即空输入状态)。 |
void ResetHistoryCursor() | 把光标放回最新位置。 |
IReadOnlyList<string> History { get; } | 按时间顺序的快照(从最旧到最新);每次调用返回全新的列表。 |
支持类型
| 类型 | 说明 |
|---|---|
delegate CommandResult CommandHandler(IReadOnlyList<string> args) | 你针对已解析字符串参数编写的处理函数。命令名不包含在内,且列表绝不为 null。类型化的值由处理函数自己解析——注册表不做任何类型转换,这正是它保持 IL2CPP/AOT 安全的原因。 |
readonly struct CommandResult | bool Success 加 string Message(绝不为 null)。用 CommandResult.Ok(msg) 或 CommandResult.Fail(msg) 构建;default 视为失败。 |
readonly struct CommandMetadata(string description, string usage) | Description 与 Usage 帮助字符串(都绝不为 null)。 |
readonly struct CommandRegistryOptions | 构造期策略,经其构造函数或 .Default 设置:HistoryCapacity(64,钳制到 8–1024)、CaseSensitiveNames(true)、DuplicateRegistrationPolicy、MaxArgumentCount(0 = 不限)、WarnOnUnknownCommand、WarnOnDuplicateRegistration、WarnOnMalformedLine、InitialCommandCapacity。 |
enum CommandDuplicatePolicy | ReplaceWithWarning(默认)/ ReplaceSilently / Throw。 |
自己搭一个控制台
注册表把控制台所需的一切都给了你,除了屏幕:TryExecute 产出输出文本,Previous/Next 驱动上/下键的历史回溯,Lookup 支撑自动补全。把这三样绑到任何你喜欢的 UI 上,一个能用的游戏内控制台就成了。
你不必从零开始。Command Console 示例——随包发布的六个示例之一,也是三个可直接运行的示例之一——就是建立在本服务之上的一个完整可用的控制台:一块深色 UGUI 面板,带输出视图、一行暗色的自动补全提示、一个输入框、五个演示命令(help、echo、add、timescale、clear)以及上/下键历史。通过 Tools → Common Game System → Welcome → Import "Command Console Sample" 导入它,打开导入的 CommandConsole.unity,然后点击 Play。它是推荐的起点:把它复制进你的项目并扩展命令列表。
接线模式的核心归纳如下:
using System.Collections.Generic;
using CommonGameSystem.Core;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;
[DefaultExecutionOrder(100)]
public class MyConsole : MonoBehaviour
{
[SerializeField] private InputField _inputField; // your input row
[SerializeField] private Text _outputText; // your output view
private ICommandRegistry _commands;
private bool _fieldFocusedLastFrame;
private void Awake() => _commands = ServiceLocator.Resolve<ICommandRegistry>();
private void Update()
{
Keyboard kb = Keyboard.current;
if (kb == null) return;
// History recall while the field is focused.
if (_inputField.isFocused)
{
if (kb.upArrowKey.wasPressedThisFrame) Recall(_commands.Previous());
else if (kb.downArrowKey.wasPressedThisFrame) Recall(_commands.Next() ?? string.Empty);
}
// Submit on Enter — detected here in Update (see the tip below).
bool enter = kb.enterKey.wasPressedThisFrame || kb.numpadEnterKey.wasPressedThisFrame;
if (enter && (_inputField.isFocused || _fieldFocusedLastFrame))
Submit(_inputField.text);
_fieldFocusedLastFrame = _inputField.isFocused;
}
private void Submit(string line)
{
if (string.IsNullOrWhiteSpace(line)) return;
CommandResult result = _commands.TryExecute(line); // tokenize + dispatch + history
_outputText.text += $"\n> {line}\n{result.Message}";
_commands.ResetHistoryCursor(); // next Up starts from the newest entry
_inputField.text = string.Empty;
_inputField.ActivateInputField(); // refocus for the next command
}
private void Recall(string line)
{
if (line == null) return; // null only when history is empty
_inputField.text = line;
_inputField.caretPosition = line.Length;
_inputField.ActivateInputField();
}
// Autocomplete: call from the field's onValueChanged and render the matches yourself.
public IReadOnlyList<string> Complete(string prefix) => _commands.Lookup(prefix);
}
实用提示——在 Update 中检测 Enter,而不是在 onEndEdit 中。 从输入框的 onEndEdit 事件里提交很有诱惑力,但等到 UI 抛出 onEndEdit 时,键盘的 enterKey.wasPressedThisFrame 已经变回 false(在 Unity 6.3 LTS 上实测)——在 onEndEdit 里用那个标志位做提交门禁的控制台会悄悄吞掉每一次提交。请像上面的代码片段那样,改在 Update 里轮询键盘。还有一个细节:同一次 Enter 按键可能在这一帧的更早时刻让输入框失焦,所以只要输入框现在处于焦点,或上一帧处于焦点就接受这次按键——_fieldFocusedLastFrame 就是干这个的。
示例里还有两个值得照抄的习惯:
- 在共享注册表上注册,且只清理你自己的名称。 在
OnDestroy中,恰好对你添加的那些命令调用Unregister。永远不要对注册表调用Dispose()——它是由 Bootstrap 拥有的共享服务。 - 如果打字或按 Enter 完全没有反应,你的项目很可能还停在 Unity 的旧输入后端上。打开 Tools → Common Game System → Welcome,点击 Enable the new Input System(需要重启一次编辑器),然后再次 Play。
示例
using System.Collections.Generic;
using CommonGameSystem.Core;
using UnityEngine;
[DefaultExecutionOrder(100)]
public class DebugConsoleInput : MonoBehaviour
{
private ICommandRegistry _commands;
private void Awake()
{
_commands = ServiceLocator.Resolve<ICommandRegistry>();
_commands.Register("god", _ => CommandResult.Ok("god mode toggled"),
new CommandMetadata("Toggle invulnerability", "god"));
}
// Called by your own UI when the player submits a console line.
public string Submit(string line)
{
CommandResult r = _commands.TryExecute(line); // recorded in history automatically
return r.Message; // your UI prints it
}
// Up/Down arrow recall — pure data; you bind the keys.
public string RecallPrevious() => _commands.Previous();
public string RecallNext() => _commands.Next();
// Tab autocomplete — you render the candidate list.
public IReadOnlyList<string> Complete(string prefix) => _commands.Lookup(prefix);
}
关闭它
ServiceLocator.Replace<ICommandRegistry>(new NullCommandRegistry());
这会静默所有命令。Register/Unregister 不做任何事,TryExecute 返回失败的 CommandResult,每个查询都返回空集合或 null。按真实注册表接线的游戏照常运行,只是作弊与命令悄然失效。替换实现构造时记录一条警告,因此替换绝不会悄无声息。编程错误仍会抛异常——null/全空白名称、null 处理函数、释放后使用——这是框架中每个空实现共同遵循的规则。
常见陷阱
- 控制台 UI 归你。 注册表只交付数据核心——没有打印、渲染或开关键 API。把你自己的 UI 绑到
TryExecute(输出)、Previous/Next(历史回溯)和Lookup(自动补全)上。CommandResult.Message是注册表产出的唯一文本。 - 处理函数自己解析参数。 参数以
IReadOnlyList<string>形式到达,且不含命令名。注册表不做类型化转换或反射绑定——int.Parse之类请自己调用。这正是它保持 IL2CPP/AOT 安全的原因。 - 两个
TryExecute重载对历史的处理不同。string rawLine重载会分词并记入历史;预解析的(name, args)重载两者都跳过。控制台输入用原始行形式,程序化派发用已解析形式。 default(CommandRegistryOptions)不是.Default。 裸的new CommandRegistryOptions()(或default)是全零的选项包:历史容量 0、名称不区分大小写。注册表会从这些零值恢复出合理的数值,但请显式传入CommandRegistryOptions.Default来获得规范策略。- 仅限主线程。 与多数框架服务一样,注册表会断言主线程。从工作线程调用它属于编程错误。