Verse Wiki — 写给 UE6 时代的 Verse 互动手册
第二章 · 第 4 课

常量与变量:名字牌与背包格子

欢迎进入第二章「数据」。这一课发两件基础装备:刻好就不换的名字牌(常量),和随时能换内容的背包格子(变量 var)。学会类型标注、:= 和 set,再看懂两条最经典的编译报错,你就正式开始管理数据了。学会怎么给数据贴类型标签、怎么用 := 新建、怎么用 set(也就是蓝图里的 Set 节点)改值,再认得两条最常见的 Compile 报错,你就正式开始管理数据了。

一、常量:名字牌挂上就不换

游戏里的数据分两种脾气:一种从开局到结束都不该变——竞技场的名字、生命值上限、每回合的时长;另一种时时刻刻在变——金币数、分数、剩余弹药。Verse 给这两种数据发了两套装备,先看第一套:常量。

声明一个常量的完整写法是 名字:类型 = 值,三段式:名字是刻在牌子上的字,:类型 规定这块牌子只能配哪种值,= 值 是当场刻上去的内容。注意——没有任何额外关键字。在 Verse 里,「不可变」是默认待遇,不用申请。

arena_device.verse
using { /Fortnite.com/Devices }

arena_device := class(creative_device):

    # 完整写法:名字:类型 = 值
    MaxHealth:int = 100
    ArenaName:string = "新手竞技场"

    # 简写::= 让 Verse 自己推断类型
    # 12 是整数字面量,推断出 int
    MaxPlayers := 12

读图翻译:这段等于打开 arena_device 蓝图的「变量面板」,新建三个变量——MaxHealth(类型 Integer、默认 100)、ArenaName(类型 String、默认「新手竞技场」)、MaxPlayers(类型 Integer、默认 12)。三个都是常量:意味着整张蓝图里没有任何一个 Set 节点会碰它们。:= 这个写法相当于「新建变量并填好默认值」一步到位,而且类型那一栏 Verse 会照着默认值替你选好。

第三个例子用了简写 :=:声明加初始化一步到位,类型由右边的值自动推断——12 是整数,所以 MaxPlayersint。眼熟吗?我们前几课一直在写的 arena_device := class(creative_device):,其实就是给一个类挂名字牌,和给数字挂牌是同一个语法。

第三个例子用了简写 :=:名字和初始值一步到位,类型不用你手填,Verse 照着右边的值自己猜——12 是整数,于是 MaxPlayers 就是整数型(蓝图变量面板里的 Integer)。眼熟吗?我们前几课一直在写的 arena_device := class(creative_device):,其实就是给一个蓝图类挂名字牌,和给数字挂牌是同一个写法。

常量有两条铁律:第一,函数和模块里的声明必须当场给初始值,不存在「先挂块空牌子,以后再刻字」(类的字段可以例外——不给默认值时,创建实例的一方必须补上,后面讲类时再展开);第二,刻上去就焊死了,谁敢对常量喊 set,编译器立刻翻脸(第四节会看到这条报错长什么样)。经验法则:先按常量写,真的需要改再升级成 var——能不变的值就让它不变,读代码的人一眼就知道哪些东西是死的、哪些是活的。

常量有两条铁律:第一,函数和模块里声明的常量必须当场给初始值,不存在「先挂块空牌子、以后再刻字」(类的字段是例外——不给默认值时,谁把这个蓝图放进关卡、谁就得在 Details 面板里补上,后面讲类再展开);第二,刻上去就焊死了,谁敢给常量接一个 Set 节点,一按 Compile 就亮红报错(第四节会看到它长什么样)。经验法则:先按常量写,真需要改再加 var 升级成可变——能不变的值就让它不变,别人打开你的蓝图一眼就知道哪些是死的、哪些是活的。

二、变量 var:背包格子,set 才能换

轮到会变的数据了。每个角色都有背包:一格放药水、一格放金币。程序也需要这样的「格子」来存会变的数据,这就是变量。开格子要在声明前面加一个词:var

