Flame 游戏引擎对话系统进阶:Jenny 运行时 CommandStorage 自定义指令注册与执行全解析
2026/9/16 20:03:36 网站建设 项目流程

Flame 游戏引擎对话系统进阶:Jenny 运行时 CommandStorage 自定义指令注册与执行全解析

【免费下载链接】flameA Flutter based game engine.项目地址: https://gitcode.com/GitHub_Trending/fl/flame

Flame 的官方对话引擎 Jenny(位于 packages/flame_jenny)在运行时通过CommandStorage统一管理所有用户自定义指令(user-defined commands),让游戏开发者可以把<<give>><<walk>><<prompt>>这类指令接入真实的游戏逻辑。本文以 doc/other_modules/jenny/runtime/command_storage.md 为核心,结合 command_storage.dart 源码与 command_storage_test.dart 测试用例,系统讲解自定义指令的注册约束、全部 API、底层参数解析与执行流程,读完即可在 Flame 项目中稳定接入任意自定义对话指令。

CommandStorage 是什么

CommandStorage是 YarnProject 中负责存放所有用户自定义指令的容器,通过YarnProject.commands属性访问。它允许你注册任意数量的自定义命令,使它们可以在 yarn 脚本中直接使用。

一个关键的时序约束是:自定义指令必须在解析(parse)yarn 脚本之前完成注册,否则编译器在遇到未知指令时会直接报错。这与函数注册(FunctionStorage,见 function_storage.md)的约束完全一致。标准初始化顺序如下(摘自 yarn_project.md):

  1. 链接用户自定义函数(functions);
  2. 链接用户自定义指令(commands);
  3. 设置 locale(如果默认en不满足需求);
  4. 解析包含全局变量与角色声明的.yarn脚本;
  5. 解析其余所有.yarn脚本;
  6. 从存档恢复变量。
final yarn = YarnProject() ..functions.addFunction0('money', player.getMoney) ..commands.addCommand1('achievement', player.earnAchievement) ..parse(readFile('project.yarn')) ..parse(readFile('chapter1.yarn')) ..parse(readFile('chapter2.yarn'));

从源码看,CommandStorage的内部实现非常轻量——它只是用一个Map<String, _Cmd?>来存储命令名到封装函数的映射(command_storage.dart):

class CommandStorage { CommandStorage() : _commands = {}; final Map<String, _Cmd?> _commands;

其中_Cmd是包裹 Dart 函数的内部类,负责记录函数签名(参数类型列表)并在运行时做类型转换与调用。

注册自定义指令的硬性约束

要把一个 Dart 函数注册为 yarn 指令,该函数必须满足以下要求(与原文档一致,并有源码_Cmd类作证):

约束说明
返回值必须是voidFuture<void>。返回 Future 的指令会被await,对话会等待其完成后再进入下一步——这正是<<walk>><<moveCamera>><<prompt>>这类需要时间展开的指令的实现基础
参数类型必须是 Jenny 已知的类型:Stringnumintdoublebool。源码中的类型映射表位于 command_storage.dart:bool → booleanint → integerdouble → doublenum → numericString → string
参数形式必须全部是位置参数(positional),非空(non-nullable),且不能有默认值
注册方法按参数个数选择addCommand0()~addCommand5()中对应的方法
尾部布尔参数若函数签名末尾有 1 个及以上bool参数,这些参数将被视为可选,缺省时自动取false

尾部布尔参数的可选特性在源码中有明确实现:_Cmd构造时通过_signature.reversed.takeWhile((type) => type == _Type.boolean).length计算出numTrailingBooleans(command_storage.dart),在unpackArguments中先把这些位置预填为false(第 194-196 行),再按实际提供的参数字符串逐位覆盖。测试用例 command_storage_test.dart 验证了true true truetrue truetrue、空串四种情况分别得到(true,true,true)(true,true,false)(true,false,false)(false,false,false)

另外,指令名本身受_checkName校验(command_storage.dart),必须满足三条规则,否则触发 assert:

  • 不能与已注册指令重名(Command <<$name>> has already been defined);
  • 不能与内置指令同名(Command <<$name>> is built-in);
  • 必须是一个合法标识符,正则^[a-zA-Z_]\w*$Command name "$name" is not an identifier)。

内置指令白名单位于 command_storage.dart:declareelseelseifendifforifjumplocalsetstopvisitwaitwhile(其中部分为预留)。测试 command_storage_test.dart 验证了注册if/set/for/while/local都会抛断言错误。

