技巧 · EXTRA
读写分离的变量权限:var<private> Ammo<public>
类成员变量可以「谁都能读、只有自己能改」——两个说明符各管一头,零样板拿到只读属性。
类里的变量可以「谁都能读、只有自己能接 Set」——一个小设置管「谁能读」、一个管「谁能改」,不写多余节点就白拿一个对外只读的变量。
进入拓展 →欢迎进入第二章「数据」。这一课发两件基础装备:刻好就不换的名字牌(常量),和随时能换内容的背包格子(变量 var)。学会类型标注、:= 和 set,再看懂两条最经典的编译报错,你就正式开始管理数据了。学会怎么给数据贴类型标签、怎么用 := 新建、怎么用 set(也就是蓝图里的 Set 节点)改值,再认得两条最常见的 Compile 报错,你就正式开始管理数据了。
游戏里的数据分两种脾气:一种从开局到结束都不该变——竞技场的名字、生命值上限、每回合的时长;另一种时时刻刻在变——金币数、分数、剩余弹药。Verse 给这两种数据发了两套装备,先看第一套:常量。
声明一个常量的完整写法是 名字:类型 = 值,三段式:名字是刻在牌子上的字,:类型 规定这块牌子只能配哪种值,= 值 是当场刻上去的内容。注意——没有任何额外关键字。在 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 是整数,所以 MaxPlayers 是 int。眼熟吗?我们前几课一直在写的 arena_device := class(creative_device):,其实就是给一个类挂名字牌,和给数字挂牌是同一个语法。
第三个例子用了简写 :=:名字和初始值一步到位,类型不用你手填,Verse 照着右边的值自己猜——12 是整数,于是 MaxPlayers 就是整数型(蓝图变量面板里的 Integer)。眼熟吗?我们前几课一直在写的 arena_device := class(creative_device):,其实就是给一个蓝图类挂名字牌,和给数字挂牌是同一个写法。
常量有两条铁律:第一,函数和模块里的声明必须当场给初始值,不存在「先挂块空牌子,以后再刻字」(类的字段可以例外——不给默认值时,创建实例的一方必须补上,后面讲类时再展开);第二,刻上去就焊死了,谁敢对常量喊 set,编译器立刻翻脸(第四节会看到这条报错长什么样)。经验法则:先按常量写,真的需要改再升级成 var——能不变的值就让它不变,读代码的人一眼就知道哪些东西是死的、哪些是活的。
常量有两条铁律:第一,函数和模块里声明的常量必须当场给初始值,不存在「先挂块空牌子、以后再刻字」(类的字段是例外——不给默认值时,谁把这个蓝图放进关卡、谁就得在 Details 面板里补上,后面讲类再展开);第二,刻上去就焊死了,谁敢给常量接一个 Set 节点,一按 Compile 就亮红报错(第四节会看到它长什么样)。经验法则:先按常量写,真需要改再加 var 升级成可变——能不变的值就让它不变,别人打开你的蓝图一眼就知道哪些是死的、哪些是活的。
轮到会变的数据了。每个角色都有背包:一格放药水、一格放金币。程序也需要这样的「格子」来存会变的数据,这就是变量。开格子要在声明前面加一个词:var。
var Health:int = 100 一口气干了四件事:var 是「我要开一个可变格子」的申请;Health 是格子上的标签;:int 规定格子只能装整数;= 100 是开箱自带的初始物品。和常量一样,函数和模块里声明的变量也必须给初始值——Verse 不允许空格子出厂(类字段的例外同上一节)。官方文档里 var 声明总是带完整类型标注,本教程也这么写:变量是要被反复修改的,把类型明明白白写出来,改的人心里才有数。官方文档里 var 声明总是把类型写全(就像在蓝图里新建变量时,Variable Type 那一栏一定要选好),本教程也这么写:变量是要被反复改的,把它装什么类型标明白,改的人心里才有数。
格子开好了,怎么换里面的东西?这是 Verse 最有个性的地方:必须先喊口令 set。
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 -= 30 和 set Health += 5 是在原值基础上增减(还有 *= 可用)。三行跑完,格子里躺着 55。为什么 Verse 对「修改」这么较真?因为改状态是 bug 的高发地段:强制写 set,你就永远不会「手滑改了个变量还不自知」。别的语言里要调试半天的坑,在 Verse 里根本挖不出来——少写一个词,编译器直接拦下,连运行的机会都不给。
set Health = 80 是整个换掉;set Health -= 30 和 set Health += 5 是在原值基础上加减(还有 *= 乘)。三行跑完,格子里躺着 55。放到蓝图里,这就是三个连着的 Set 节点,依次把 Health 改成 80、再减 30、再加 5。为什么 Verse 对「改值」这么较真?因为改状态是 bug 的重灾区:强制你显式接 Set,你就永远不会「手滑连错一根线、把某个变量悄悄改了还不自知」。这种在别处要盯着调试半天的坑,在 Verse 里根本挖不出来——少写一个 set,一按 Compile 就被拦下,连运行的机会都不给。
把常量和变量放进同一台设备,亲眼看它们各司其职:名字牌全程不动,背包格子被 set 两次,最后 Print 用字符串插值报出结果。点「运行下一步」。
把常量和变量放进同一台设备,亲眼看它们各司其职:名字牌全程不动,背包格子被 Set 两次,最后用 Print String 把结果打到屏幕上。那句 {RoomName}、{GoldCoins} 的写法叫「插值」,就是把大括号里的变量名换成它当前的值,相当于蓝图里给 Print String 的文字引脚接上变量再拼起来。点「运行下一步」。
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——函数里、模块里新建的变量都必须当场给个默认值。下面这台「药水商店」被挖掉了两个关键词,把它们填回去:
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 里在某一圈中新建的常量或变量,也只在这一圈(和它内部更小的圈)里认得;这一圈一结束,名字就作废了。
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
类成员变量可以「谁都能读、只有自己能改」——两个说明符各管一头,零样板拿到只读属性。
类里的变量可以「谁都能读、只有自己能接 Set」——一个小设置管「谁能读」、一个管「谁能改」,不写多余节点就白拿一个对外只读的变量。
进入拓展 →拓展 · EXTRA
为什么不能写全局 var?模块级变量的特殊规矩,以及它附赠的跨对局存档系统。
为什么不能在最外层建一个全局可变变量?模块级变量的特殊规矩,以及它附赠的「退出重进还在」的跨对局存档系统。
进入拓展 →拔高 · EXTRA
常量优先、set 显式、赋值可回滚——这些「怪癖」全部来自同一篇论文:Verse Calculus。
常量优先、改值必须显式接 Set、= 是比较不是赋值——这些「怪癖」全部来自同一篇论文:Verse Calculus。
进入拓展 →