var Health:int = 100 一口气干了四件事:var 是「我要开一个可变格子」的申请;Health 是格子上的标签;:int 规定格子只能装整数;= 100 是开箱自带的初始物品。和常量一样,函数和模块里声明的变量也必须给初始值——Verse 不允许空格子出厂(类字段的例外同上一节)。官方文档里 var 声明总是带完整类型标注,本教程也这么写:变量是要被反复修改的,把类型明明白白写出来,改的人心里才有数。官方文档里 var 声明总是把类型写全(就像在蓝图里新建变量时,Variable Type 那一栏一定要选好),本教程也这么写:变量是要被反复改的,把它装什么类型标明白,改的人心里才有数。

格子开好了,怎么换里面的东西?这是 Verse 最有个性的地方:必须先喊口令 set

backpack_device.verse
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }

backpack_device := class(creative_device):

    # var:开一个可变格子,初始 100
    var Health:int = 100

    OnBegin<override>()<suspends>:void =
        set Health = 80
        set Health -= 30
        set Health += 5
        Print("Health: {Health}")

set Health = 80 是整个换掉;set Health -= 30set Health += 5 是在原值基础上增减(还有 *= 可用)。三行跑完,格子里躺着 55。为什么 Verse 对「修改」这么较真?因为改状态是 bug 的高发地段:强制写 set,你就永远不会「手滑改了个变量还不自知」。别的语言里要调试半天的坑,在 Verse 里根本挖不出来——少写一个词,编译器直接拦下,连运行的机会都不给。

set Health = 80 是整个换掉;set Health -= 30set Health += 5 是在原值基础上加减(还有 *= 乘)。三行跑完,格子里躺着 55。放到蓝图里,这就是三个连着的 Set 节点,依次把 Health 改成 80、再减 30、再加 5。为什么 Verse 对「改值」这么较真?因为改状态是 bug 的重灾区:强制你显式接 Set,你就永远不会「手滑连错一根线、把某个变量悄悄改了还不自知」。这种在别处要盯着调试半天的坑,在 Verse 里根本挖不出来——少写一个 set,一按 Compile 就被拦下,连运行的机会都不给。

三、逐行运行:声明 → set → Print

把常量和变量放进同一台设备,亲眼看它们各司其职:名字牌全程不动,背包格子被 set 两次,最后 Print 用字符串插值报出结果。点「运行下一步」。

把常量和变量放进同一台设备,亲眼看它们各司其职:名字牌全程不动,背包格子被 Set 两次,最后用 Print String 把结果打到屏幕上。那句 {RoomName}{GoldCoins} 的写法叫「插值」,就是把大括号里的变量名换成它当前的值,相当于蓝图里给 Print String 的文字引脚接上变量再拼起来。点「运行下一步」。

loot_room_device.verse
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }

loot_room_device := class(creative_device):

    # 常量:名字牌,挂上就不换
    RoomName:string = "宝物室"

    # 变量:背包格子,里面的东西可以换
    var GoldCoins:int = 0

    OnBegin<override>()<suspends>:void =
        set GoldCoins = 30
        set GoldCoins += 20
        Print("{RoomName} 金币:{GoldCoins}")
输出日志

点「运行下一步」,看代码怎么一行行执行。

注意第 13、14 行:格子还是那个格子,里面的东西换了两次。而 RoomName 从头到尾没人动它——也动不了。这就是本课的全部世界观:该变的用 var + set,不该变的挂名字牌。该变的就新建变量、用 Set 节点改,不该变的就挂个名字牌、不给它接 Set。

四、常见坑:三个符号与两条报错

本课出场了三个长得像亲戚的符号,从其他语言来的同学十有八九会拿错。先上速查表:

本课出场了三个长得像亲戚的符号,一不留神就会看花眼、拿错。先上速查表:

写法 作用 例子
:= 声明并初始化(挂牌 / 开格子) MaxPlayers := 12
set … = 修改变量(只对 var 有效) set Score = 100
= 比较两边是否相等 Score = 100(这是提问,不是赋值!)

