变量
一个值存在哪里、能活多久,以及该选哪个作用域
项目里的每个值都得有个落脚处,而这个选择本质上是在选生命周期:一个随所在界面一起消亡的计数器、一个必须跟着玩家存档走的标记、一个要比每一次通关都活得更久的设置项。作用域一共四种,选错是「我的值不见了」最常见的来源
逐引脚的签名在变量节点。本页讲的是该选哪个作用域,以及值在那之后会经历什么
四种作用域
| 作用域 | 声明方式 | 值存放在 | 何时重新开始 |
|---|---|---|---|
| 蓝图变量 | 图中的一个Var节点 | 内存,每个宿主实例一份 store | 该宿主实例消失时 |
| 场景变量 | 场景中的一行/local | 存档文件,作为该场景的Scene.local | 进入该场景时 |
| 存档变量 | 故事中的一行/save | 存档文件,作为一个Storable命名空间 | 开始新游戏时 |
| 持久变量 | 蓝图编辑器的成员面板,或一行/global | 项目之外的宿主存储,以storageKey为键 | 永不——只有你自己清除 |
蓝图变量
在蓝图的任意位置放一个Var(声明变量)节点,它就为整份蓝图声明了一个变量。Var没有引脚,也从不执行:运行时会在第一个事件触发之前扫描该蓝图的每一张图——event、function、macro 一视同仁——找到的每个Var都成为这份蓝图 store 中的一个槽位。给它填上Name、Data type和Default。除widgetValue外的每种宿主都能声明
值存在一个挂在宿主实例上的 store 里,而不是挂在图上,也不是挂在某条事件链上。在同一个按钮上点两次,读到的是同一个计数器。什么算一个实例,取决于宿主:
| 宿主 | 每份 store 对应 | 什么会让它重新开始 |
|---|---|---|
globalMain | 该全局蓝图 | 应用运行期间不会 |
surfaceMain | 该 Surface 每一次被压入导航栈 | 离开该 Surface 后再次打开 |
widgetMain · componentWidgetMain | 控件实例——列表中重复出现的每一项都算自己的实例 | 控件卸载 |
sharedAsset | 该共享蓝图 | 应用运行期间不会 |
storyAction | 该图的一次调用 | 每一次调用 |
Get Var(读取变量)和Set Var(写入变量)提供三组变量,顺序恒定:页面(当前图所属 Surface 的surfaceMain蓝图)、蓝图(你正在编辑的这一份)、全局(globalMain蓝图)。若某份蓝图本身已经是其中之一,它只出现一次,不会重复。组内按名称排序;只有当两个可访问变量重名时,作用域标签才会印在名称旁边——于是常见情况下列表干干净净,而真正的重名一眼就能看到
编辑图不会抹掉运行中的值。store 被重新取用时,新声明的变量会被填入其默认值,不再声明的变量会被移除,其余槽位保持原有的值
蓝图变量从不写入存档。它只在一个宿主实例存活期间持有值,一刻也不多——存读档、开新游戏、关闭窗口,它都不会留下任何东西。如果一个值必须跟着玩家回来,它属于场景变量、存档变量或持久变量
场景变量
场景变量属于某一个故事场景,由 NarraLeaf 的Scene.local支撑。把它声明成场景内的一行。这一行就是变量本身:嵌在分支里的声明同样为整个场景声明,把该行禁用也不会取消声明,因为声明不是一条会执行的语句
Scene.local在每次进入场景时都会被清空,并重新应用已声明的默认值。所以场景变量既在存档文件里、能随一次场景中途的读档回来,但再次走进那个场景时,它会从默认值重新开始
存档变量
存档变量属于故事文档而不属于某一个场景,该故事的每个场景都能看见它。它由一个带着全部已声明默认值创建出来的Storable命名空间支撑,因此新游戏从这些默认值开始,而读档会还原存档中保存的内容
存档变量与持久变量的值必须可序列化;写入函数、symbol 或 bigint 会在运行时被拒绝,而不是悄悄产出一份读不回来的存档
持久变量
持久变量是项目级的:一份定义,对每份蓝图和每个故事都可见,而它的值完全在存档文件之外。语言选择、「看过片头没有」、图鉴解锁,都放在这里
定义存放在项目的变量注册表(editor/variables.json)中,从蓝图编辑器成员面板的持久变量分组里编写。那里的编辑与你正在编辑的蓝图共用同一条撤销栈,所以一次 Ctrl+Z 会同时撤回变量改动和节点改动。注册表会被烘焙进开发模式 bundle 和游戏包一起分发;运行时只读它,从不写它
值存放在宿主管理的存储中,以条目的storageKey为键,且绝不在项目目录内。作者时它们位于 Studio 自己的用户数据下,按项目分键;在发布的游戏中它们位于游戏自己的用户数据下。把项目复制到另一台机器不会带走它们
Get Persistent(读取持久变量)和Set Persistent(写入持久变量)是 latent 的,因为宿主的读写是异步的——这同样是它们不能出现在function图中的原因。当某个键从未被存过任何值时,Get Persistent回退到编写的默认值,并且交给你的是它的深拷贝,所以改动读到的东西不会波及定义本身
storageKey只在创建时铸造一次,重命名变量不会改变它——正是这一点让玩家已存的值在重命名之后依然解析得到。反过来说:删除一个持久变量不会删除该键下已经存好的值,而用同一个名字重新建一个会得到一个新键,读到的是默认值。要改名就改名,别删了重建
该选哪个作用域
从上往下读,停在第一条成立的:
- 这个值只在当前界面、控件或 action 存活期间有意义 → 蓝图变量
- 它描述玩家在某一个场景内走到了哪 → 场景变量
- 读档时丢掉它玩家会不高兴 → 存档变量
- 它必须横跨玩家手上的每一个存档文件都成立 → 持久变量
优先选能满足需求的最窄作用域。更宽的作用域不是免费的:每个存档变量都让每一份存档变大,而每个持久变量都是一笔在它含义改变那天需要你手工迁移的数据
什么能活下来
| 作用域 | 存档与读档 | 新游戏 | 重新构建项目 |
|---|---|---|---|
| 蓝图变量 | 没了——从不写入存档 | 没了 | 本来就没存过 |
| 场景变量 | 随存档还原,再次进入该场景时又会重置 | 回到声明的默认值 | 已有存档保留各自的值 |
| 存档变量 | 随存档还原 | 回到声明的默认值 | 已有存档保留各自的值 |
| 持久变量 | 不受影响——它不在存档里 | 不受影响 | 只要storageKey没变就保留 |
类型
Var提供九种类型。你选的那一种决定卡片上Default的编辑器,也决定投影到Get Var和Set Var上的类型
valueType | 默认值 | 说明 |
|---|---|---|
string | "" | |
integer | 0 | 可接入float或string输入 |
float | 0 | 可接入string输入 |
boolean | false | |
json | {} | 接受array来源 |
array | [] | 可接入json输入;json不能反向接回 |
Timer | null | 用来存放 Delay 交回的令牌 |
AnimationToken | null | 用来存放动画交回的令牌 |
any | null | Default字段被禁用——见下文 |
数值接到string的连线会在取值时做转换。其余允许的配对都是精确匹配或加宽,绝不会背着你收窄
场景变量、存档变量和持久变量使用一个更窄的封闭集合——boolean、number、string、json——因为这些值会被序列化进存档文件或宿主存储,而一个活的Timer没有什么可以序列化的
默认值
默认值以真正的 JSON 值存储,而不是字符串化的 JSON。json默认值{"hp": 3}在文档里就是一个对象,array默认值就是一个数组——正因如此,编辑器才能对你填的字面量做类型检查,运行时也能直接交出它而无需中间的一次解析
默认值在创建 store 时会被深拷贝。每个宿主实例都从自己的一份副本开始,所以运行时往json变量里写东西绝不会反过来改掉编写的默认值,共用同一个模板的两个列表项也不会共用同一个对象
声明时完全没有填默认值的变量,起始值为null
any是唯一一种起始值不由你决定的类型。它恒为null,卡片上以一个禁用的null字段把这件事说清楚。要给它值,用Set Var
Get Var与Set Var上的类型推断
两个节点都把自己的value引脚声明为any,由编辑器把所选变量的类型投影上去。Get Persistent和Set Persistent同理。这一次投影驱动了下游的一切:节点卡片上印出什么、拖动连线时哪些引脚会高亮、松手时创建浮窗提供哪些节点、一条连接会不会被接受,以及 graph validation 检查什么
改变某个Var的Data type,该图中所有指向它的Get Var和Set Var上的投影都会被改写
如果这次改写让某条已有连线变得不兼容,这条连线会保留。validation 会在它上面报告类型不匹配,并把它留在图里——为了执行一秒钟前刚被改掉的类型而悄悄删掉作者的连线,毁掉的东西比省下的多。要么修这条线,要么把类型改回去
Get Var或Set Var所选的变量不复存在时——被删除,或从节点所处的位置已不可达——处理方式相同:在节点上报一条诊断,而不是改写这张图
注册表与重名
持久变量有两处编写入口:项目注册表,以及故事中的声明行。每一个消费方——故事编译器、故事变量面板、蓝图成员面板——读的都是这两者以storageKey为键合并出的同一份视图,因此它们不可能对「存在哪些变量」产生分歧
一个显示名称同时出现在两处,就是一次真正的歧义,因为作者眼前出现了顶着同一个名字的两个变量。编译会就此报告一条警告,指名该变量并说明对它的引用是有歧义的。同一处入口内重名的两行走不到这一步:重复项在输入的地方就被拒绝了
Blueprint Value
Blueprint Value 是绑定到单个控件属性上的widgetValue蓝图,它的创建浮窗被刻意限制为安全的取值类节点。对变量而言有三条后果:
- 它不能声明
Var。 该节点不在它的创建浮窗里,声明变量的扫描也会直接跳过widgetValue宿主 - 它不能读写持久变量。
Get Persistent和Set Persistent是 latent 的,而值绑定必须在被询问的那一拍里给出值——没有地方安放一个待定的 promise - 它照样能读写自己看得见的变量。
Get Var和Set Var在白名单上,它们解析到页面蓝图和全局蓝图——那就是全部可访问集合,因为值蓝图自己一个变量也不声明
故事变量与表达式
故事变量和蓝图变量是两套独立的机制,只在一处交汇:持久作用域——两边都往里声明,两边也都读它
在场景编辑器里输入一行命令来声明故事变量:
/local hp 100 type=number desc='Player health'
/save chapter 1 type=number
/global seenIntro false type=boolean/local声明场景变量,/save声明存档变量,/global声明持久变量。/var和/persis仍然可用,作为/save和/global的别名。没有显式的type=时,由默认值自身的类型决定;完全没有默认值的声明是boolean
故事表达式——/set的右侧、/if的判定,以及行内文本插值——按名称寻址变量。裸写的名称沿场景 → 存档 → 持久依次查找,命中即止,因此最窄的作用域获胜。名称被遮蔽时,直接点名作用域:
/set gold gold + 10
/if scene.gold >= 100可用前缀是scene. / local.、saved. / var.,以及persis. / persistent. / global.。表达式在你输入的当下就被解析,文档存的是语法树而不是文本,所以运行时不会再解析一次;这门语言也无法命名任何宿主对象,因此没有沙箱可逃
故事 action 蓝图能触及全部三种故事作用域:Get Scene Var / Set Scene Var、Get Saved Var / Set Saved Var,以及Get Persistent / Set Persistent。这四个故事节点只对storyAction宿主存在——在运行中的故事之外,既没有场景,也没有Storable可以对话