全部 API 一览

CommandStorage对外提供的接口可分为"注册类"和"查询/管理类"两类。

注册类方法

方法签名功能
addCommand0(String name, FutureOr<void> Function() fn)注册一个无参函数为指令name
addCommand1(String name, FutureOr<void> Function(T1) fn)注册单参数函数
addCommand2(String name, FutureOr<void> Function(T1, T2) fn)注册两参数函数
addCommand3(String name, FutureOr<void> Function(T1, T2, T3) fn)注册三参数函数
addCommand4(String name, FutureOr<void> Function(T1, T2, T3, T4) fn)注册四参数函数
addCommand5(String name, FutureOr<void> Function(T1, T2, T3, T4, T5) fn)注册五参数函数
addOrphanedCommand(String name)注册一个没有 Dart 函数支撑的"孤儿指令"

泛型参数T1~T5的取值被限制在上述五个已知类型内。如果在注册时使用了不支持的参数类型(例如List),_unpackTypes中的 assert 会直接失败(Unsupported type List<dynamic> of argument 1),测试见 command_storage_test.dart。

addOrphanedCommand 的特殊语义:该指令不绑定任何 Dart 函数,注册时在映射中存入null(command_storage.dart)。它依然会被投递到所有 DialogueView 的onCommand()回调中,但它的参数不会被解析——对应的 UserDefinedCommand 对象的arguments属性保持为null。这适合那些纯 UI 通知类指令(例如让视图播放一段动画而无需参数类型检查)。

查询与管理类方法