报错一:忘写 set。写下 Score = 100 想赋值?Verse 里压根没有 ==,单个 = 的分工就是「比较」——而且是一个「可失败表达式」(相等则成功,不等则失败,后面讲 if 的章节会正式认识它)。所以编译器不会说「你忘了 set」,而是给出一条让新手一头雾水的报错,大意是:这个可失败表达式必须放在失败上下文里。以后看到「failure context」几个字,先检查是不是把 set 落在了键盘上。

报错一:忘了 set。想改值,直接写了 Score = 100?Verse 里根本没有 ==,单个 = 的活儿就是「比较」——而且它是一个「可能走不通的判断」(相等就算走通,不等就走不通,后面讲 Branch 那种判断时会正式认识它)。所以一按 Compile,报错不会贴心地说「你忘了 set」,而是甩给你一句新手看不懂的话,大意是:这个可能走不通的判断必须摆在「允许走不通的地方」。以后 Compile 报错里蹦出「failure context」,先回头检查是不是把 set 漏掉了。

报错二:给常量赋值。对不带 var 的名字喊 set MaxHealth = 50,编译器直接拒绝——左边不是变量,没有格子可换。这条报错倒是很直白,它其实在提醒你做一个设计决定:这个值真的需要变吗?需要,就去声明处补上 var;不需要,就删掉这行 set。

报错二:给常量接 Set。对一个没写 var 的名字用 set MaxHealth = 50,一按 Compile 直接被拒——左边根本不是变量,没有格子可换。这条报错倒很直白,它其实在提醒你做个设计决定:这个值真需要变吗?需要,就回到声明处补上 var(把它变成能接 Set 的变量);不需要,就删掉这行 set。

顺手补一刀:声明必须初始化。只写 var Score:int 不给初始值,同样过不了编译——函数里和模块里的声明都必须当场给值。下面这台「药水商店」被挖掉了两个关键词,把它们填回去:

顺手补一刀:新建就得给默认值。只写 var Score:int 却不填初始值,一样过不了 Compile——函数里、模块里新建的变量都必须当场给个默认值。下面这台「药水商店」被挖掉了两个关键词,把它们填回去:

potion_shop_device.verse
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }

potion_shop_device := class(creative_device):

    # 常量:药水单价,全场不变
    PotionPrice:int = 25

    # 库存会变——开可变格子的关键字是?
    ____ Stock:int = 10

    OnBegin<override>()<suspends>:void =
        # 卖出一瓶——改格子前要喊的口令是?
        ____ Stock -= 1
        Print("单价 {PotionPrice},库存 {Stock}")

五、作用域初步:出了代码块,名字就失效

最后一个问题:名字牌挂上之后,在哪些地方有效?答案是:跟着代码块走。Verse 的代码块靠缩进划分——类的身体是一块,函数的身体是里面的一小块,以后学到的 if、for 的身体又是更里面的一小块。在某个代码块里声明的常量或变量,只活在这个块(以及它内部的子块)里;块一结束,名字随之失效。

最后一个问题:名字牌挂上之后,在哪些地方管用?答案是:跟着「代码块」走。Verse 靠缩进把代码圈成一块块,越往里缩进就是越小的一圈——整个蓝图类是最大一圈,一个函数图是里面小一圈,以后学到的 Branch、ForEach 的身体又是更里面的一圈。就像蓝图里的 Local Variable 只在自己那个函数图里认得,Verse 里在某一圈中新建的常量或变量,也只在这一圈(和它内部更小的圈)里认得;这一圈一结束,名字就作废了。

scope_demo_device.verse
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }

scope_demo_device := class(creative_device):

    OnBegin<override>()<suspends>:void =
        Reward:int = 100
        if (Reward > 50):
            # Bonus 声明在 if 的代码块里
            Bonus:int = 10
            Print("块内可以用:{Bonus}")
        # 到这里 Bonus 已经失效
        # Print("{Bonus}")  ← 取消注释就是编译错误

