Flame 游戏引擎 Jenny 对话系统随机函数指南:dice、random与random_range的原理与实战
【免费下载链接】flameA Flutter based game engine.项目地址: https://gitcode.com/GitHub_Trending/fl/flame
本篇技术指南聚焦 Flame 游戏引擎内置对话语言系统 Jenny(基于 YarnSpinner 语法)中三个随机函数:dice(n)、random()与random_range(a, b)。文中以 random.md 为骨架,结合 packages/flame_jenny 包内的源码实现与单元测试,系统讲解每个函数的调用语义、参数约束、截断规则、错误行为、可复现随机源的替换方式,以及在游戏叙事(掷骰、概率事件、随机掉落)中的落地写法。读完本文,你将能在自己的 Yarn 脚本中正确、稳妥地使用全部随机能力,并能用种子化随机源进行可复现调试。
随机函数在 Jenny 中的定位
在 Jenny 的表达式系统中,函数与任何编程语言或数学中的函数概念一致:接收若干参数,计算并返回结果,调用时函数名后必须紧跟括号(即使无参也要写())。Jenny 内置约 20 个函数,按用途分为随机、数值、类型转换与杂项四组,完整清单见 functions.md,其中随机组正是本文要讲的三个函数:
dice(n):模拟掷骰;random():生成[0, 1)均匀随机浮点数;random_range(a, b):在自定义闭区间内取随机整数。
三者每次求值都会产生不同结果。关键设计在于:它们并非各自使用独立的随机源,而是统一复用YarnProject.random这个随机数生成器。该字段在 yarn_project.dart 中定义为:
/// Random number generator used by the dialogue whenever randomization is /// needed. Random random;默认情况下random在YarnProject构造时以Random()初始化(无固定种子),每次运行结果随机。但你可以替换它为自定义生成器——例如Random(seed)——以实现两件事:
- 调试可复现:同一脚本同一种子,每次跑出完全相同的随机序列,便于定位逻辑问题;
- 存档一致性:防止玩家重载游戏后获得与之前不同的随机结果(例如掷骰事件重载后结果变了)。
这一点在测试中体现得淋漓尽致:dice_test.dart与random_range_test.dart均通过YarnProject()..random = Random(420)之类的方式注入固定种子,从而断言精确的期望输出。
dice(n):掷一颗 n 面骰
语义与边界规则
dice(n)返回1到n之间(含两端)的随机整数。例如dice(6)等价于掷一颗常规六面骰,结果落在 1~6。规则要点:
- 参数
n必须是数值类型,且大于等于 1; - 若
n非整数,运行时会被截断为整数,因此dice(3.5)等价于dice(3); - 若
n截断后小于等于 0,运行时抛出DialogueError(错误信息为Argument to dice() must be positive: $n); - 参数个数不为 1、或参数不是数值类型,则在编译阶段直接报
TypeError。
基本用法
<<set $roll = dice(6)>> <<set $coin_flip = if(dice(2) == 1, "H", "T")>>第一行把一次 d6 的结果存入变量$roll;第二行用dice(2)模拟硬币(1 为正面 "H",2 为反面 "T"),再配合内置函数if()做条件映射。想实现"掷两枚 d6 求和"这种经典骰子判定,直接相加即可:
<<set $roll_2d6 = dice(6) + dice(6)>>
<<set>>指令与if()条件函数的完整说明分别见 set.md 与 misc.md。
源码级原理
dice的实现位于 dice.dart。其make静态构造器负责编译期校验:参数数量必须恰好为 1,且args[0].expression.isNumeric必须为真,否则调用errorFn报TypeError。真正的随机逻辑在valuegetter 中:
@override num get value { final n = _n.value.toInt(); if (n <= 0) { throw DialogueError('Argument to dice() must be positive: $n'); } return _yarn.random.nextInt(n) + 1; }可见其工作分三步:先把参数toInt()截断;再检查是否大于 0(不满足抛DialogueError);最后借助_yarn.random.nextInt(n) + 1得到 1~n 的均匀整数。nextInt(n)本身返回[0, n),加 1 后即得到闭区间[1, n],两端都可达。
dice通过 _common.dart 中的builtinFunctions注册表('dice': DiceFn.make)接入解析器。值得一提的是,该注册表注释提示:新增内置函数时需同步更新doc/_sphinx/extensions/yarn_lexer.py中的语法高亮词表,这保证了文档示例的代码着色与真实函数名保持一致。
测试验证
dice_test.dart 覆盖了完整的边界情形:
- 注入
Random(420)后,连续四轮{dice(6)}的期望输出依次为3、6 3、2 1 2、1 6 4 3,证明序列可复现且分布在 1~6; DiceFn(const NumLiteral(3.14), ...)连续取值得到1、1、2、3,实证小数参数被截断(等价于dice(3));dice(0)触发hasDialogueError('Argument to dice() must be positive: 0');dice()(无参)、dice(3, 6)(多参)、dice("three")(字符串)均在YarnProject.parse阶段报TypeError,并给出精确的行列号定位。
random():均匀随机浮点数与概率事件
语义与用法
random()返回一个0到1之间(含 0、不含 1)的随机浮点数,用于实现指定概率触发的事件。典型场景如下:
<<if random() < 0.001>> // This happens only with 0.1% probability You found it! The Holy Grail! <<endif>>random() < p成立的概率恰为p,因此只需调整阈值即可精确控制事件出现频率:< 0.001对应 0.1%,< 0.25对应 25%,依此类推。<<if>>指令语法详见 if.md。它同样可以组合进表达式,例如<<set $random = random()>>将本次采样存入变量以便复用(同一次事件判定中多次调用random()会得到不同数值,务必注意)。
源码级原理
random.dart 的实现极其简洁:make只校验"不允许携带任何参数"(传参即编译报错function random() requires no arguments),取值逻辑仅一行:
@override num get value => _yarn.random.nextDouble();nextDouble()是dart:math.Random的标准 API,返回[0, 1)区间内均匀分布的浮点数,与文档语义完全一致。因为整段随机逻辑都汇聚在可替换的_yarn.random上,只要注入带种子的生成器,random()的取值序列同样完全可复现。
random_range(a, b):自定义闭区间内的随机整数
语义与边界规则
random_range(a, b)返回a到b之间(含两端)的随机整数,可看作dice()的泛化版本——用于需要自定义范围的场合(不限于从 1 开始)。规则要点:
- 参数
a、b必须为数值类型,求值时会各自toInt()截断为整数; - 要求
a <= b,否则运行时抛出DialogueError(错误信息为In random_range(a=$lower, b=$upper) the upper bound cannot be less than the lower bound); - 参数个数不为 2、或任一参数非数值,均在编译期报
TypeError(错误信息分别为function random_range() requires two arguments、the first argument should be numeric、the second argument should be numeric)。
基本用法
原文档给出的示例是用它模拟抛硬币(0 与 1 等概率):
<<set $coin_flip = bool(random_range(0, 1))>>random_range(0, 1)只会产生 0 或 1,配合内置类型转换函数bool()(规则见 type.md)即得到布尔值,简洁地完成二分支随机。自定义范围的更多场景:random_range(3, 6)模拟四面的 3~6 骰、random_range(0, 100)生成百分比、random_range(-12, 12)生成含负数的偏移量。
源码级原理
random_range.dart 的valuegetter 清晰地呈现了算法:
@override num get value { final lower = _a.value.toInt(); final upper = _b.value.toInt(); if (upper < lower) { throw DialogueError( 'In random_range(a=$lower, b=$upper) the upper bound cannot be less ' 'than the lower bound', ); } return _yarn.random.nextInt(upper - lower + 1) + lower; }其核心是nextInt(upper - lower + 1) + lower:区间宽度upper - lower + 1保证了闭区间内每个整数等概率出现(包括两端),加lower完成偏移。当a == b时,宽度为 1,nextInt(1)恒返回 0,函数退化为恒等返回a——这一退化行为在测试中有专门断言。
测试验证
random_range_test.dart 覆盖:
- 注入
Random(7)后,random_range(3, 6)、random_range(-12, 12)、random_range(0, 100)、random_range(50, 70)的期望输出分别为3、0、57、59,验证含负数的范围与正确性; - 100 次
random_range(-20, 20)压力测试,统计出的min == -20、max == 20,实证区间边界确实可达; random_range(0, 0)恒为 0(min == max的退化情形);random_range(10, 0)触发hasDialogueError,报错信息与源码完全一致;random_range(1)(少参)、random_range(true, 12)(首参非数值)、random_range(3, "seventeen")(次参非数值)均抛TypeError并带行列号。
编译期检查与运行时错误的分工
通过源码与测试可以总结出 Jenny 对随机函数的两道防线,这是编写 Yarn 脚本时需要牢记的心智模型:
| 错误类别 | 触发条件 | 抛出时机 | 示例 |
|---|---|---|---|
TypeError(编译期) | 参数个数不符、参数类型非数值、random()携带参数 | YarnProject.parse()解析阶段,附带行列号定位 | {dice()}、{dice(3, 6)}、{dice("three")}、{random_range(1)}、{random_range(true, 12)} |
DialogueError(运行时) | 数值截断后超出语义边界:dice的n <= 0;random_range的upper < lower | 表达式真正求值(对话执行)时 | dice(0)、random_range(10, 0) |
也就是说:能在编译期发现的写错(数量/类型)绝不拖到运行期,而涉及动态取值边界的问题(例如参数来自变量)则留待运行时校验并抛出带上下文的DialogueError。开发时应尽可能保证编译零错误,同时在运行时对可能越界的动态参数做好兜底判断。
实战组合:在游戏对话中安全使用随机
掷骰判定与概率分支
title: Treasure --- <<set $roll = dice(20)>> <<if $roll >= 15>> Your keen eyes spot a hidden lever. (Rolled $roll) <<else>> You find nothing but dust. (Rolled $roll) <<endif>> ===把dice()结果先存入变量再参与多次判定,可避免多次采样导致逻辑不一致。
可复现调试
在初始化YarnProject时注入种子生成器,即可让随机序列确定化:
final project = YarnProject()..random = Random(2024);随后无论运行多少次,dice、random()、random_range的取值序列都完全一致,非常适合对齐期望输出、回归测试与排查概率逻辑;正式发布时再改回默认的无种子Random()。运行时完整接入方式可参考 jenny_runtime.md。
与其他函数的协作
随机函数可与数值函数(floor、round、int等,见 numeric.md)、类型转换函数(bool、number、string,见 type.md)自由组合。例如:
// 在 0~99 中随机取数并四舍五入到十位 <<set $luck = round(random_range(0, 99) / 10) * 10>> // 10% 概率的暴击,结果存为布尔 <<set $crit = bool(random_range(0, 9) == 0)>>小结
dice(n):1~n 闭区间随机整数,参数截断且必须 ≥ 1;random():[0, 1)均匀浮点数,适合random() < p的概率事件;random_range(a, b):a~b 闭区间随机整数,要求a <= b;- 三者共用
YarnProject.random随机源,可替换为种子化生成器实现调试可复现与存档一致性; - 参数数量/类型错误在编译期暴露,边界越界在运行期抛
DialogueError。
随机函数是 Jenny 叙事系统中最常用的表达式之一,正确理解其截断规则、区间闭包与随机源替换机制,能显著提升游戏内随机事件的可控性与可测试性。若需自定义更复杂的行为(如加权随机、洗牌),可参考 functions.md 中关于用户自定义函数的说明,结合 function_storage.md 将游戏引擎能力接入 Yarn 脚本。
【免费下载链接】flameA Flutter based game engine.项目地址: https://gitcode.com/GitHub_Trending/fl/flame
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考