Flame 游戏引擎中的 Yarn 命令系统:Jenny 方言内置命令与用户自定义命令完全指南
【免费下载链接】flameA Flutter based game engine.项目地址: https://gitcode.com/GitHub_Trending/fl/flame
YarnSpinner 是书写.yarn对话脚本的语言,而 Flame 生态中的 Jenny 是其 Dart 实现,命令(commands)是这门语言中最重要的执行单元——它们以双尖括号<<...>>包裹,用于变量操作、流程控制和与游戏逻辑交互。本文将完整讲解 Jenny 命令系统的全貌:内置命令的语法、语义与源码级实现,以及如何声明带类型参数的用户自定义命令,帮助你在基于 Flame 的游戏中编写可维护、可扩展的对话系统。
命令概览:<<命令>>的两种类型
命令是 Yarn 脚本中一类特殊指令,统一由双尖括号包裹,例如<<stop>>。命令分为两类:
- 内置命令(built-in commands):由 YarnSpinner/Jenny 运行时自身支持,通常用于改变对话的执行流程或执行与对话相关的功能,完整清单见下文。
- 用户自定义命令(user-defined commands):由你自己创建并在 yarn 脚本中使用,详细说明见用户自定义命令。
一个.yarn文件可以包含注释、标签(tag)、命令和节点(nodes)。关于 Yarn 文件的基础结构与语言整体介绍,可参考 YarnSpinner 语言总览。
命令在文件中的位置:编译期与运行期
需要特别注意的是,并非所有命令都可以出现在任何位置。在 language.md 中明确规定:文件根层级(即节点之外)只允许出现两类命令:
<<declare>><<character>>
位于节点之外(文件根层级)的命令属于编译期指令,它们在YarnProject编译解析期间就被执行,而不是在对话运行时执行。这一区分是理解 Jenny 命令系统行为的关键前提。
变量相关命令
<<declare>>:声明全局变量
<<declare>>用于创建新的全局变量并赋予初始值。命令被执行后,该变量即可在任意需要变量的地方使用——包括内联表达式、其他命令,甚至是其他<<declare>>语句。
与大多数命令不同,<<declare>>在编译期(即 yarn 脚本被解析时)执行。当对话运行时,它已经不起作用,因为此时变量早已初始化完毕。正因如此,<<declare>>必须放置在节点之外、脚本的根层级,以此明确这些命令不会在节点运行时执行。
基础示例(摘自 declare.md):
<<declare $monicker = "boy">> --------------- title: Greeting --------------- Teacher: Welcome to the class, {$monicker}! ===这里<<declare>>引入了一个名为$monicker、类型为String、初始值为"boy"的变量。之后该变量在 "Greeting" 节点中被使用——到那时变量的值可能是任何内容(可能在其他节点或游戏本身中被修改),但<<declare>>语句是必需的:它告诉 Jenny 这是一个合法变量名,以及它的类型是什么。
<<declare>>的三种语法:
// 形式一:由表达式推断类型(最常见) <<declare $VARIABLE = EXPRESSION>> // 形式二:显式指定类型,初始值取该类型的默认值 <<declare $VARIABLE as TYPE>> // 形式三:组合形式,表达式与类型显式绑定 <<declare $VARIABLE = EXPRESSION as TYPE>>- 形式一中,
$VARIABLE是变量名(Yarn 中所有变量都以$开头),EXPRESSION可以是字面量或更复杂的表达式,该表达式会在编译期求值以提供初始值,变量的类型由表达式的类型推导得出。 - 形式二中,
TYPE只能是Bool、Number或String三者之一,创建出的变量分别初始化为false、0或""。 - 形式三适用于表达式类型不够直观、希望显式标注的场景;编译器会校验
EXPRESSION的类型与TYPE一致,否则抛出编译期错误。
更多示例:
<<declare $prefix = "Mr.">> <<declare $gold = 100>> <<declare $been_to_hell = false>> <<declare $name as String>> <<declare $distanceTraveled as Number>> <<declare $birthDay = randomRange(1, 365) as Number>> <<declare $vulgarity = GetObscenitySetting() as Bool>>工程组织建议(原文要点,必须遵循):
- 推荐将所有
<<declare>>语句集中放入单独的一个 yarn 文件,并确保该文件最先被解析,从而保证所有全局变量在任何节点使用之前就已声明完毕。 - 如果你的游戏支持存档,通常还需要保存 yarn 全局变量的值。此时恢复存档值必须在所有 yarn 脚本解析完成之后进行,否则引擎会认为变量被声明了两次。
- 建议为每个
<<declare>>附带一条文档注释(doc-comment)说明变量的用途,就像为类的公共成员写文档那样。
<<local>>:声明节点级局部变量
<<local>>与<<declare>>类似,区别在于它创建的变量仅在当前节点内可见,适合只在一段对话中临时使用的数据。其语法为:
<<local $VARIABLE = EXPRESSION>> <<local $VARIABLE = EXPRESSION as TYPE>>第二种形式会对表达式类型与TYPE做一致性校验,不匹配则编译报错,相当于为局部变量做显式类型标注。
限制条件(摘自 local.md):
- 同一节点内,每个局部变量只能声明一次;
- 局部变量的名字不能与任何全局变量重名。
示例:骰子投掷的结果$roll只在当前节点内短暂使用,没必要声明为全局变量。
title: a_dice_roll --- <<local $roll = dice(6)>> <<if $roll == 1>> You've rolled 1, rotten luck... <<elseif $roll == 2>> You've rolled 2, which is still below the average. Try harder! <<elseif $roll == 3>> You've rolled 3.14159265 (well, almost). <<elseif $roll == 4>> Your roll is an unlucky number. Please roll again <<else>> You've rolled 10 (when rounded to the nearest ten). Good job! <<endif>> ===<<set>>:更新变量值
<<set>>用于更新已存在变量的值。变量必须先通过<<declare>>或<<local>>声明,才能出现在<<set>>中。它支持常规赋值和修改赋值两种形式(摘自 set.md):
// 常规赋值 <<set $VARIABLE = EXPRESSION>> <<set $VARIABLE to EXPRESSION>> // 修改赋值 <<set $VARIABLE += EXPRESSION>> <<set $VARIABLE -= EXPRESSION>> <<set $VARIABLE *= EXPRESSION>> <<set $VARIABLE /= EXPRESSION>> <<set $VARIABLE %= EXPRESSION>> // 上述修改赋值等价于: <<set $VARIABLE = $VARIABLE + EXPRESSION>> <<set $VARIABLE = $VARIABLE - EXPRESSION>> <<set $VARIABLE = $VARIABLE * EXPRESSION>> <<set $VARIABLE = $VARIABLE / EXPRESSION>> <<set $VARIABLE = $VARIABLE % EXPRESSION>>在所有情况下,EXPRESSION的类型必须与$VARIABLE相同,否则会抛出编译期错误。
综合示例(颜色问答 + 好感度累加):
<<declare $favorite_color as String>> title: ColorQuiz --- What is your favorite color? -> White <<set $favorite_color to "White">> -> Red <<set $favorite_color to "Red">> -> Yellow <<set $favorite_color = "Yellow">> -> Blue Oh, Nice! Which shade of blue? -> Azure -> Cerulean -> Lapis Lazuli Umm, I don't know how to spell that. I'll just put you down as "blue". <<set $favorite_color = "Blue">> -> Black <<set $favorite_color = "Black">> That's mine too! <<set $affinity += 3>> -> Prefer not to tell Aww... Maybe if I ask again really nicely? <<jump ColorQuiz>> ===注意这里同时展示了<<set ... to ...>>与<<set ... = ...>>两种写法,以及+=修改赋值、<<jump>>循环提问的用法。
<<character>>:声明角色与别名
<<character>>用于声明一个角色以及一个或多个可在脚本中使用的别名,它有以下用途(摘自 character.md):
- 防止在脚本中意外拼错角色名;
- 允许角色拥有不必是 ID 的"全名"(full name);
- 允许为同一角色声明多个别名,可在不同节点中使用(别名甚至可以与全名使用不同语言);
- 可以为每个角色关联附加数据,这些数据在运行时可用。
语法:
<<character "FULL NAME" alias1 alias2...>>全名是可选的:若给出,则视为该角色的正式名字;若省略,则第一个别名被视为角色的正式名字。每个别名必须是合法的 ID,且至少提供一个别名。
// 一个很有礼貌的七岁小女孩,却总是卷入各种奇妙的冒险。 <<character Alice>> // 一只以灿烂微笑和部分隐身能力闻名的魔法猫。他自己承认他疯了。 <<character "Cheshire Cat" Cat Cheshire>> // 一位脾气暴躁的王后,同时也是一张扑克牌。 <<character "Queen of Hearts" Queen QoH QH>>角色声明之后,其任意别名都可在脚本中使用,它们都指向同一个Character对象。与此同时,不声明就使用角色是不被允许的——除非在YarnProject中设置了允许如此的特殊标志。在对话中,角色别名会出现在台词前缀位置:
title: Alice_and_the_Cat --- Alice: But I don't want to go among mad people. Cat: Oh, you can't help that, we're all mad here. I'm mad. You're mad. Alice: How do you know I'm mad? Cat: You must be, or you wouldn't have come here. Alice: And how do you know that you're mad? Cat: To begin with, a dog's not mad. You grant that? Alice: I suppose so. Cat: Well then, you see a dog growls when it's angry, and wags its tail \ when it's pleased. Cat: Now, [i]I[/i] growl when I'm pleased, and wag my tail when I'm angry. \ Therefore, I'm mad. Alice: [i]I[/i] call it purring, not growling. Cat: Call it what you like. ===从源码结构看,Jenny 在 character.dart 与 character_storage.dart 中实现Character对象及其存储/查找逻辑,<<character>>命令在编译期完成角色注册。
控制流命令
<<if>>:条件分支
<<if>>求值其条件,并据此决定接下来执行哪些语句,等价于大多数编程语言中的if关键字。它可以有多个部分(摘自 if.md):
<<if condition1>> statements1... <<elseif condition2>> statements2... <<else>> statementsN... <<endif>>规则要点:
- 每个条件必须为布尔类型;
<<elseif>>块的数量不限;<<elseif>>块和<<else>>块均可选;- 结尾的
<<endif>>必须存在; - 每个块内的语句必须缩进。
运行时首先求值if块的条件:若为true,执行statements1,不再求值其他条件;若为false,则依次求值condition2……全部为false时落入else块执行statementsN。最终对话会继续执行最终<<endif>>之后的语句。
示例(守卫的不同态度取决于你的声望):
title: GuardGreeting --- <<if $reputation >= 100>> Guard: Hail to the savior of the people! <<elseif $reputation >= 30>> Guard: Nice to meet you, sir! <<elseif $reputation >= 0>> Guard: Hello <<elseif $reputation > -30>> Guard: I'm keeping an eye on you... <<elseif $reputation > -100>> Guard: You filthy scum! <<else>> Guard: You'll pay for your crimes! #auto <<attack>> <<endif>> ===这个例子还展示了条件判断的分层递减写法(从>= 100到> -100再到 else),以及#auto自动续行标记和自定义命令<<attack>>的组合使用——当声望低于 −100 时,守卫会当场攻击你。
<<jump>>:切换到另一个节点
<<jump>>停止执行当前节点,并立即开始运行目标节点,类似许多语言中的goto(摘自 jump.md):
<<jump FarewellScene>>参数是目标节点的 id,既可以直接给出纯节点 ID,也可以用花括号包一个表达式:
<<jump {"Ending_" + $ending}>>如果表达式在运行时求值得到未知的节点名,将抛出NameError异常。注意<<jump>>是"一去不回"的跳转;如果你需要跳过去再回来,请使用<<visit>>。
<<visit>>:临时跳转并返回
<<visit>>将当前节点暂时挂起,执行目标节点,待其结束后恢复上一个节点的执行——类似编程语言中的函数调用(摘自 visit.md)。
它非常适合把大型对话拆分成多个较小的节点,或在多个节点间复用公共对话片段:
title: RoamingTrader1 --- <<if $roaming_trader_introduced>> Hello again, {$player}! <<else>> <<visit RoamingTraderIntro>> <<endif>> -> What do you think about the Calamity? <<if $calamity_started>> <<visit RoamingTrader_Calamity>> -> Have you seen a weird-looking girl running by? <<if $quest_little_girl>> <<visit RoamingTrader_LittleGirl>> -> What do you have for trade? <<OpenTrade>> Pleasure doing business with you! #auto ===参数同样是目标节点 id,支持纯 ID 或花括号表达式两种形式:
<<visit {"RewardChoice_" + string($choice)}>>与<<jump>>一样,若运行时表达式求值得到未知节点名,将抛出NameError。上例还展示了选项(->)后跟<<if>>条件判断的选项条件门控写法。
<<stop>>:停止当前节点
<<stop>>立即停止当前节点的求值,就像跳到了它的结尾一样。该命令不接受任何参数(摘自 stop.md):
<<stop>>通常情况下,它的效果是停止整个对话;但如果你是从另一个节点<<visit>>进来的,那么<<stop>>只会退出当前节点,执行流返回到父节点。因此<<stop>>类似许多编程语言中的return;。
<<wait>>:暂停对话
<<wait>>强制对话引擎在恢复对话前等待指定的时长(单位:秒)。秒数可以为 0,但不能为负数。该命令接受单个参数,必须是一个数值表达式(摘自 wait.md):
// 等待四分之一秒 <<wait 0.25>> // 等待 $delay 变量给出的时长 <<wait $delay>>这在表现对话节奏、配合角色动画或音效时非常实用。
用户自定义命令:把对话接进游戏逻辑
除了内置命令,你还可以在 yarn 脚本中声明和使用自己的用户自定义命令。典型用途是执行可作为对话自然组成部分的游戏内动作,例如:<<wave>>、<<smile>>、<<frown>>、<<moveCamera>>、<<zoom>>、<<shakeCamera>>、<<fadeOut>>、<<walk>>、<<give>>、<<take>>、<<achievement>>、<<GainExperience>>、<<startQuest>>、<<finishQuest>>、<<openTrade>>、<<drawWeapon>>等(摘自 user_defined_commands.md)。
参数处理规则(五步流程)
多数情况下,自定义命令需要携带参数。其参数按照以下规则处理:
- 解析:命令名之后直到
>>的所有内容,按照常规行解析规则解析——允许插值表达式,但不允许标记(markup)和标签(hashtag)。 - 求值:运行时对该行内容求值,即代入所有表达式的值。
- 拆分:求值后的参数字符串按空白拆分成独立参数,并与后端函数的签名做类型比对。
- 调用:调用后端函数,传入解析好的参数。
- 分发事件:对话运行器中的所有对话视图(dialogue views)都会收到
onCommand()事件。
一个具体例子
考虑下面的命令:
<<give Gold {round(100 * $multiplier)}>>首先注意:与内置命令不同,自定义命令的参数被当作文本处理,任何表达式都必须放在花括号里。
然后,运行时求值表达式——假设$multiplier为 1.5,命令的参数字符串就变成"Gold 150"。接着按空白拆分,并依据后端 Dart 函数的参数类型逐个解析。例如,若函数签名为void give(String item, int amount),则会被调用为give("Gold", 150)。反之,如果参数个数或类型与签名不匹配,则会抛出DialogueException。
源码层面的实现印证
从 user_defined_command.dart 的源码可以看出,UserDefinedCommand类在运行时持有命令名与LineContent内容,其execute()方法最终委托给dialogue.project.commands.runCommand(this)(对应 command_storage.dart),由命令存储负责参数求值、类型校验与后端函数调用。这也是文档所描述的五步参数流程在引擎内部的落点;而onCommand()事件则定义在 dialogue_view.dart 中,由 dialogue_runner.dart 在命令执行后统一分发,让所有 UI 层都能感知到命令的发生。
设计建议与最佳实践
综合原文档与源码,在设计对话脚本时建议遵循以下实践:
- 全局变量集中声明:将全部
<<declare>>放入一个独立的 yarn 文件并确保最先解析,避免变量未声明即使用。 - 存档恢复时机:恢复 yarn 全局变量的存档值必须在所有脚本解析完成之后进行,否则会触发重复声明错误。
- 文档注释:为每个
<<declare>>编写 doc-comment,说明变量用途,就像为公共类成员写文档。 - 局部变量够用即用:只在单节点内需要的数据用
<<local>>,避免污染全局命名空间;注意局部变量不得与全局变量重名、且每节点只能声明一次。 - 善用
<<visit>>拆分对话:将大段对话拆成小节点并通过<<visit>>组合,可显著提升脚本的可读性与复用性;需要"一去不回"时用<<jump>>,需要提前终止用<<stop>>。 - 自定义命令负责游戏动作:把表现型动作(动画、镜头、音效、任务进度、交易等)封装成自定义命令,让对话编写者专注于叙事本身。
结语
Jenny 的命令系统为 Yarn 对话脚本提供了完整的变量管理与控制流能力:<<declare>>/<<local>>/<<set>>管理数据,<<if>>/<<jump>>/<<visit>>/<<stop>>/<<wait>>控制流程,<<character>>管理角色,而用户自定义命令则把对话安全地接进游戏逻辑。理解了"编译期命令(根层级)"与"运行期命令(节点内)"的区别,以及自定义命令的五步参数处理流程,你就能在 Flame 项目中搭建出结构清晰、扩展性强的对话系统。需要进一步深入时,可继续阅读表达式与操作符、节点结构以及行与选项等配套章节。
【免费下载链接】flameA Flutter based game engine.项目地址: https://gitcode.com/GitHub_Trending/fl/flame
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考