news 2026/9/16 8:27:34

Flame 游戏引擎中的 Yarn 命令系统:Jenny 方言内置命令与用户自定义命令完全指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Flame 游戏引擎中的 Yarn 命令系统:Jenny 方言内置命令与用户自定义命令完全指南

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只能是BoolNumberString三者之一,创建出的变量分别初始化为false0""
  • 形式三适用于表达式类型不够直观、希望显式标注的场景;编译器会校验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)。

参数处理规则(五步流程)

多数情况下,自定义命令需要携带参数。其参数按照以下规则处理:

  1. 解析:命令名之后直到>>的所有内容,按照常规行解析规则解析——允许插值表达式,但不允许标记(markup)和标签(hashtag)。
  2. 求值:运行时对该行内容求值,即代入所有表达式的值。
  3. 拆分:求值后的参数字符串按空白拆分成独立参数,并与后端函数的签名做类型比对。
  4. 调用:调用后端函数,传入解析好的参数。
  5. 分发事件:对话运行器中的所有对话视图(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 层都能感知到命令的发生。

设计建议与最佳实践

综合原文档与源码,在设计对话脚本时建议遵循以下实践:

  1. 全局变量集中声明:将全部<<declare>>放入一个独立的 yarn 文件并确保最先解析,避免变量未声明即使用。
  2. 存档恢复时机:恢复 yarn 全局变量的存档值必须在所有脚本解析完成之后进行,否则会触发重复声明错误。
  3. 文档注释:为每个<<declare>>编写 doc-comment,说明变量用途,就像为公共类成员写文档。
  4. 局部变量够用即用:只在单节点内需要的数据用<<local>>,避免污染全局命名空间;注意局部变量不得与全局变量重名、且每节点只能声明一次。
  5. 善用<<visit>>拆分对话:将大段对话拆成小节点并通过<<visit>>组合,可显著提升脚本的可读性与复用性;需要"一去不回"时用<<jump>>,需要提前终止用<<stop>>
  6. 自定义命令负责游戏动作:把表现型动作(动画、镜头、音效、任务进度、交易等)封装成自定义命令,让对话编写者专注于叙事本身。

结语

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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 8:27:03

Fiddler+夜神模拟器绕过SSL Pinning,实现抖音HTTPS明文抓包

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 8:26:09

PLC编程思路本质:用状态机重构工业控制逻辑

1. 这不是教科书&#xff0c;是我在产线调试三年后撕掉的“编程说明书”PLC编程思路——这五个字在自动化工程师的日常里&#xff0c;比咖啡因还提神。但凡你在车间蹲过、在控制柜前熬过通宵、被甲方临时改需求逼到墙角&#xff0c;就一定明白&#xff1a;真正卡住你的从来不是…

作者头像 李华
网站建设 2026/9/16 8:25:38

大模型推理优化:从GPU利用率到单位算力价值

1. 标题背后的真实战场&#xff1a;一场被低估的AI基础设施军备竞赛“冲击500亿估值前夜&#xff0c;Kimi把最贵的家底塞进了便宜套餐”——这句话乍看像营销话术&#xff0c;实则是一张精准的行业切片。我盯这个标题盯了三天&#xff0c;不是因为好奇估值数字&#xff0c;而是…

作者头像 李华
网站建设 2026/9/16 8:25:37

FreeRTOS软件架构实战:任务划分、通信机制与内存管理

搞嵌入式的大部分人&#xff0c;接触 RTOS 的第一站都是 FreeRTOS。原因很简单&#xff1a;它免费、源码开放、资料多、生态成熟&#xff0c;不管是小家电、电动工具&#xff0c;还是工业控制器、物联网网关&#xff0c;几乎都能看到它的身影。但很多人学到后面会卡在一个地方&…

作者头像 李华
网站建设 2026/9/16 8:24:53

行业大模型技术演进与垂直应用实践指南

1. 行业大模型技术演进全景图过去三年&#xff0c;大模型技术经历了从通用到垂直的快速进化。2021年GPT-3的横空出世展示了通用大模型的惊人潜力&#xff0c;但随之而来的行业应用困境促使技术路线发生重要分化。当前主流技术迭代呈现三个明确方向&#xff1a;首先是架构轻量化…

作者头像 李华
网站建设 2026/9/16 8:24:22

【javaweb】day4

1.flex弹性布局&#xff1a;这个是一维布局&#xff0c;就是说只能横着或竖着布局&#xff08;默认横向&#xff09; 首先display:flex定义弹性布局 再接着使用属性进行布局 2.post提交方式&#xff1a; get提交方式&#xff1a; 3.表单项的标签其实就只有三个&#xff0c;分…

作者头像 李华