(这里的 if 先混个脸熟,条件判断在后面的章节细讲——现在只需要知道它冒号后面的缩进部分是一个代码块。)Bonus 声明在 if 的块里,所以只在块里有效;想在块外用它,编译器会说不认识这个名字。解法很朴素:要么把用它的代码挪进块里,要么把声明提到外层。

(这里的 if 先混个脸熟,它就是蓝图里的 Branch 判断,后面的章节细讲——现在只要知道:它冒号后面、往里缩进的那几行,是被圈进去的一小块。)读图翻译:Reward 在函数这一圈里新建;Bonus 是在 Branch 走通的那条分支里才新建的,所以只在那一小圈里认得。想在圈外用 Bonus,一按 Compile 就说不认识这个名字。解法很朴素:要么把用它的节点挪进那一圈里,要么把 Bonus 提到外面那一圈去建。

还有一条 Verse 的独门规矩:禁止同名遮蔽(shadowing)。在 C++ 或 JS 里,内层声明一个和外层同名的变量,内层的会悄悄「盖住」外层的——经典 bug 温床。Verse 直接一刀切:外层已经有 Count,内层再声明 Count := 7 就是编译错误。解法是换个名字,让每块名字牌独一无二。

还有一条 Verse 的独门规矩:禁止同名遮蔽(shadowing)。很多语言允许在里面一圈再建一个和外面同名的变量,里面那个会悄悄「盖住」外面那个——经典的 bug 温床。Verse 干脆一刀切:外面那圈已经有 Count,里面再建一个 Count := 7 就直接 Compile 报错。解法是换个名字,让每块名字牌都独一无二。

顺带埋个彩蛋:作用域这个洋葱的最外层是模块。你可能已经在盘算「那我在模块顶层放一个 var TotalKills:int = 0 当全局计数器」——很遗憾,这样写会报错:UEFN 对模块级 var 有特殊要求(必须是 weak_map 类型)。为什么会有这条规矩、以及它如何顺便送你一套跨对局存档系统,见文末拓展阅读。

顺带埋个彩蛋:作用域这个洋葱剥到最外层,是「模块」(可以理解成一整个资产文件夹)。你可能已经在盘算「那我在最外层放一个 var TotalKills:int = 0 当全局计数器」——很遗憾,一按 Compile 会报错:UEFN 对模块级的可变变量有特殊要求(必须是 weak_map 类型,类似「每个玩家一份、退出重进还在的 SaveGame」)。为什么有这条规矩、以及它如何顺手送你一套跨对局存档,见文末拓展阅读。

六、关卡挑战

背包整理完毕,来闯三道小关卡验收战利品。答对拿星星 ★,答错可以一直重试,零惩罚。

想声明一个整场游戏都不变的最大生命值,哪种写法才是 Verse 的常量?

写了 Score = 100(前面没有 set),编译器会怎么理解这行代码?

在 if 的代码块里声明的常量 Bonus,出了这个代码块之后还能用吗?在 Branch(蓝图的 if 判断)那一小圈里新建的常量 Bonus,出了这一圈之后还能用吗?

拓展阅读

技巧 · EXTRA

读写分离的变量权限:var<private> Ammo<public>

类成员变量可以「谁都能读、只有自己能改」——两个说明符各管一头,零样板拿到只读属性。

类里的变量可以「谁都能读、只有自己能接 Set」——一个小设置管「谁能读」、一个管「谁能改」,不写多余节点就白拿一个对外只读的变量。

进入拓展 →

拓展 · EXTRA

模块作用域变量与数据持久化:weak_map 与 persistable

为什么不能写全局 var?模块级变量的特殊规矩,以及它附赠的跨对局存档系统。

为什么不能在最外层建一个全局可变变量?模块级变量的特殊规矩,以及它附赠的「退出重进还在」的跨对局存档系统。

进入拓展 →

拔高 · EXTRA

为什么 Verse 默认不可变

常量优先、set 显式、赋值可回滚——这些「怪癖」全部来自同一篇论文:Verse Calculus。

常量优先、改值必须显式接 Set、= 是比较不是赋值——这些「怪癖」全部来自同一篇论文:Verse Calculus。

进入拓展 →