方法/属性功能
bool hasCommand(String name)查询指令name是否已注册(源码实现即_commands.containsKey(name)
void clear()清空所有用户自定义指令
void remove(String name)按名字移除某个用户自定义指令
int length已注册的用户自定义指令数量
bool isEmpty是否一个指令都未注册
bool isNotEmpty是否已有任意指令注册

测试 command_storage_test.dart 验证了clear()isEmptytrue,以及remove('foo')hasCommand('foo')变为false、其余指令不受影响。

指令执行的底层流程

当对话运行到一条用户自定义指令时,CommandStorage.runCommand()(标注为@internal,由对话运行时调用)会执行如下流水线(command_storage.dart):

FutureOr<void> runCommand(UserDefinedCommand command) { command.argumentString = command.content.evaluate(); // 1. 求值参数文本 final cmd = _commands[command.name]; if (cmd != null) { final stringArgs = ArgumentsLexer(command.argumentString).tokenize(); // 2. 分词 final typedArgs = cmd.unpackArguments(stringArgs); // 3. 类型检查与转换 command.arguments = typedArgs; // 4. 回填解析结果 return cmd.run(typedArgs); // 5. 调用 Dart 函数 } }

各步骤的细节如下:

  1. 参数求值:指令内容按普通行解析规则求值,其中允许插值表达式(放在花括号{}中),但不允许 markup 和 hashtag。例如<<give Gold {round(100 * $multiplier)}>>$multiplier == 1.5时,求值后的参数字符串为"Gold 150"UserDefinedCommandargumentString属性会保存这份求值结果(user_defined_command.dart)。

  2. 分词(ArgumentsLexer):参数字符串按空白(空格、Tab)切分为独立参数,同时支持双引号包裹的带空格参数,以及转义序列\\\"\n(command_storage.dart)。该词法分析器位于同一文件内,测试集中在 command_storage_test.dart,例如"Hello World"被解析为单个参数Hello World,而"hello会抛出Unterminated quoted stringDialogueError

  3. 类型检查与转换unpackArguments首先校验参数个数——既不能超出签名长度,也不能少于"必需参数数"(stringArguments.length + numTrailingBooleans < _arguments.length时报错),然后逐个按签名类型转换(command_storage.dart):

    • boolean:命中YarnProject.trueValuestrue,命中falseValuesfalse,否则抛TypeError。默认集合定义在 yarn_project.dart:trueValues = {'true','yes','on','+','T','1'}falseValues = {'false','no','off','-','F','0'}
    • integerint.tryParse,失败抛TypeError
    • doubledouble.tryParse,失败抛TypeError
    • numeric(对应num):num.tryParse,整数、浮点、Infinity-0.0均可接受;
    • string:原样保留字符串。
  4. 回填与调用:解析后的类型化参数写入UserDefinedCommand.arguments,随后调用被包裹的 Dart 函数。返回的FutureOr<void>会被对话运行器等await——这就是<<prompt>>这类指令能让对话"卡住"等待用户输入的原因。

一个常见的报错示例(测试 command_storage_test.dart):addCommand2('xyz', (int z, bool f) => null)后运行<<xyz 1 true 3>>,会得到TypeError: Command <<xyz>> expects 2 arguments but received 3 arguments

实战示例一:<<StartQuest>>发起任务

假设我们想要一个发起任务的指令<<StartQuest>>,它携带任务 ID 与任务名两个参数。如果只传 ID,yarn 脚本的可读性会很差,无法一眼看出是哪个任务,因此同时传 ID 与名称,并在运行时校验二者匹配。

典型调用如下(注意任务名带引号,否则Get rid of bandits会被解析成"Get""rid""of""bandits"四个独立参数):

<<StartQuest Q037 "Get rid of bandits">>

对应的 Dart 实现:函数返回void(任务提示动画不需要对话等待),用addCommand2注册:

class MyGame { late YarnProject yarnProject; void startQuest(String questId, String questName) { assert(quests.containsKey(questId)); assert(quests[questId]!.name == questName); // ... 实际发起任务的逻辑 } @override void onLoad() { yarnProject = YarnProject() ..commands.addCommand2('StartQuest', startQuest); } }

注意 Dart 函数名(startQuest)与指令名(StartQuest)可以不同,注册时的name才是 yarn 脚本中实际使用的名字,你可以按自己的编程风格自由命名。

实战示例二:<<prompt>>弹出输入框并回写变量

<<prompt>>会打开一个模态对话框等待用户输入。由于必须等待用户响应,该函数返回Future<void>。指令本身不是表达式、无法返回值,因此把结果写入全局变量$prompt,对话脚本后续再读取该变量:

class MyGame { final YarnProject yarnProject = YarnProject(); Future<void> prompt(String message) async { // 一直等到模态对话框从路由栈中被弹出 final name = await router.pushAndWait(KeyboardDialog(message)); yarnProject.variables.setVariable(r'$prompt', name); } @override void onLoad() { yarnProject ..variables.setVariable(r'$prompt', '') ..commands.addCommand1('prompt', prompt); } }

在 yarn 脚本中的用法如下——注意先<<declare $name as String>>声明,再把$prompt的值转移给玩家名变量:

<<declare $name as String>> title: Greeting --- Guide: Hello, my name is Jenny, and you? <<prompt "Enter your name:">> <<set $player = $prompt>> // Store the name for later Guide: Nice to meet you, {$player} ===

$prompt的读写依赖 VariableStorage,这是 Jenny 中全局变量的统一容器。

实战示例三:<<give>>发放物品

再实现一个"给玩家发放物品"的指令,它接收三个参数:物品来源(谁给的)、物品名、数量。yarn 中的写法及变量替换效果如下:

<<give {$quest_reward} TraderJoe>>

假设任务奖励变量$quest_reward的内容是"100 gold""5 potion_of_healing"'1 "Sword of Darkness"',运行时会替换成对应的三参数指令再解析:

<<give 100 gold TraderJoe>> <<give 5 potion_of_healing TraderJoe>> <<give 1 "Sword of Darkness" TraderJoe>>

对应的 Dart 函数签名:

/// Takes [amount] of [item]s from [source] and gives them to the player. void give(int amount, String item, String source) { // ... 发放逻辑 }

这个例子同时展示了三个关键点:变量插值在运行时才求值、带空格参数必须用引号包裹、int参数会做严格整数解析("100"100)。

小结

CommandStorage是 Flame + Jenny 对话体系中"把脚本指令接到游戏逻辑"的唯一入口。使用时牢记三条原则:先注册后解析参数类型限于五个已知类型尾部布尔参数自动可选。配合addOrphanedCommand处理纯 UI 通知型指令,配合返回Future的函数实现等待型指令,即可覆盖绝大多数游戏内对话驱动的交互场景。

延伸阅读

  • YarnProject 总览:指令、函数、变量、角色的统一入口
  • 用户自定义指令语言层语法:指令参数的解析规则与求值细节
  • UserDefinedCommand 运行时对象:nameargumentStringarguments三属性的完整语义
  • DialogueView:onCommand()回调如何接收指令事件
  • CommandStorage 源码 与 测试用例:完整的注册校验、类型转换与错误分支实现

【免费下载链接】flameA Flutter based game engine.项目地址: https://gitcode.com/GitHub_Trending/fl/flame

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询